Merge origin/main into codex/content-handles

This commit is contained in:
moppeeer
2026-10-15 12:38:07 +02:00
217 changed files with 32942 additions and 2543 deletions
Generated
+53 -36
View File
@@ -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"
@@ -887,7 +896,7 @@ dependencies = [
[[package]]
name = "designcraft"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"arboard",
"designcraft-engine",
@@ -905,7 +914,7 @@ dependencies = [
[[package]]
name = "designcraft-cli"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -921,7 +930,7 @@ dependencies = [
[[package]]
name = "designcraft-color"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"kurbo",
"moxcms",
@@ -933,7 +942,7 @@ dependencies = [
[[package]]
name = "designcraft-compose"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"designcraft-color",
"designcraft-doc",
@@ -951,7 +960,7 @@ dependencies = [
[[package]]
name = "designcraft-doc"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"designcraft-color",
"designcraft-geom",
@@ -963,7 +972,7 @@ dependencies = [
[[package]]
name = "designcraft-engine"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"caseless",
"craft-print",
@@ -977,6 +986,7 @@ dependencies = [
"designcraft-idml",
"designcraft-images",
"designcraft-pdf",
"designcraft-pptx",
"designcraft-render",
"designcraft-textimport",
"designcraft-tools",
@@ -985,7 +995,7 @@ dependencies = [
"hayro-syntax",
"kurbo",
"log",
"quick-xml 0.38.4",
"quick-xml",
"regex",
"serde",
"serde_json",
@@ -998,7 +1008,7 @@ dependencies = [
[[package]]
name = "designcraft-epub"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"designcraft-color",
"designcraft-doc",
@@ -1007,7 +1017,7 @@ dependencies = [
[[package]]
name = "designcraft-fonts"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"harfrust 0.13.3",
"kurbo",
@@ -1019,7 +1029,7 @@ dependencies = [
[[package]]
name = "designcraft-format"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"designcraft-doc",
"serde",
@@ -1030,7 +1040,7 @@ dependencies = [
[[package]]
name = "designcraft-geom"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"flo_curves",
"kurbo",
@@ -1043,20 +1053,20 @@ dependencies = [
[[package]]
name = "designcraft-idml"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
"designcraft-doc",
"designcraft-geom",
"quick-xml 0.38.4",
"quick-xml",
"thiserror 2.0.21",
"zip",
]
[[package]]
name = "designcraft-images"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"designcraft-color",
"designcraft-fonts",
@@ -1067,7 +1077,7 @@ dependencies = [
[[package]]
name = "designcraft-mcp"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"designcraft-engine",
"designcraft-geom",
@@ -1080,8 +1090,9 @@ dependencies = [
[[package]]
name = "designcraft-pdf"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"craft-pdf",
"designcraft-color",
"designcraft-compose",
"designcraft-doc",
@@ -1092,7 +1103,6 @@ dependencies = [
"hayro-interpret",
"hayro-syntax",
"image",
"krilla",
"krilla-svg",
"kurbo",
"log",
@@ -1100,9 +1110,26 @@ dependencies = [
"tiff",
]
[[package]]
name = "designcraft-pptx"
version = "0.8.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
"designcraft-doc",
"designcraft-fonts",
"designcraft-geom",
"designcraft-images",
"image",
"quick-xml",
"skrifa 0.47.0",
"thiserror 2.0.21",
"zip",
]
[[package]]
name = "designcraft-render"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1120,16 +1147,16 @@ dependencies = [
[[package]]
name = "designcraft-textimport"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"designcraft-doc",
"quick-xml 0.38.4",
"quick-xml",
"zip",
]
[[package]]
name = "designcraft-tools"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1141,7 +1168,7 @@ dependencies = [
[[package]]
name = "designcraft-ui-egui"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"craft-ui",
"designcraft-color",
@@ -1169,7 +1196,7 @@ dependencies = [
[[package]]
name = "designcraft-web"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"designcraft-engine",
"designcraft-format",
@@ -2438,8 +2465,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",
@@ -3506,15 +3532,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.38.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.41.0"
@@ -5001,7 +5018,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
dependencies = [
"proc-macro2",
"quick-xml 0.41.0",
"quick-xml",
"quote",
]
@@ -5697,7 +5714,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.7.1"
version = "0.8.0"
dependencies = [
"serde_json",
]
+7 -4
View File
@@ -4,13 +4,15 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"]
[workspace.package]
version = "0.7.1"
version = "0.8.0"
edition = "2024"
license = "MIT OR Apache-2.0"
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" }
@@ -23,6 +25,7 @@ designcraft-render = { path = "crates/render" }
designcraft-pdf = { path = "crates/pdf" }
designcraft-idml = { path = "crates/idml" }
designcraft-epub = { path = "crates/epub" }
designcraft-pptx = { path = "crates/pptx" }
designcraft-textimport = { path = "crates/textimport" }
designcraft-format = { path = "crates/format" }
designcraft-tools = { path = "crates/tools" }
@@ -57,14 +60,13 @@ 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"
quick-xml = "0.41"
zip = { version = "3", default-features = false, features = ["deflate"] }
# ui
@@ -100,6 +102,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" }
+6
View File
@@ -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,
+4 -1
View File
@@ -87,8 +87,9 @@ by its own renderer. Try it yourself with `File → New → Sample Document`, or
Line breaks are identical on screen and in PDF.
- **Fast.** Multithreaded SIMD rendering (vello_cpu), copy-on-write documents with O(1) undo
snapshots, and cached composition.
- **Open.** A documented native format, IDML import and export, and PNG, PDF (PDF/X-4, PDF/A-2b)
- **Open.** A documented native format, IDML import and export, editable PowerPoint export, and PNG, PDF (PDF/X-4, PDF/A-2b)
and EPUB export.
No subscription, no licence server, no telemetry.
- **Agent-native.** Every menu item, tool gesture, panel control and dialog can be driven over a
JSON control channel and an **MCP server**, so Claude and other agents can lay out and edit
@@ -141,6 +142,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 +224,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).
+286 -1
View File
@@ -1,8 +1,225 @@
# DesignCraft roadmap
**Stage: pre-alpha** · next: alpha, ~5–7 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%)
- FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Linear/radial, midpoint, archival and print policies have synthetic regression coverage.
- AR1.1: Kashida justification tests verify space fallback without Tatweel fonts using an isolated font database, while retaining elongation assertions when fonts support it.
- AR1: Arabic IDML controls now retain character direction, kashida switches, diacritic offsets/forms, paragraph policy values and independent story/table directions. Shaping preserves joining context and script/language selection; paragraph-level bidi, contextual digit conversion, mark-safe elongation, RTL columns/tables and physical column insertion have regression coverage. Vendor-specific justification and diacritic presets remain explicit preflight warnings; see docs/arabic-typography.md.
- CJK1: IDML composite fonts now retain character mappings and metrics; explicit aki, tsume, jidori, em alignment/leading, named/custom kinsoku boundaries, hanging punctuation, tate-chu-yoko offsets and kenten symbols flow through composition and IDML export. Automatic kerning no longer imports inactive numeric values as enormous manual spacing. Composite definitions are editable through commands with undo/cache invalidation. Mojikumi definitions are retained and unsupported spacing-table / push-in-push-out policies are reported by preflight. Full InDesign CJK composition parity, frame-grid layout and detailed ruby remain unfinished. Warichu sets the selected run in smaller lines inside the line after the line is broken.
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.
## Status (2026-10-01)
**Working today**
- Document model: facing/non-facing spreads, parent pages with page-number markers, sections, layers, frames (text / graphic / unassigned), threaded stories, paragraph/character/object styles with based-on, swatches (process/spot/tints/gradients), corner options, text wrap, drop shadow, opacity/blend.
- Text engine: Knuth–Plass paragraph composer and single-line composer, dictionary hyphenation (public-domain Moby list + our own trained Liang patterns), justification with word/letter/glyph-scaling ranges, keep options (keep with next, keep lines together, widows/orphans), balance ragged lines, optical margin alignment, hyphenation zone/limit, columns, threading across frames and spreads, tabs, bullets/numbering, rules, shading, baseline grid, first-baseline options, vertical justification, wrap exclusions, overset detection, caret/hit testing.
- FID2: IDML underline/strikethrough Text Color resets survive style inheritance and roundtrip. Raster and PDF paint underlines behind glyphs, retain strikethrough above, and center rules on their offsets; synthetic overlap tests cover both backends.
- Rendering: vello_cpu (SIMD, multithreaded) — pages, items, images with mip levels, gradients, composed text.
- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts, document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches, Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes.
- Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows.
- Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom.
- PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. File › Export PDF… now opens an "Export PDF" options dialog (General / Compression / Marks and Bleeds / Advanced) and remembers the last-used settings across exports and sessions.
- IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`).
- Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu, Table panel, Create Table dialog, caret/typing/Tab navigation and cell selection in cells; `table.*` commands.
- Hyperlinks (text or frames → URL / e-mail / page) and bookmarks, exported as PDF link annotations and outline.
- EPUB 3 (reflowable) export: stories in reading order, CSS from paragraph/character styles, images, navigation (`file.exportEpub`, CLI `--export x.epub`).
- Data Merge (one linked CSV, TSV, text, or Excel file; text, image, QR, and hyperlink placeholders; preview; a new document with one record per page or records tiled on a single page), spell checking (public-domain Moby list + document dictionary, suggestions), Step and Repeat (count or grid), snippets, object styles, Numbering & Section Options.
- Footnotes: Type › Insert Footnote (caret moves into the note), Document Footnote Options (numbering style incl. symbols, start/restart per page/spread/section, prefix/suffix, reference position/character style, paragraph style, separator, spacing, first baseline, rule above); notes composed at the bottom of the referencing column with the body text making room; edited in place on the canvas; rendered, exported to PDF (real text) and IDML (InDesign's structure, verified opening in InDesign 2026) and imported from IDML; `footnote.*` commands.
- Paragraph Rules: Rule Above / Rule Below (on, weight, colour, tint, column or text width, offset, left/right indent) set in Type › Paragraph Rules…, the Paragraph panel menu and Paragraph Style Options › Paragraph Rules; inherited style values shown, and `type.para` / `style.paragraph.edit` change only the rule fields named.
- Cross-references and text anchors: Insert Cross-Reference (paragraph by style, or text anchor), 11 formats with InDesign's building blocks (full/partial paragraph, paragraph text/number, page number, anchor name, chapter, file name) plus user formats; resolved live at composition — never out of date, no Update step; unresolved destinations show `??`; PDF link annotations to the destination page; IDML export/import as InDesign cross-reference sources, hyperlinks, text destinations and formats (verified in InDesign 2026); `xref.*` / `anchor.*` commands.
- Index: page references (topic from the selection or up to 4 levels, sort keys; current page, to end of story, next n paragraphs, suppressed, See / See also), Generate / Update Index (section headings, nested or run-in, page ranges merged, Index Title / Section Head / Level 1–4 styles); IDML export/import as InDesign topics, page references and topic cross-references (verified in InDesign 2026); `index.*` commands.
- Text clipboard keeps formatting (character/paragraph formats, footnotes, cross-references, index markers, tables) and falls back to plain text when the system clipboard changed; Paste without Formatting (⇧⌘V); Change Case (UPPERCASE, lowercase, Title Case, Sentence case — formatting kept); Type › Insert Special Character / White Space / Break Character submenus.
- Anchored objects: items flow in the text inline (on the baseline, with Y offset; lines grow to fit) or above the line (left/center/right, space before/after); paste copied items into text to anchor them; Anchored Object Options and Release (back onto the page where shown); rendered, exported to PDF and to/from IDML (InDesign anchored object settings; verified in InDesign 2026); copy/paste and threading carry them; `anchored.*` commands.
- Ruler guides: drag out of the rulers (page guides, or spread guides on the pasteboard), drag to move, drop on a ruler to delete; Layout › Create Guides (rows/columns with gutters, fit to margins or page); Delete All Guides on Spread; `guide.*` commands.
- Links panel: every placed graphic with status (OK / Modified / Missing / Embedded, checked against the file on disk), page and effective PPI (low resolution flagged); Relink…, Go To, Update, Embed; `links.*` commands.
- Missing fonts: highlighted pink on screen (never in output), listed by Preflight; Type › Find/Replace Font (fonts in the document, missing first; replace in text, cells, footnotes and styles); `font.list` / `font.replace`.
- Missing glyphs, as in InDesign: a character the applied font lacks is drawn as that font's missing-glyph box (its .notdef; a drawn box when the font's is empty), on screen and in PDF (as a path, so PDF/A and PDF/UA exports take it), and Preflight lists the characters by font. Preferences › Composition › Draw Missing Glyphs from Fallback Fonts (`document.preferences {glyphFallback}`, stored with the document) draws them from other fonts instead; it is off in new documents and IDML imports, on in documents saved before it existed. A missing font's substitute always uses fallback fonts.
- Damage-region repaint: after an edit the canvas re-renders only the frames whose composed text (or the items that) changed and patches its texture — pixel-identical to a full render; typing in a frame repaints in under a millisecond instead of a full-viewport render (22 ms measured on the sample).
- Crash recovery: unsaved documents are written to the recovery folder every 30 s and reopened (unsaved, remembering their files) after a crash; File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`.
- Graphics backend chosen before the window exists (DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, OpenGL as the fallback; `WGPU_BACKEND` overrides) with a start-up fallback: a backend whose start never showed a frame is skipped at the next start and the status bar says so (`gpu.json`). Found on an AMD hybrid-graphics laptop whose Vulkan driver faulted at the first present.
- Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool).
- Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`.
- Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`).
- Gradient Feather (⇧G tool, `object.gradientFeather`): objects fade along a linear or radial opacity gradient, on screen and as a soft mask in PDF.
- Script Label (`object.label`, `object.findByLabel`), alt text (`object.altText`), Isolate Blending / Knockout Group (`object.transparencyGroup`; on screen, PDF isolates), Table ▸ Sort (`table.sortRows`, numeric-aware, header/footer fixed).
- File ▸ Export Text (`file.exportText`): a story as Text Only or RTF (fonts, styles, alignment, indents, tables as tab-separated rows); Export EPUB now asks for a path in the UI.
- Tagged PDF (`file.exportPdf {tagged}`, on by default from the UI): structure tree with stories as paragraphs in reading order, figures carrying their alt text, parent-page items and printer's marks as artifacts.
- Global Light (`object.globalLight`, `globalLight` on drop/inner shadows; Use Global Light in the Effects panel) and Generate Static Caption (`object.caption`: template over name, path, alt text, label, effective ppi, dimensions, format; any side, offset, style).
- Control panel scale X/Y, rotation and shear fields (`transform.info`, absolute `transform.set {scaleX, scaleY, rotation, shear}` about the reference point) and Preferences › Transformations are Totals for nested objects and placed graphics.
- Variable fonts: each named instance is a style (Font menu, shaping with feature variations, metrics, outlines) and embeds at its axis settings in PDF; free axis sliders still to come.
- Object ▸ Select (first/next above, next/last below, container, content, previous/next in group), full Object ▸ Convert Shape (`object.convertShape`), Table ▸ Split Cell Horizontally/Vertically.
- Attributes panel (`object.attributes`: Overprint Fill/Stroke/Gap, Nonprinting), with Overprint Gap in Overprint Preview and IDML.
- Page Numbering View (Preferences: section or absolute): page labels everywhere follow it; Go to Page takes section names, numbers or `+n`.
- Preferences › Advanced Type (document `advancedType`: superscript/subscript size and position; IDML TextPreference); subscripts now drop 33.3% like InDesign.
- Preferences › Composition › Highlight: keep violations, H&J violations (three shades), custom tracking/kerning, substituted fonts.
- File ▸ Export HTML (`file.exportHtml`): one self-contained page in reading order, styles as CSS, images inline, alt text from Object Export Options (also in EPUB).
- File ▸ Export PowerPoint (`file.exportPptx`, CLI `--export x.pptx`): every page becomes a slide of editable objects: shapes (preset or custom geometry, solid and gradient fills, dashed/compound strokes with arrowheads, shadows, glow, feather, opacity), pictures cropped like their frames (SVG kept as SVG), native tables (merged cells, fills, per-edge borders) and text boxes. By default the text keeps DesignCraft's line breaks, justified spacing and leading; threaded stories split at their frames and lines pushed aside by a wrap get boxes of their own. `lineBreaks: reflow` writes one box per frame in native columns instead. TrueType fonts are embedded where their licences allow, and objects without a PowerPoint equivalent are rasterised. Checked against PowerPoint for Mac and LibreOffice.
- Swatches panel colour groups (`swatch.newColorGroup`, `moveToGroup`, `ungroupColorGroup`, `renameColorGroup`; folders and a row context menu; IDML ColorGroup).
- Object ▸ Generate QR Code (`object.qrCode`: web link, text, SMS, email, business card; vector modules as a compound path, into a frame or re-encoded in place).
- Conditional text (`condition.new/options/delete/apply/list`, Conditional Text panel): hidden conditions drop their text from layout and exports, indicators underline on screen, IDML Condition/AppliedConditions.
- Endnotes (`endnote.insert/edit/delete/list/options`, Type ▸ Insert Endnote): references number through the document, the endnote frame on a new last page lists them under a heading; IDML keeps the listed text only.
- Editorial notes (`note.new/edit/delete/convertToText/list`, Notes panel): anchored in text, flagged on screen, never printed or exported.
- File ▸ Print Booklet (`file.printBooklet`): saddle-stitch or 2-up consecutive printer spreads as PDF, padded with blanks, with a gap between pages.
- Edit ▸ Transparency Blend Space (`edit.transparencyBlendSpace`; RGB for web/mobile documents; IDML TransparencyPreference) — exported PDFs give pages with transparency a DeviceCMYK or DeviceRGB page group, so viewers and RIPs blend in that space. On screen, spreads with transparency in a CMYK blend space are shown through the working CMYK profile (their colours keep to its gamut, as when composited in CMYK).
- High Contrast interface theme (fifth Interface Color Theme: black chrome, white text, yellow selection).
- Place Excel workbooks (.xlsx): the first worksheet's used range becomes a table (shared/inline strings, numbers, booleans).
- Object Library (`library.new/open/add/place/remove/list/json/close`, Library panel): `.dclib` files of snippets that keep stories, styles, swatches and images.
- Ink Manager (`ink.list`, `ink.options`; Swatches panel menu): All Spots to Process, per-ink conversion and ink aliases in PDF output; IDML ConvertToProcess/AliasInkName.
- Appearance of Black complete: Printing/Exporting rich black for RGB output (PNG), Overprint [Black] Swatch at 100% (document, IDML OverprintBlack).
- Hyperlinks and Bookmarks panels (Window ▸ Interactive): new from the selection, edit (`hyperlink.edit`), go to source (`hyperlink.goToSource`), rename bookmarks (`bookmark.rename`), go to page, delete.
- Nested line styles (`nestedLineStyles` paragraph attribute, Paragraph Style Options › Drop Caps and Nested Styles, IDML AllNestedLineStyles): the first lines restyle until the line breaks settle.
- User dictionary hyphenation exceptions (`hyphenation.addException/removeException/list`, Edit ▸ Spelling ▸ User Dictionary): `ex~am~ple` breaks only at `~`, a plain word never hyphenates.
- Frame Fitting Options (`object.fittingOptions`, Object ▸ Fitting ▸ Frame Fitting Options…): Auto-Fit refits on resize, fitting, Align From reference point, crop amounts; IDML FrameFittingOption.
- Paragraph borders (per-side weights, colour, tint, offsets) and shading offsets, drawn per column part of split paragraphs (also when overset); Paragraph panel controls; IDML ParagraphBorder*/ParagraphShading*Offset.
- Font menu: search, favourites (stars, Show Favorites Only; `favoriteFonts` preference) and each family previewed in its own face. Western (and other-script) families come first, then Japanese, Simplified Chinese, Traditional Chinese and Korean ones, each group alphabetical and set off by a separator; the group comes from the font's own data (`meta` languages, family names in a CJK language, OS/2 code pages, else what it covers), read once (installed fonts by the font scan, without loading them). CJK families show their native names, the English name on hover; Preferences › Type › Show Font Names in English (`showFontNamesInEnglish`) shows English names throughout. Search matches either name; documents store the English name. Every family list (Character panel, Control bar, Properties, style dialogs, Find Font, Glyphs) follows this order.
- Application preferences (engine `Prefs`) now persist across launches (desktop: prefs.json beside ui.json).
- View ▸ Fit Selection in Window (⌥⌘=) and Find/Change ▸ Object (`find.objects`, `find.changeObjects`: by fill, stroke, weight, opacity, kind, layer, label).
- Custom workspaces (`window.newWorkspace/deleteWorkspace/resetWorkspace`; workspace switcher shows the current one): bars and panel arrangement saved by name.
- Word/RTF Import Options (File ▸ Place with Import Options…, `place.styles`, `file.place {styleMap, styleConflicts}`): map imported styles onto the document's, use existing / redefine / auto-rename on conflicts, or remove formatting.
- Custom anchored objects (`anchored.insert/options {position: custom}`): placed relative to the anchor, frame, column, page margins or page edge with reference points and offsets, no space in the text, keep within column; IDML Anchored position.
- Variable font axes: Character panel sliders; any axis values (`fontStyle: "Bold {wght:650}"`) make that instance for layout, screen and PDF.
- Image Import Options › Crop to for placed PDFs (`file.place {pdfCrop: crop|trim|bleed|art|media}`, Place PDF dialog): the frame shows that page box.
- Stroke styles: striped (thick-thin… and custom bands), wavy and straight-hash strokes now draw on screen and in PDF; named custom stripe/dash/dot styles (`strokeStyle.new/delete/list`, Stroke panel Type list; IDML Striped/Dashed/DottedStrokeStyle).
- Object ▸ Primary Text Frame (`object.primaryTextFrame`): any text frame's story can become (or stop being) the primary story Smart Text Reflow follows.
- Column rules (Text Frame Options › Column Rules, `object.textFrameOptions {columnRule, columnRuleWeight, columnRuleColor, columnRuleTint, columnRuleOffset, columnRuleTopInset, columnRuleBottomInset}`, reported by `document.inspect`): a bar centred in each gutter of a multi-column frame, inside the insets and between the columns of right-to-left and vertical frames too, broken around paragraphs that span columns, with weight, colour, tint, horizontal offset and top/bottom insets; on screen, in PDF (tagged as an artifact) and in IDML (`ColumnRule…` attributes of `TextFramePreference`, also in object styles). The dialog shows the frame's options and changes only the fields edited. Rule stroke types and overprint aren't there yet.
- Place a page of an IDML or DesignCraft document (`file.place {layoutPage}`): its objects, stories, styles and swatches as one group (INDD files are not readable).
- Articles panel (`article.new/add/remove/options/delete/list`): reading order and content of EPUB and HTML exports.
- Object ▸ Clipping Path (`object.clippingPath {type: alpha|edges, threshold, tolerance}`): traces the placed image and makes the outline the frame.
- Gridify: arrow keys while dragging a frame or shape tool add columns/rows (`frame.grid`), one undo step.
- Gap tool (U, `gap.move`): drag the space between objects (or an object and the page edge) and both sides resize.
- XML basics (`xml.newTag/deleteTag/tag/mapStyle/structure`, `file.exportXml`, `file.importXml`; Tags panel with structure; View ▸ Show Tagged Frames): tagged frames export in reading order, mapped paragraph styles become elements, imported elements flow into frames with matching tags. Inline text tagging (`xml.tagText`, an `xmlTag` character attribute, exported as nested elements) and tag markers (View ▸ Structure ▸ Show Tag Markers) too. DTDs (`xml.loadDtd`, `xml.validate`, `xml.deleteDtd`; Tags panel): loading one adds its elements as tags and names the root; validation checks content models (sequences, choices, `?*+`, mixed content, EMPTY/ANY, parameter entities) and required attributes, and lists each problem with its element path.
- Adjust Layout / Layout Adjustment (`adjustLayout` on Document Setup and Margins and Columns): objects follow each page's margin box to its new size and position.
- Content Collector and Placer tools (B; `conveyor.collect/place/list/clear`): copies of objects (with stories, styles, images) ride a conveyor between pages and documents; Alt-click keeps them on it.
- Linked stories (Edit ▸ Place and Link, `story.placeAndLink/links/updateLink/unlink`): child stories copy a parent, show out of date in the Links panel and update.
- Edit ▸ Menus (`window.hideMenuItem`): hide menu items; menus with hidden items end in Show All Menu Items.
- View ▸ Separations Preview (`view.separations`): one process plate as ink density, or areas over an ink limit in red (a GCR preview of the rendered view; spot plates and the flattener preview aren't there).
- Separations Preview plates now come from each object's own colour (CMYK as authored; RGB and images through the colour settings, images via a cached 17³ table), so pure black shows only on the black plate.
- Language (Character panel ▸ Language, Properties ▸ Character, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). Spanish is hyphenated with Javier Bezos's Liang patterns (`hyph-es.tex` 5.0, MIT) through the `hypher` crate; hyphenation for other languages isn't bundled yet.
- Books (`book.new/open/add/remove/list/styleSource/paginate/syncStyles/exportPdf`, Book panel): `.dcbook` files, continuous page numbering, style sync from a style source, one merged PDF.
- Track Changes (`changes.track/list/acceptAll/rejectAll`, Type ▸ Track Changes): typing is marked added (highlighted on screen), deletions are kept but hidden from layout and exports until accepted or rejected. Per-change accept/reject and authors aren't there yet.
- Track Changes: per-change Accept / Reject (`changes.accept`, `changes.reject`) and a Track Changes panel listing every change.
- Move table rows and columns (`table.moveRow`, `table.moveColumn`, Table panel buttons; with whole rows or columns selected, drag them with the Type tool — `table.dropCells`).
- Distribute Rows Evenly (`table.distributeRows`, Table menu and panel): selected rows receive equal fixed heights preserving their composed total height, with undo/redo. Row-spanning merges and rows outside the composed frames are rejected; shrinking tall cells can overset their text.
- Object States (`states.create/show/rename/release/list`, Object States panel): multi-state objects show one state on screen and in PDF.
- Buttons (`button.set/clear/list`, Buttons and Forms panel): go to page / next / previous / first / last / URL as PDF link annotations. Form fields too (`form.set/clear/list`: text fields, check boxes, combo and list boxes, signature fields; required, multiline, defaults) exported as AcroForm widgets through an incremental update (not in PDF/X).
- Power Zoom (Hand tool: press and hold still for half a second, or Alt-press): zoom out to the spread, aim the red view rectangle, release to zoom back in there.
- Graphic cells (`table.placeGraphic`, `table.textCell`, Table › Convert Cell Type, Table panel): images in table cells, fitted or filled, clipped to the cell on screen and in PDF.
- Live Distribute (Space while dragging a selection handle; `transform.resize {distribute}`): objects keep their size and spread with the bounds.
- Liquid Layout (`liquid.pageRule/object`, `guide.liquid`, `layout.createAlternate`, Liquid Layout panel): scale / re-center / guide-based / object-based page rules applied on page resize and Document Setup; dashed liquid guides; alternate layouts as resized page copies in a named section. Text in an alternate layout stays linked to the original stories (Links panel shows them out of date; `story.updateLink`).
- Split Window / New Window (`window.split`, `window.newWindow`, Window ▸ Arrange): two side-by-side views of the document with their own zoom, scroll and render cache. New Window puts the second view in its own OS window (an egui viewport; a floating window on the web).
- Transparency flattener (`file.exportPdf {flatten: high|medium|low|ppi}`, Edit ▸ Transparency Flattener Presets): each object involving transparency becomes an opaque image of its area over what's beneath it, so everything else stays vector (spreads with transparent parent items are rasterised whole).
- Video and sound (place .mp4/.mov/.webm/.mp3/.wav…, `media.options/get`, Media panel): media frames show a poster or placeholder on screen, in print and in PDF, and export as HTML5 `<video>`/`<audio>` in EPUB. Interactive PDF export (`file.exportPdf {media: true}`, File ▸ Export PDF (Interactive)) embeds each file and plays it in a Screen annotation through a rendition action (controls, loop, play on page load). No in-app playback.
- Right-to-left text (World-Ready basics): right-to-left runs are shaped right to left, lines are reordered by the Unicode Bidirectional Algorithm (matched against the reference implementation), Paragraph Direction (`type.para {direction}`, Paragraph panel, IDML ParagraphDirection, EPUB `dir`). Digits (`digits`: Default, Arabic, Hindi, Farsi, Native by language; Character panel for right-to-left text; IDML DigitsType) draw 0–9 in another script. Kashidas (`kashidas` paragraph attribute, on by default): justified Arabic lines stretch at one join per word (after seen/sad when possible, never inside lam–alef) with a drawn tatweel before word spaces widen. Carets follow the drawing: the caret before a right-to-left glyph sits at its right edge, arrow keys move as drawn (by words in the line's direction), clicks find the nearest drawn caret position and selections highlight the selected glyphs. Binding: Right to Left (Document Setup, `layout.documentSetup {binding}`, IDML PageBinding) makes page 1 a left page and lays spreads out right to left.
- Interface language (Edit ▸ Interface Language, `app.language`): German, French, Spanish, Italian, Japanese, Simplified Chinese and Arabic menu titles, common menu items and panel names (our own translations). Common dialog, panel and tooltip labels are also localized, with English fallback for untranslated strings. Arabic translations cover every key in the shared interface translation table, including menu commands and utility labels. German and Japanese cover the whole interface: every key of every catalog. The Type menu's Story Direction, Horizontal/Vertical, Tate-Chu-Yoko, Ruby… and Kenten are translated too. Arabic labels use Unicode bidi run ordering and contextual shaping; welcome and new-document form layouts follow RTL direction independently of document binding. CJK and Arabic glyphs come from [craft-fonts](https://github.com/storytold/craft-fonts) when built with `CRAFT_FONTS_DIR` (all releases): BIZ UDPGothic and Noto Sans CJK SC for the UI (Chinese forms first in the Chinese interface), Noto Sans Arabic, and Mincho for Japanese documents (the web build embeds BIZ UDPGothic only); otherwise from system fonts. Ukrainian covers all 1,430 existing catalog keys, including publishing dialogs and built-in names, with persisted selection, grammatical count captions, English fallback and bundled Cyrillic UI glyph coverage.
- Page transitions (`page.transition/transitions`, Page Transitions panel): twelve transition types with speed and direction per spread, written into exported PDFs as `/Trans` entries through an incremental update.
- PDF/X-4 export (`file.exportPdf {standard: "x4"}`): an output intent with our own Generic CMYK ICC profile (lut16 tables generated from the parametric press model), GTS_PDFXVersion in Info and XMP, `pdf:Trapped` in XMP as in Info, RGB images without a profile tagged sRGB, no image interpolation (none in PDF/A either), and built-in PDF/X-4 checks reported as warnings. qpdf finds no errors in the result.
- EPS place: placed at its (high-resolution) bounding box and shown, printed and exported through its TIFF preview, or a placeholder when it has none. PostScript itself isn't interpreted.
- Preferences: Dictionary (user dictionary; `spelling.setWords/words`), Spelling (Dynamic Spelling: red squiggles on the canvas, also Edit ▸ Spelling), Autocorrect (word list, applied as you type), Notes, Track Changes, Story Editor Display and File Handling (recovery interval) sections.
- Colour management exposed: Edit ▸ Color Settings (`color.settings`: RGB/CMYK working spaces, intent, black-point compensation; `color.loadProfile` for ICC files; `color.convert` through the working spaces, with gamut checks) and View ▸ Proof Setup / Proof Colors (press, sRGB, legacy Mac RGB, colour-blindness simulations, simulate paper) on the canvas.
- Keyboard: Tab / Shift+Tab hide and show all panels / all but Tools (`window.hidePanels`, `window.hidePanelsExceptTools`); Cmd+F6 / Cmd+Shift+F6 cycle documents; panel fields are reached with Tab and Escape returns to the layout.
- Smart guides and snapping (View ▸ Snap to Guides / Snap to Document Grid / Smart Guides, `view.snapPreferences` and Preferences ▸ Guides & Pasteboard): moving, drawing, resizing, rotating and pen/anchor drags snap in order to the document and baseline grids, ruler guides, margins and columns, other objects' edges and centres (parent-page items too, by visible stroked bounds), equal spacing, matching dimensions and matching rotation, with guides, gap marks and measurements drawn on the canvas. Shift and Cmd constrain the gesture.
- View ▸ Rotate Spread (`view.rotateSpread`, `layout.rotateSpreadView`): the spread in view turns in quarter turns on screen (stored with the document; output is unaffected). The canvas maps each spread through its own transform, so tools, snapping, resize handles, guides, overlays and rendering all follow the turned spread.
- Vertical type (Vertical Type Tool, Type ▸ Story Direction `type.storyDirection`, IDML StoryOrientation): the direction belongs to the story, so every frame of a thread is vertical or horizontal together and a frame threaded onto a story takes its direction (`object.textFrameOptions {vertical}` sets the frames' stories); lines run top to bottom and follow each other right to left; the arrow keys follow them (Down and Up along the line, Left and Right to the next and previous line, keeping the position along it); CJK characters stay upright and other text turns, on screen and in PDF; CJK line breaking with basic kinsoku (also in horizontal text); Korean breaks at spaces, word by word (Hangul in any language, hanja in Korean text), or between syllables when the paragraph asks for it (`type.para {koreanCharBreaks}`, Paragraph Style Options ▸ Justification; not written to IDML). Upright text is shaped top to bottom: punctuation and brackets take their vertical forms (OpenType `vert`; `vkrn` and `vpal` through OpenType features), glyphs advance by their vertical metrics (`vmtx`) and hang from their vertical origins (`VORG`) on the centre of the ideographic em box (`BASE`, else OS/2), which the first line's ascent follows; turned text keeps its horizontal forms. Tate-chu-yoko (Type ▸ Tate-Chu-Yoko, `type.tateChuYoko`, IDML `Tatechuyoko`) sets a run such as "12" side by side within one em of the line. Ruby (Type ▸ Ruby…, `type.ruby`, IDML RubyString; group ruby spread 1-2-1 or centred) and kenten emphasis dots (`type.kenten`, IDML KentenKind) are set over their text — to its right in vertical frames — without changing line breaks. Warichu and ruby options (per-character ruby, positions, overhang) aren't there yet.
- Math expressions (Type ▸ Insert Math Expression…, `math.insert`, `math.svg`): a LaTeX subset (fractions, scripts, roots, big operators with limits, Greek, relations and operators with math spacing, `\left…\right`, `\text`) typeset by our own engine into SVG, placed inline at the cursor or as a frame; the LaTeX is kept as alt text and can be edited.
- Object Layer Options for placed PDFs (`object.pdfLayers`, `object.layerOptions`, Object ▸ Object Layer Options…): the file's optional-content layers listed and shown or hidden per frame on screen; in exported PDFs such graphics go out as 300 ppi images of their visible layers.
- Effects: Inner Glow (edge or centre), Bevel and Emboss (inner bevel lit from the light angle), Satin (`object.innerGlow`, `object.bevel`, `object.satin`); PDF export now keeps soft effects: objects with shadows, glows, feathers, bevels or satin go out as 300 ppi transparent images of their appearance (threaded text frames excepted).
- Directional Feather (`object.directionalFeather {widths: [top, left, bottom, right]}`): each side fades over its own width.
- View ▸ Flattener Preview (`view.flattenerPreview`): objects involving transparency highlighted in red; knockout groups export as images (PDF output has no knockout groups).
- Live captions (`object.caption {live: true}`, Object ▸ Captions ▸ Generate Live Caption): the caption text is re-filled from the source object's metadata after every edit (same undo step).
- Style Export Tagging (`style.exportTag`, Paragraph Style Options ▸ Export Tagging): paragraph and character styles choose their HTML element and class in EPUB/HTML.
- Tagged Text: export (`file.exportText {format: "tagged"}`) and place (detected by its header; ASCII, UTF-8 or UTF-16) with paragraph and character styles, font, typeface and size, and `<0x…>` characters.
- Tagged PDF: one structure element per paragraph (H1–H6 and BlockQuote from Export Tagging, else P) in reading order, frame fills, strokes, rules and tables as artifacts, figures with alt text.
- EPUB: cover image from the first page (`file.exportEpub {cover}`), and Fixed Layout export (`file.exportFixedEpub`, File ▸ Export EPUB (Fixed Layout)…): pre-paginated pages as 144 ppi images with their text kept for search and read-aloud.
- Interactive PDF options (`file.exportPdf {fullScreen, bookmarksPanel, pageLayout, view, advanceSeconds}`, File ▸ Export PDF (Interactive)…): page mode, layout, opening view and page timing in the catalog and pages.
- Object Export Options (`object.exportOptions`): tagged-PDF artifact, EPUB/HTML rasterize (the object as an image), alignment and page break before, alt text.
- Scripts panel (Window ▸ Utilities ▸ Scripts, `script.run`): saved command scripts (with `$N.path` references to earlier results) run as one undo step; edit, add, load and delete them in the panel. Scripts use DesignCraft's command language, not ExtendScript.
- Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`).
- Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export.
- Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override.
- View › Overprint Preview (⌥⇧⌘Y): overprinting fills, strokes and 100% [Black] text mix with what's beneath on screen.
- Type on a Path (⇧T tool, `type.onPath`): a story along any line, curve or shape on screen and in PDF; Type on a Path Options (start, flip, baseline/ascender/descender/centre) and Delete Type from Path. Round-trips through IDML as TextPath; on-path caret drawing is still to come.
- Preferences › Appearance of Black: 100% K on screen accurately (dark grey) or as rich black (`window.richBlack`); the printing/export side is still to come.
- Spreads of up to 10 pages: Add to Previous Spread and Allow Spread to Shuffle (Pages panel; `layout.pages.toSpread`, `layout.spreadShuffle`); inserted pages go around kept spreads.
- Page tool (⇧P) and `layout.pageSize`: pages of their own size (or a preset); the spine stays put and objects keep their place on their page.
- File › Print (⌘P): printer, copies, page range, spreads, marks, bleed; printed as PDF through the system spooler (`file.print`, `file.printers`, `dryRun` for agents). Shortcuts now act like choosing the menu item (dialogs open), and UI commands win shortcut ties (⌘N opens New Document).
- Nested styles (through/up to N sentences, words, characters, letters, digits, tabs, breaks, spaces or given characters) and GREP styles (regex → character style), edited in Paragraph Style Options and composed under local formatting; `ui.dialog.open` for agents. Nested line styles are still to come; nested and GREP styles round-trip through IDML.
- Table and cell styles (round-trip through IDML): cell styles (fill, insets, vertical justification, strokes, paragraph style) and table styles (region cell styles, border, alternating rows, spacing); apply from the Table panel, edits re-apply to every user; `style.cell.*`, `style.table.*`.
- Style groups: paragraph and character styles shown in group folders, Move to Group (new, existing, none); `style.group` renames every use (styles, stories, table cells, footnotes, object styles).
- Scrubby zoom: drag the Zoom tool left or right to zoom continuously around the press point.
- Guides belong to the active layer: hidden with it (or with its Show Guides off) and locked with it.
- Pencil tool (N): freehand strokes simplified to smooth paths (Alt closes); Smooth and Erase tools re-fit or remove the stretch you drag along (`path.smooth`, `path.erase`).
- Edit › Keyboard Shortcuts: every command, click and press the new keys, conflicts shown, per-command and global reset; `window.setShortcut`.
- Primary Text Frame (New Document) and Smart Text Reflow: pages are added while the primary story oversets and empty ones at the end removed, in the same undo step (Preferences › Type).
- Glyphs panel: every character of any font (rendered by our renderer), search by character or U+code, click to insert, recently used.
- UI scaling (Preferences › Interface, 50–200%; `window.uiScale`); the window title steps aside when the bar is crowded.
- Underline / Strikethrough Options: weight, offset, colour and tint (Character panel; screen, PDF and IDML).
- Relink to Folder and Relink File Extension (`links.relinkFolder`; missing links found by name).
- File › Package (⌥⇧⌘P): the document relinked to a Links folder, the placed files, the font files that draw its text in a Document Fonts folder (the fonts it names and any fallback fonts that draw characters those lack; except fonts whose licence restricts it, OS/2 `fsType`), an IDML copy, an optional PDF and a report (fonts, fallback fonts, links, preflight, instructions); Copy Links To (`links.copyTo`).
- Document fonts: opening a `.designcraft` or `.idml` file loads the fonts in the `Document Fonts` folder beside it (as Package writes it), ahead of installed fonts with the same name, so a packaged document opens with its fonts on another machine. Damaged, oversized (over 256 MB per file, 512 MB in all) and surplus (over 200) font files are skipped and listed in `file.open`'s `warnings`; `font.list` gives each font's `source`. Document fonts belong to their document, as in InDesign: other open documents don't see them (each document with a same-named font draws with its own), and closing the document (or reverting it, or a book chapter's export ending) makes them unavailable. Their files stay in memory until DesignCraft quits, so reopening a document doesn't read or load them again; on the web there is no folder to read.
- Load Swatches / Save Swatches for Exchange (.ase: CMYK, RGB, Lab, Gray; spot or process) from the Swatches panel menu; `swatch.load` / `swatch.save`.
- Pathfinder (Object › Pathfinder and a Pathfinder panel): Add, Subtract, Intersect, Exclude Overlap, Minus Back; curves stay curves (flo_curves); `object.pathfinder`.
- Make / Release Compound Path (⌘8; nested paths become holes), Create Outlines (⇧⌘O; one path per text colour), Scissors tool (C) with `path.split`.
- Transform Again / Individually / Sequence Again (⌥⌘3; a moved copy repeats like Step and Repeat), Clear Transformations; Layers: Merge, Delete Unused, Hide/Lock Others, Show/Unlock All; Break Link to Style (paragraph and character).
- Floating panels: tear a panel off the dock (its float button, or drag its header away) into a movable, resizable window; Dock puts it back; positions persist; `window.floatPanel` / `window.dockPanel`.
- Place SVG (vector in PDF export via krilla-svg, resvg on screen, text in the bundled fonts), Photoshop (.psd/.psb composite in its colour mode — Bitmap, Grayscale, Duotone, Indexed, RGB, CMYK, Lab, 8/16-bit — with merged transparency; extra alpha and spot channels don't change the image), BMP, and Illustrator files saved with PDF compatibility (.ai). New `designcraft-images` crate (L2) for sniffing, sizes and decoding. EPS is still missing.
- View › Display Performance: Fast (grey boxes, no effects, ⌥⇧⌘Z), Typical (72 ppi proxies, ⌥⌘Z), High Quality (⌥⌘H, our default — the renderer is fast enough).
- OpenType menu (Character panel): discretionary ligatures, fractions, ordinal, swash, titling, contextual alternates, slashed zero, the four figure styles and stylistic sets 1–20; `type.openType`; mapped to IDML's OTF attributes.
- Add Anchor Point (=), Delete Anchor Point (-) and Convert Direction Point (⇧C) tools (click toggles smooth/corner, drag pulls out handles), with `path.addAnchor`, `path.deleteAnchor`, `path.convertAnchor`.
- Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel sets type, angle, reverse and edits stops on a ramp (drag, add, drag off to remove, location, colour). Edited gradients are unnamed swatches; `object.gradient`; the vector round-trips through IDML.
- Document Setup (⌥⌘P): intent, number of pages, start page # (an even start begins with a left page), facing pages, size presets and orientation, bleed and slug per edge; `layout.documentSetup {}` reports the setup.
- Stroke panel: cap, join, miter limit, alignment, type, Start/End arrowheads (12 kinds, drawn on screen and in PDF; the path is shortened under pointed heads) and gap colour under dashes and dots.
- Preferences (⌘K: General, Type, Units & Increments, Grids, Guides & Pasteboard, Display Performance) backed by `prefs.set` (application) and `document.preferences` (units, increments, grids — undoable, saved with the document; right-click a ruler to pick its units, the horizontal and vertical rulers each have their own); scaling applies to content and scales stroke weights (Include Stroke Weight), and X/Y/W/H measure the stroke's outer edge (Dimensions Include Stroke Weight). Quick Apply (⌘Return) finds paragraph, character and object styles as well as commands.
- Parent item overrides: Cmd+Shift-click a parent item on a page (or Override All Parent Page Items, ⌥⇧⌘L) makes an editable local copy that hides the parent's; Remove All Local Overrides and Detach All Objects from Parent in the Pages panel menu.
- Place PDF: placed PDF pages are sized by their crop box, drawn on screen through hayro (with mip levels) and embedded in exported PDFs as vector form XObjects (krilla), `<PDF>` in IDML.
- Menus expose the engine's features (TOC, numbering & sections, text variables, hyperlinks, cross-references, footnotes, step and repeat, spelling, fitting/content, effects, Data Merge, Preflight, EPUB); any menu command with parameters gets a dialog generated from its parameter documentation.
- Table of contents (generate/update, dot leaders) and text variables (running headers, last page number, chapter number, file name, dates, custom) resolved per page.
- Soft effects: blurred drop shadow, inner shadow, outer glow, basic feather.
- ~200 commands, all reachable through the JSON control channel; headless CLI rendering to PNG.
- MCP server (`designcraft-cli mcp [--connect PORT]`, docs/mcp.md): headless engine or the running app; commands, batch, document/story inspection, page renders as images, window screenshots, pointer/keyboard/dialog input.
- Web build (`apps/designcraft-web`, trunk): the same UI on WebGPU with a WebGL2 fallback; open/place via the browser file picker or drag-and-drop, save/export as downloads.
**Next (in order):** books · New Window / split view · Ink Manager and Separations Preview · XML structure, tags and export · buttons and forms · PDF/X-4 output intent (needs krilla support) · EPS place.
## How far from full parity (estimate, 2026-10-04)
**Breadth: ~99% weighted** (P0 core 99%, P1 100%, P2 97%) over the 275 features of the InDesign catalogue, scored
row by row in [docs/parity.md](docs/parity.md) (`cargo xtask parity` recomputes it). Many features scored done still
lack some of InDesign's options or dialog details, so **overall parity including depth is about 83%**.
**Remaining work: about 170 wall-clock hours of a single Claude Opus 5.5 agent** (±30%), or roughly 60–85 hours with
four agents in parallel on separate crates:
| Work | Estimate |
|---|---|
| Open P0 (2: PDF/X-4 validation against a certified checker, PostScript interpretation for EPS) | 8 h |
| Open P1 (none partial) | 0 h |
| Open P2 (3 partial: localization breadth, INDD pages (not readable clean-room), in-app media playback) | 14 h |
| Depth and pixel fidelity of every dialog, panel and menu against InDesign 2026 | 90 h |
| Performance (incremental composition, GPU raster) and interchange hardening (IDML/PDF corpus) | 55 h |
Basis: since the first estimate this effort landed about 45 arcs (from parent overrides, Preferences, Stroke panel,
gradients and Pathfinder to table/cell styles, nested/GREP styles and Print) in roughly 14 hours on a heavily loaded
machine — about 1–2 hours per catalogue row.
## Agents: CLI and MCP
Every command is reachable from `designcraft-cli` (`run`, `script` with `$N.path` result references, `app` for the
running window, `describe`, `commands`), from MCP (`designcraft-cli mcp`, tools including `execute` and `batch` with the
same references), and from the app's JSON control channel. See [docs/agents.md](docs/agents.md).
## Milestones
| # | Milestone | State |
**Stage: pre-alpha** · next: alpha, ~0% and ~15–25 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%)
> **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.
@@ -98,7 +315,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
@@ -126,9 +343,53 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
## Progress log
- **2026-10-13** · Switching between document and parent pages refreshes the canvas immediately, preventing black parent pages and stale document objects when the zoom stays the same. Ordinary edits retain partial repainting. No readiness percentage change.
Newest first.
- **2026-10-13** · A selected placed graphic shows its own brown bounding box with handles: dragging one resizes the graphic inside its frame (Shift keeps proportions, Alt from the centre) and leaves the frame as it is; `transform.set` takes the graphic's new spread `bounds`. Builds on #415 and #550. No readiness percentage change.
- **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-15** · Flex Layout (InDesign 2026): Object › Flex Layout (Add to, Convert to, Remove) and a Flex Layout panel laid out as InDesign's, also in Properties. A container lays out its objects by direction, wrap, justification, the alignment grid, Fixed/Auto width and height, padding and spacing, after every edit; Add detects aligned objects (direction, Auto size, Space Between at their spacing); resizing a container reflows its objects instead of scaling them; dragging an object in one reorders it. Montilla Design's tutorial replays step for step. No readiness percentage change.
- **2026-10-14** · Layout › Margins and Columns and Layout › Create Guides are laid out as InDesign's: Margins in Top/Bottom and Inside/Outside pairs with a link button that makes all four the same, Rows and Columns side by side, Fit Guides to Margins or Page, and a Preview checkbox that shows the change on the canvas before OK. A measurement field works out arithmetic when you leave it (`0.4375 in*2` becomes `0.875 in`). No readiness percentage change.
- **2026-10-14** · The Pathfinder panel is laid out as InDesign's: Paths (Join, Open, Close, Reverse), Pathfinder, Convert Shape and Convert Point, each a row of icon buttons. Join Path closes an open path or joins two at their nearest ends (`path.join`), and Convert Point makes the selected points plain, corner, smooth or symmetrical (`path.convertPoints`). No readiness percentage change.
- **2026-10-12** · Linked text files (#597): placed Word, RTF and text files can stay linked; the Links panel shows them OK / Modified / Missing, and Update imports the file again into the same frames with the saved style map (the Links panel asks before replacing edits made in the layout). No readiness percentage change.
- **2026-10-15** · IDML: the CJK leading model InDesign writes on [No paragraph style] of every document (Aki Below) only applies when the document uses a Japanese composer, as in InDesign; a Western document's paragraphs after a size change no longer sit lower than in InDesign (Montilla Design's Flex Layout file: its captions came out overset). No readiness percentage change.
- **2026-10-14** · Type › Fill with Placeholder Text fills a frame with paragraphs of a few sentences, as InDesign does, so one paragraph can be styled on its own (it was one long paragraph, and the filler ran sentences together, "interit.Ovid"). 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.
- **2026-10-14** · The Swatches panel has InDesign's header: a Fill/Stroke proxy, Formatting Affects Container/Text and the Tint of the colour in front (a tint alone keeps each object's own swatch). Clicking a swatch applies it to the fill or stroke in front (of the text, when formatting affects text), and the bottom row has New Color Group, New Swatch and Delete Swatch. No readiness percentage change.
- **2026-10-14** · With the Page tool, the Control panel shows the page as InDesign's does: W/H from a reference point, page-size preset (US Business Card added), orientation, Liquid Page Rule (now with Controlled by Parent) and Show Parent Page Overlay, which draws the parent page's size over a resized page. No readiness percentage change.
- **2026-10-14** · On an object-based page the Page tool shows a selected object's liquid pins and resize locks as InDesign does: a click pins a side to the page edge or locks the width or height, with InDesign's tooltips. No readiness percentage change.
- **2026-10-14** · The Liquid Layout panel follows InDesign's: Liquid Page Rule with the page number, Content › Auto-Fit, and Object Constraints › Resize with Page and Pin for the selected object on an object-based page; Create Alternate Layout moved to the Layout menu. No readiness percentage change.
- **2026-10-14** · With the Page tool, the Control panel shows the page as InDesign's does: W/H from a reference point, page-size preset (US Business Card added), orientation, Liquid Page Rule (now with Controlled by Parent) and Show Parent Page Overlay, which draws the parent page's size over a resized page. No readiness percentage change.
- **2026-10-14** · With the Page tool, the Control panel shows the page as InDesign's does: W/H from a reference point, page-size preset (US Business Card added), orientation, Liquid Page Rule and Show Parent Page Overlay, which draws the parent page's size over a resized page. No readiness percentage change.
- **2026-10-14** · The Page tool works as InDesign's: a click selects a page and shows its handles and liquid page rule; dragging a handle previews the rule and springs back, Alt-drag resizes the page from the opposite edge, Shift keeps proportions, with a W/H readout. No readiness percentage change.
- **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-13** · Create Outlines makes one compound path per line (and per text colour within a line), grouped, as InDesign does: Ungroup then leaves each line on its own to be spaced and made into one compound path again. A single line in one colour stays a single compound path. No readiness percentage change.
- **2026-10-13** · Gradient swatches work on strokes: drawn across the path's box on screen and in PDF (they drew nothing), set with `object.gradient {target: stroke}` and in the Gradient panel when the Stroke box is in front; the stroke's gradient vector moves with the shape and round-trips through IDML. No readiness percentage change.
- **2026-10-13** · Text takes gradient swatches: a gradient fill or stroke on characters spans the text frame's box (on a path: the path's), on screen and in PDF. 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
@@ -136,7 +397,23 @@ 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-13** · Reflowable EPUB and HTML export include tables (header and footer rows, merged cells), footnotes (a linked note after the paragraph that refers to it) and endnotes (a linked section at the end of the book). No readiness percentage change.
- **2026-10-13** · Word import: table cells read text by the same rules as body text, so deleted text, moved-away text and field codes stay out (#721). Word's Normal comes in as its own paragraph style "Normal" over the document defaults, so text formatted only by Normal keeps its look; it conflicts only with a document's own Normal, and the style map can send it to [Basic Paragraph] (#722). 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.
@@ -145,6 +422,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.
@@ -162,9 +440,16 @@ Newest first.
- **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** · With a placed graphic selected (Direct Selection), the Transform values act on the graphic inside its frame, as in InDesign: a tilted frame's image can be straightened to 0°. 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** · In a CMYK transparency blend space (print documents), blend modes blend in CMYK on screen as in InDesign. Color Dodge with 100% K text turns coloured areas under it white and leaves the paper alone, where RGB blending changed almost nothing. PDF export already did this. No readiness percentage change.
- **2026-10-11** · Moving a sheared and rotated text frame no longer leaves trails of text behind: canvas repaints cover the frame's whole text box, not just its outline. 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-13** · Dropping an `.ase` file on the window loads its swatches, as Load Swatches does (the drop failed with "Can't Place the File"). 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.
- **2026-10-10 (UI.4–UI.5):** Panel tabs can move into other groups, split the dock, float, resize and return to their saved locations through shared docking transactions. Layouts persist in custom workspaces; UI and control commands share the same validated action path. Shared pane geometry and widget-state theme application retain all five palettes and local fonts. Split panes send keyboard input only to the focused canvas and scope contextual toolbar IDs per pane. 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()));
}
}
+50 -8
View File
@@ -1,6 +1,7 @@
//! Headless DesignCraft.
//!
//! ```text
//! designcraft-cli run [--in FILE.designcraft|FILE.idml | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--export OUT.png|.jpg|.pdf|.designcraft|.idml|.epub|.pptx] [--pdf-options JSON] [--all-pages DIR]
//! designcraft-cli run [--in FILE.designcraft|FILE.idml | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT.png|.jpg|.pdf|.designcraft|.idml|.epub] [--all-pages DIR]
//! # --page, --scale and --pdf-options apply to the exports that follow them
//! designcraft-cli commands [FILTER] # list commands (JSON), optionally only ids/labels/menus containing FILTER
@@ -9,7 +10,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 +32,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 +58,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 +165,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 +201,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)
}
};
@@ -261,7 +289,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()?;
@@ -295,6 +327,14 @@ fn run(args: &[String]) -> Result<(), String> {
}
fn export(s: &mut Session, out: &str, page: usize, scale: f64) -> Result<(), String> {
if out.to_ascii_lowercase().ends_with(".pptx") {
let r = s.execute("file.exportPptx", &json!({"path": out})).map_err(|e| e.to_string())?;
eprintln!("wrote {out} ({} slides, {} bytes)", r["slides"], r["bytes"]);
for w in r["warnings"].as_array().into_iter().flatten() {
eprintln!("warning: {}", w.as_str().unwrap_or_default());
}
return Ok(());
}
if out.to_ascii_lowercase().ends_with(".idml") {
let r = s.execute("file.exportIdml", &json!({"path": out})).map_err(|e| e.to_string())?;
eprintln!("wrote {out} ({} bytes)", r["bytes"]);
@@ -422,6 +462,8 @@ fn script(args: &[String]) -> Result<(), String> {
exec(b, "file.exportIdml", p)?;
} else if lower.ends_with(".epub") {
exec(b, "file.exportEpub", p)?;
} else if lower.ends_with(".pptx") {
exec(b, "file.exportPptx", p)?;
} else {
// PNG / JPEG of the first page.
b.call("app.export", p)?;
+139
View File
@@ -138,6 +138,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() {
@@ -169,3 +187,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}");
}
+1 -1
View File
@@ -30,4 +30,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] }
web-sys = { version = "0.3", features = ["Event", "EventTarget", "Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] }
+159
View File
@@ -0,0 +1,159 @@
//! Stop editor work after graphics loss while retaining documents for recovery downloads.
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use designcraft_engine::{Session, doc::Document};
#[derive(Clone, Default)]
pub(crate) struct GraphicsState(Arc<AtomicBool>);
impl GraphicsState {
/// Only the first loss should replace the error panel or move keyboard focus.
pub(crate) fn fail(&self) -> bool {
!self.0.swap(true, Ordering::Relaxed)
}
pub(crate) fn has_failed(&self) -> bool {
self.0.load(Ordering::Relaxed)
}
pub(crate) fn run_if_healthy<R>(&self, work: impl FnOnce() -> R) -> Option<R> {
if self.has_failed() { None } else { Some(work()) }
}
}
/// An immutable copy that outlives the session and its renderer.
#[derive(Clone)]
pub(crate) struct RecoveryDocument {
title: String,
doc: Arc<Document>,
}
impl RecoveryDocument {
pub(crate) fn title(&self) -> &str {
&self.title
}
pub(crate) fn save_copy(&self) -> Result<(String, Vec<u8>), String> {
let bytes = designcraft_format::save(&self.doc).map_err(|error| format!("Couldn't save a copy of {}: {error}", self.title))?;
Ok((format!("{}.designcraft", self.doc.title), bytes))
}
}
/// Retain all documents without activating one, changing dirty state, or committing a preview.
pub(crate) fn recovery_documents(session: &Session) -> Vec<RecoveryDocument> {
session
.documents()
.iter()
.map(|state| RecoveryDocument { title: state.title(), doc: state.preview_stash.as_ref().unwrap_or(&state.doc).clone() })
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn recovery_document(session: &Session, index: usize) -> Result<(String, Vec<u8>), String> {
recovery_documents(session).get(index).ok_or_else(|| "This document is no longer open.".to_string())?.save_copy()
}
// Synthetic 1x1 red RGBA PNG.
const PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
#[test]
fn graphics_loss_blocks_editor_work_and_cannot_be_cleared_by_another_callback() {
let graphics = GraphicsState::default();
let device_callback = graphics.clone();
let mut session = Session::new();
session.execute("file.new", &json!({"pages": 1})).unwrap();
graphics.run_if_healthy(|| session.execute("file.place", &json!({"name": "red.png", "base64": PNG}))).unwrap().unwrap();
graphics.run_if_healthy(|| session.execute("edit.undo", &json!({}))).unwrap().unwrap();
let (_, undone) = recovery_document(&session, 0).unwrap();
assert!(designcraft_engine::cmd::from_bytes(&undone).unwrap().all_items().is_empty());
graphics.run_if_healthy(|| session.execute("edit.redo", &json!({}))).unwrap().unwrap();
let (_, before) = recovery_document(&session, 0).unwrap();
assert_eq!(designcraft_engine::cmd::from_bytes(&before).unwrap().all_items().len(), 1);
assert!(device_callback.fail());
assert!(!graphics.fail(), "duplicate context/device notifications must not replace recovery controls");
assert!(graphics.has_failed());
assert!(graphics.run_if_healthy(|| session.execute("edit.undo", &json!({}))).is_none());
assert_eq!(recovery_document(&session, 0).unwrap().1, before, "a hidden editor must not apply more edits");
}
#[test]
fn recovery_copies_all_open_documents_without_changing_session_state() {
let mut session = Session::new();
session.execute("file.new", &json!({"title": "First", "pages": 1})).unwrap();
session.execute("file.place", &json!({"name": "red.png", "base64": PNG})).unwrap();
let first = session.doc().unwrap().doc.clone();
session.execute("file.new", &json!({"title": "Second", "pages": 1})).unwrap();
session.execute("frame.create", &json!({"rect": [10, 10, 110, 110]})).unwrap();
let second = session.doc().unwrap().doc.clone();
let active = session.active_index();
let journal_len = session.journal.len();
for (index, expected) in [first.clone(), second.clone()].iter().enumerate() {
let (name, bytes) = recovery_document(&session, index).unwrap();
assert_eq!(name, format!("{}.designcraft", expected.title));
let reopened = designcraft_engine::cmd::from_bytes(&bytes).unwrap();
assert_eq!(reopened, **expected, "the copy must retain document content and embedded PNG bytes");
}
assert_eq!(session.active_index(), active);
assert_eq!(session.journal.len(), journal_len);
assert!(Arc::ptr_eq(&session.documents()[0].doc, &first));
assert!(Arc::ptr_eq(&session.documents()[1].doc, &second));
assert!(session.documents().iter().all(|state| state.is_dirty()));
assert!(recovery_document(&session, 2).is_err());
}
#[test]
fn recovery_saves_the_template_during_data_merge_preview() {
let mut session = Session::new();
session.execute("file.new", &json!({"title": "Template", "pages": 1})).unwrap();
let template = session.doc().unwrap().doc.clone();
session.execute("frame.create", &json!({"rect": [0, 0, 100, 100]})).unwrap();
session.doc_mut().unwrap().preview_stash = Some(template.clone());
let (_, bytes) = recovery_document(&session, 0).unwrap();
let reopened = designcraft_engine::cmd::from_bytes(&bytes).unwrap();
assert_eq!(reopened, *template);
assert!(Arc::ptr_eq(session.doc().unwrap().preview_stash.as_ref().unwrap(), &template));
}
#[test]
fn recovery_snapshots_keep_all_documents_and_assets_after_session_drop() {
let mut session = Session::new();
session.execute("file.new", &json!({"title": "First", "pages": 1})).unwrap();
session.execute("file.place", &json!({"name": "red.png", "base64": PNG})).unwrap();
let first = session.doc().unwrap().doc.clone();
session.execute("file.new", &json!({"title": "Template", "pages": 1})).unwrap();
session.execute("file.place", &json!({"name": "template.png", "base64": PNG})).unwrap();
let template = session.doc().unwrap().doc.clone();
session.execute("frame.create", &json!({"rect": [0, 0, 100, 100]})).unwrap();
session.doc_mut().unwrap().preview_stash = Some(template.clone());
let documents = recovery_documents(&session);
assert_eq!(documents.len(), 2);
assert_eq!(documents[0].title(), "First");
assert_eq!(documents[1].title(), "Template");
assert!(Arc::ptr_eq(&documents[0].doc, &first));
assert!(Arc::ptr_eq(&documents[1].doc, &template));
session.stop_data_preview();
session.execute("frame.create", &json!({"rect": [10, 10, 110, 110]})).unwrap();
drop(session);
for (snapshot, expected) in documents.iter().zip([first, template]) {
let (name, bytes) = snapshot.save_copy().unwrap();
assert_eq!(name, format!("{}.designcraft", expected.title));
assert!(bytes.starts_with(b"PK"));
let reopened = designcraft_engine::cmd::from_bytes(&bytes).unwrap();
assert_eq!(reopened, *expected, "snapshots must retain every document and its embedded asset bytes");
assert!(!reopened.assets.is_empty());
assert!(reopened.assets.values().all(|asset| !asset.data.is_empty()));
}
}
}
+3
View File
@@ -22,6 +22,9 @@ mod browser_policy;
#[cfg(target_arch = "wasm32")]
mod web;
#[cfg(any(target_arch = "wasm32", test))]
mod graphics;
#[cfg(target_arch = "wasm32")]
fn main() {
web::start();
+299 -27
View File
@@ -1,6 +1,7 @@
//! The browser shell: web `Services`, drag-and-drop, and the eframe web runner.
use super::browser_policy::{has_query_flag, mime_for};
use crate::graphics::{GraphicsState, RecoveryDocument, recovery_documents};
use designcraft_engine::Session;
use designcraft_ui_egui::{DesignApp, ImportRequest, ImportedFile, Inbox, Services};
use wasm_bindgen::JsCast as _;
@@ -37,8 +38,16 @@ pub fn start() {
log::error!("no document");
return;
};
if let Err(e) = create_error_panel(&document) {
log::error!("couldn't create graphics error panel: {e:?}");
if let Some(el) = document.get_element_by_id(LOADING_ID) {
el.set_text_content(Some("DesignCraft couldn't start. Reload this page to try again."));
}
return;
}
let graphics = GraphicsState::default();
let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
graphics_failed(&graphics, "DesignCraft failed to start", "Reload this page to try again.", &format!("Missing canvas {CANVAS_ID}."));
return;
};
let mut options = eframe::WebOptions::default();
@@ -47,32 +56,285 @@ pub fn start() {
{
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
}
let result = eframe::WebRunner::new()
let runner = eframe::WebRunner::new();
let creator_graphics = graphics.clone();
let result = runner
.start(
canvas,
canvas.clone(),
options,
Box::new(move |cc| {
if let Some(rs) = &cc.wgpu_render_state {
log::info!("designcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend);
let device_graphics = creator_graphics.clone();
// This also observes an already-lost WebGPU device through its lost promise.
rs.device.set_device_lost_callback(move |reason, message| {
graphics_failed(&device_graphics, GRAPHICS_TITLE, GRAPHICS_MESSAGE, &format!("Device loss ({reason:?}): {message}"));
});
}
let inbox: Inbox = Inbox::default();
let mut app = DesignApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
if has_query_flag(&query(), "sample") {
let _ = app.run("file.newSample", serde_json::json!({}));
}
Ok(Box::new(WebShell { app, inbox }))
Ok(Box::new(WebShell { app, inbox, graphics: creator_graphics }))
}),
)
.await;
if let Some(el) = document.get_element_by_id(LOADING_ID) {
match result {
Ok(()) => el.remove(),
Err(e) => el.set_inner_html(&format!("<p>DesignCraft failed to start: {e:?}</p><p>A browser with WebGPU or WebGL2 is required.</p>")),
match result {
Ok(()) => {
if let Err(e) = install_recovery_controls(&runner, &canvas, &graphics) {
graphics_failed(&graphics, GRAPHICS_TITLE, GRAPHICS_MESSAGE, &format!("Couldn't install graphics recovery controls: {e:?}"));
if let Some(shell) = runner.app_mut::<WebShell>()
&& let Err(e) = prepare_document_recovery(&runner, &shell.app.session)
{
recovery_controls_failed(&e);
}
}
// A loss callback can run during startup. Never hide its failure panel.
if !graphics.has_failed()
&& let Some(el) = document.get_element_by_id(LOADING_ID)
{
el.remove();
}
}
Err(e) => {
graphics_failed(
&graphics,
"DesignCraft failed to start",
"Reload this page to try again. A browser with WebGPU or WebGL2 is required.",
&format!("{e:?}"),
);
if let Some(shell) = runner.app_mut::<WebShell>()
&& let Err(e) = prepare_document_recovery(&runner, &shell.app.session)
{
recovery_controls_failed(&e);
}
}
}
});
}
const ERROR_ID: &str = "designcraft_graphics_error";
const ERROR_TITLE_ID: &str = "designcraft_graphics_title";
const ERROR_MESSAGE_ID: &str = "designcraft_graphics_message";
const ERROR_DETAIL_ID: &str = "designcraft_graphics_detail";
const ERROR_ACTIONS_ID: &str = "designcraft_graphics_actions";
const ERROR_STATUS_ID: &str = "designcraft_graphics_status";
const ERROR_DOCUMENTS_ID: &str = "designcraft_graphics_documents";
const GRAPHICS_LOST_EVENT: &str = "designcraftgraphicslost";
const GRAPHICS_TITLE: &str = "Graphics connection lost";
const GRAPHICS_MESSAGE: &str = "Editing has stopped. Save a copy of each open document, then reload this page and reopen the copies. Reloading discards changes you have not downloaded.";
fn create_error_panel(document: &web_sys::Document) -> Result<(), wasm_bindgen::JsValue> {
let panel: web_sys::HtmlElement = document.create_element("div")?.dyn_into()?;
panel.set_id(ERROR_ID);
panel.set_tab_index(-1);
panel.set_attribute("role", "alertdialog")?;
panel.set_attribute("aria-modal", "true")?;
panel.set_attribute("aria-labelledby", ERROR_TITLE_ID)?;
panel.set_attribute("aria-describedby", ERROR_MESSAGE_ID)?;
// DOM content remains visible when the GPU can no longer paint the egui canvas.
let colors = designcraft_ui_egui::theme::Tokens::for_brightness(designcraft_ui_egui::theme::Brightness::Dark);
panel.style().set_css_text(&format!(
"position:fixed;inset:0;z-index:1;display:none;flex-direction:column;align-items:center;justify-content:safe center;box-sizing:border-box;padding:24px;overflow:auto;background:{};color:{};font:14px system-ui,sans-serif;gap:12px;",
css_color(colors.app_bar), css_color(colors.text),
));
for (tag, id) in [("h1", ERROR_TITLE_ID), ("p", ERROR_MESSAGE_ID), ("div", ERROR_ACTIONS_ID), ("p", ERROR_STATUS_ID), ("pre", ERROR_DETAIL_ID)] {
let child: web_sys::HtmlElement = document.create_element(tag)?.dyn_into()?;
child.set_id(id);
child.style().set_css_text("margin:0;max-width:640px;white-space:pre-wrap;overflow-wrap:anywhere;");
if id == ERROR_ACTIONS_ID {
child.style().set_css_text("display:flex;flex-direction:column;gap:8px;max-width:100%;");
add_reload_control(document, &child)?;
} else if id == ERROR_STATUS_ID {
child.set_attribute("aria-live", "polite")?;
}
panel.append_child(&child)?;
}
document.body().ok_or_else(|| wasm_bindgen::JsValue::from_str("no body"))?.append_child(&panel)?;
Ok(())
}
fn graphics_failed(graphics: &GraphicsState, title: &str, message: &str, detail: &str) {
if !graphics.fail() {
return;
}
log::error!("designcraft-web: {detail}");
if let Err(e) = show_error(title, message, detail) {
log::error!("couldn't show graphics error: {e:?}");
}
// A device callback may run while AppRunner is borrowed. Defer its notification;
// the WebGL listener already holding AppRunner captures documents directly.
if let Some(window) = web_sys::window()
&& let Some(canvas) = window.document().and_then(|doc| doc.get_element_by_id(CANVAS_ID))
{
let notify = wasm_bindgen::closure::Closure::once_into_js(move || {
if let Err(e) = web_sys::Event::new(GRAPHICS_LOST_EVENT).and_then(|event| canvas.dispatch_event(&event)) {
recovery_controls_failed(&e);
}
});
window.queue_microtask(notify.unchecked_ref());
}
}
fn show_error(title: &str, message: &str, detail: &str) -> Result<(), wasm_bindgen::JsValue> {
let document = web_sys::window().and_then(|w| w.document()).ok_or_else(|| wasm_bindgen::JsValue::from_str("no document"))?;
let panel: web_sys::HtmlElement =
document.get_element_by_id(ERROR_ID).ok_or_else(|| wasm_bindgen::JsValue::from_str("no graphics error panel"))?.dyn_into()?;
for (id, text) in [(ERROR_TITLE_ID, title), (ERROR_MESSAGE_ID, message), (ERROR_DETAIL_ID, detail)] {
if let Some(el) = document.get_element_by_id(id) {
el.set_text_content(Some(text));
}
}
panel.style().set_property("display", "flex")?;
if let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|el| el.dyn_into::<web_sys::HtmlElement>().ok()) {
canvas.style().set_property("visibility", "hidden")?;
}
if let Some(loading) = document.get_element_by_id(LOADING_ID).and_then(|el| el.dyn_into::<web_sys::HtmlElement>().ok()) {
loading.style().set_property("display", "none")?;
}
// Include eframe's hidden text input: neither pointer nor keyboard focus may reach the editor.
let mut sibling = document.body().and_then(|body| body.first_element_child());
while let Some(el) = sibling {
sibling = el.next_element_sibling();
if el.id() != ERROR_ID {
if let Some(html) = el.dyn_ref::<web_sys::HtmlElement>() {
html.set_inert(true);
}
el.set_attribute("aria-hidden", "true")?;
}
}
panel.focus()?;
Ok(())
}
/// Recovery listeners are owned by their DOM targets, independently of WebRunner.
fn add_dom_event_listener(
target: &web_sys::EventTarget,
event_name: &str,
callback: impl FnMut(web_sys::Event) + 'static,
) -> Result<(), wasm_bindgen::JsValue> {
let callback = wasm_bindgen::closure::Closure::wrap(Box::new(callback) as Box<dyn FnMut(web_sys::Event)>);
target.add_event_listener_with_callback(event_name, callback.as_ref().unchecked_ref())?;
// EventTarget retains the JS function; weak references reclaim its captures with the target.
let _ = callback.into_js_value();
Ok(())
}
fn css_color(color: egui::Color32) -> String {
format!("#{:02x}{:02x}{:02x}", color.r(), color.g(), color.b())
}
fn recovery_button(document: &web_sys::Document) -> Result<web_sys::HtmlElement, wasm_bindgen::JsValue> {
let button: web_sys::HtmlElement = document.create_element("button")?.dyn_into()?;
button.set_attribute("type", "button")?;
let colors = designcraft_ui_egui::theme::Tokens::for_brightness(designcraft_ui_egui::theme::Brightness::Dark);
button.style().set_css_text(&format!(
"box-sizing:border-box;max-width:100%;padding:6px 12px;border:1px solid {};border-radius:{}px;background:{};color:{};font:inherit;white-space:normal;overflow-wrap:anywhere;cursor:pointer;",
css_color(colors.field_border), colors.radius, css_color(colors.button), css_color(colors.text),
));
Ok(button)
}
fn add_reload_control(document: &web_sys::Document, actions: &web_sys::HtmlElement) -> Result<(), wasm_bindgen::JsValue> {
let reload = recovery_button(document)?;
reload.set_text_content(Some("Reload"));
add_dom_event_listener(&reload, "click", move |event| {
event.stop_propagation();
if let Some(window) = web_sys::window()
&& let Err(e) = window.location().reload()
{
log::error!("couldn't reload: {e:?}");
}
})?;
actions.append_child(&reload)?;
Ok(())
}
fn install_recovery_controls(
runner: &eframe::WebRunner,
canvas: &web_sys::HtmlCanvasElement,
graphics: &GraphicsState,
) -> Result<(), wasm_bindgen::JsValue> {
let loss_runner = runner.clone();
runner.add_event_listener(canvas, GRAPHICS_LOST_EVENT, move |_: web_sys::Event, app_runner| {
let shell = app_runner.app_mut::<WebShell>();
if shell.graphics.has_failed()
&& let Err(e) = prepare_document_recovery(&loss_runner, &shell.app.session)
{
recovery_controls_failed(&e);
}
})?;
let context_graphics = graphics.clone();
let context_runner = runner.clone();
// wgpu's GLES backend does not observe browser WebGL context-loss events.
runner.add_event_listener(canvas, "webglcontextlost", move |_: web_sys::Event, app_runner| {
graphics_failed(&context_graphics, GRAPHICS_TITLE, GRAPHICS_MESSAGE, "The browser lost the WebGL graphics context.");
if let Err(e) = prepare_document_recovery(&context_runner, &app_runner.app_mut::<WebShell>().app.session) {
recovery_controls_failed(&e);
}
})?;
// A WebGPU device can be lost while start() is still awaiting initialization.
if graphics.has_failed()
&& let Some(shell) = runner.app_mut::<WebShell>()
{
prepare_document_recovery(runner, &shell.app.session)?;
}
Ok(())
}
fn prepare_document_recovery(runner: &eframe::WebRunner, session: &Session) -> Result<(), wasm_bindgen::JsValue> {
let window = web_sys::window().ok_or_else(|| wasm_bindgen::JsValue::from_str("no window"))?;
let documents = recovery_documents(session);
if add_document_recovery_controls(documents)? {
// Controls now own the snapshots. Release AppRunner's borrow before stopping painting
// and removing its listeners/ResizeObserver; never discard documents on setup failure.
let runner = runner.clone();
let stop = wasm_bindgen::closure::Closure::once_into_js(move || runner.destroy());
window.queue_microtask(stop.unchecked_ref());
}
Ok(())
}
fn recovery_controls_failed(error: &wasm_bindgen::JsValue) {
log::error!("couldn't prepare recovery downloads: {error:?}");
if let Some(status) = web_sys::window().and_then(|w| w.document()).and_then(|doc| doc.get_element_by_id(ERROR_STATUS_ID)) {
status.set_text_content(Some("Couldn't prepare document downloads. Keep this page open to preserve unsaved documents."));
}
}
fn add_document_recovery_controls(snapshots: Vec<RecoveryDocument>) -> Result<bool, wasm_bindgen::JsValue> {
let document = web_sys::window().and_then(|w| w.document()).ok_or_else(|| wasm_bindgen::JsValue::from_str("no document"))?;
let actions = document.get_element_by_id(ERROR_ACTIONS_ID).ok_or_else(|| wasm_bindgen::JsValue::from_str("no recovery actions"))?;
if document.get_element_by_id(ERROR_DOCUMENTS_ID).is_some() {
return Ok(false);
}
let documents: web_sys::HtmlElement = document.create_element("div")?.dyn_into()?;
documents.set_id(ERROR_DOCUMENTS_ID);
documents.style().set_css_text("display:flex;flex-direction:column;gap:8px;max-width:100%;");
for snapshot in snapshots {
let button = recovery_button(&document)?;
button.set_text_content(Some(&format!("Save a Copy: {}", snapshot.title())));
add_dom_event_listener(&button, "click", move |event| {
event.stop_propagation();
let result = snapshot.save_copy().and_then(|(name, bytes)| {
download(&name, &bytes)?;
Ok(format!("Requested download of {name}. Check your browser downloads before reloading."))
});
let text = result.unwrap_or_else(|e| e);
if let Some(status) = web_sys::window().and_then(|w| w.document()).and_then(|doc| doc.get_element_by_id(ERROR_STATUS_ID)) {
status.set_text_content(Some(&text));
}
})?;
documents.append_child(&button)?;
}
// Build off-DOM so a setup error leaves the session intact and permits a clean retry.
// The inserted element's id also prevents duplicate buttons on repeated loss notifications.
actions.insert_before(&documents, actions.first_child().as_ref())?;
Ok(true)
}
fn query() -> String {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
@@ -82,36 +344,46 @@ fn query() -> String {
struct WebShell {
app: DesignApp,
inbox: Inbox,
graphics: GraphicsState,
}
impl eframe::App for WebShell {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
for f in dropped {
let request = self.app.import_request("drop");
let inbox = self.inbox.clone();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes_async().await {
Ok(bytes) => {
inbox.lock().unwrap_or_else(|e| e.into_inner()).push(ImportedFile { request, name, bytes });
ctx.request_repaint();
let graphics = self.graphics.clone();
let _ = graphics.run_if_healthy(|| {
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
for f in dropped {
let request = self.app.import_request("drop");
let inbox = self.inbox.clone();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes_async().await {
Ok(bytes) => {
inbox.lock().unwrap_or_else(|e| e.into_inner()).push(ImportedFile { request, name, bytes });
ctx.request_repaint();
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
});
}
self.app.logic(ctx);
});
}
self.app.logic(ctx);
});
}
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
designcraft_ui_egui::drop_key_name_text(raw, ctx.text_edit_focused());
self.app.raw_input_hook(raw);
let _ = self.graphics.run_if_healthy(|| {
designcraft_ui_egui::drop_key_name_text(raw, ctx.text_edit_focused());
self.app.raw_input_hook(raw);
});
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.app.ui(ui);
let _ = self.graphics.run_if_healthy(|| self.app.ui(ui));
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
}
+9 -1
View File
@@ -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.
+13 -3
View File
@@ -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(), &params.clone());
}
+17
View File
@@ -17,16 +17,33 @@
# 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"
sync_github_name = false
[people.PhilippKrueger]
real_name = "Philipp Krüger"
[people.emanuelebonaventura]
display_name = "insanityxoff"
[people.moppeeer]
sync_github_name = true
[people.PiMinusMax]
display_name = "PiMinusMax"
[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
+80 -2
View File
@@ -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,8 +68,8 @@ pub fn read(bytes: &[u8]) -> Result<Vec<Swatch>, AseError> {
}
r.take(4)?; // version
let blocks = r.u32()?;
let mut out = Vec::new();
for _ in 0..blocks {
let (mut out, mut colors) = (Vec::new(), 0usize);
'blocks: for _ in 0..blocks {
let kind = r.u16()?;
let len = r.u32()? as usize;
let body = r.take(len)?;
@@ -72,9 +77,26 @@ 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();
let n = match &model[..] {
b"CMYK" => 4,
b"RGB " | b"LAB " => 3,
b"Gray" => 1,
_ => continue,
};
// A NaN or infinite component cannot be stored in a document: leave that swatch out.
let mut peek = R { b: body, i: b.i };
for _ in 0..n {
if !peek.f32()?.is_finite() {
continue 'blocks;
}
}
let color = match &model[..] {
b"CMYK" => Color::Cmyk { c: b.f32()?, m: b.f32()?, y: b.f32()?, k: b.f32()? },
b"RGB " => Color::Rgb { r: b.f32()?, g: b.f32()?, b: b.f32()? },
@@ -82,6 +104,9 @@ pub fn read(bytes: &[u8]) -> Result<Vec<Swatch>, AseError> {
b"LAB " => {
let (l, a, bb) = (b.f32()?, b.f32()?, b.f32()?);
let [r, g, bl] = cms::lab::lab_to_srgb(cms::Lab { l: l * 100.0, a, b: bb });
if ![r, g, bl].iter().all(|v| v.is_finite()) {
continue;
}
Color::Rgb { r: r.clamp(0.0, 1.0), g: g.clamp(0.0, 1.0), b: bl.clamp(0.0, 1.0) }
}
_ => continue,
@@ -191,4 +216,57 @@ 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");
}
#[test]
fn non_finite_components_are_skipped() {
let sw = |name: &str, color: Color| Swatch {
name: name.into(),
value: SwatchValue::Color { color, color_type: ColorType::Process },
locked: false,
named: true,
hidden: false,
};
let mut list = vec![sw("Ok", Color::Rgb { r: 0.1, g: 0.2, b: 0.3 })];
for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
list.push(sw("Rgb", Color::Rgb { r: bad, g: 0.0, b: 0.0 }));
list.push(sw("Cmyk", Color::Cmyk { c: 0.0, m: 0.0, y: 0.0, k: bad }));
list.push(sw("Gray", Color::Gray { k: bad }));
}
let mut bytes = write(&list);
// Append Lab blocks with a non-finite component.
let mut extra = 0u32;
for bad in [f32::NAN, f32::INFINITY] {
let mut c = Vec::new();
c.extend(2u16.to_be_bytes());
c.extend([0, b'L', 0, 0]);
c.extend(b"LAB ");
for v in [bad, 0.0, 0.0] {
c.extend(v.to_bits().to_be_bytes());
}
c.extend(2u16.to_be_bytes());
bytes.extend(COLOR.to_be_bytes());
bytes.extend((c.len() as u32).to_be_bytes());
bytes.extend(&c);
extra += 1;
}
let count = u32::from_be_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]) + extra;
bytes[8..12].copy_from_slice(&count.to_be_bytes());
let back = read(&bytes).unwrap();
let names: Vec<&str> = back.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, ["Ok"], "non-finite swatches must be left out");
}
}
-7
View File
@@ -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 {
+2 -4
View File
@@ -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();
+39
View File
@@ -103,6 +103,26 @@ impl Gradient {
}
out
}
/// Stops for a renderer that blends between them in RGB: the expanded stops, plus samples
/// through each span that isn't RGB at both ends, so process colours keep blending as inks.
pub fn display_stops(&self) -> Vec<(f32, Color, f32)> {
const STEPS: usize = 16;
let stops = self.expanded_stops();
let mut out: Vec<(f32, Color, f32)> = Vec::with_capacity(stops.len() * STEPS);
for (i, s) in stops.iter().enumerate() {
out.push(*s);
let Some(n) = stops.get(i + 1) else { continue };
if matches!((s.1, n.1), (Color::Rgb { .. }, Color::Rgb { .. })) || n.0 - s.0 <= 1e-4 {
continue;
}
for j in 1..STEPS {
let t = s.0 + (n.0 - s.0) * j as f32 / STEPS as f32;
let (c, a) = self.sample(t);
out.push((t, c, a));
}
}
out
}
pub fn reverse(&mut self) {
// A midpoint belongs to the segment after its stop, so it moves with that segment and mirrors.
let mids: Vec<_> = self.stops.iter().rev().skip(1).map(|s| 1.0 - s.midpoint).collect();
@@ -199,6 +219,25 @@ mod tests {
assert!((mid - 0.5).abs() < 1e-5);
}
#[test]
fn process_stops_blend_as_inks() {
// LYH Studio episode 6: magenta to cyan is C 50 M 50 halfway (a light lilac), not the RGB mix.
let mut g = Gradient::default();
g.stops[0].color = Color::cmyk(0.0, 1.0, 0.0, 0.0);
g.stops[1].color = Color::cmyk(1.0, 0.0, 0.0, 0.0);
assert_eq!(g.sample(0.5).0, Color::cmyk(0.5, 0.5, 0.0, 0.0));
let d = g.display_stops();
assert_eq!(d.len(), 17, "sampled through the span");
assert!(d.iter().any(|(o, c, _)| (*o - 0.5).abs() < 1e-6 && *c == Color::cmyk(0.5, 0.5, 0.0, 0.0)));
// A midpoint stays an ink too; RGB stops aren't sampled.
g.stops[0].midpoint = 0.25;
assert!(matches!(g.expanded_stops()[1].1, Color::Cmyk { .. }));
assert_eq!(Gradient::default().display_stops().len(), 2);
// Grey and CMYK meet as inks; RGB with anything else blends in RGB.
assert_eq!(Color::gray(1.0).lerp(&Color::cmyk(1.0, 0.0, 0.0, 0.0), 0.5), Color::cmyk(0.5, 0.0, 0.0, 0.5));
assert!(matches!(Color::rgb(1.0, 0.0, 0.0).lerp(&Color::cmyk(1.0, 0.0, 0.0, 0.0), 0.5), Color::Rgb { .. }));
}
#[test]
fn midpoint_shifts() {
let mut g = Gradient::default();
+10 -1
View File
@@ -169,8 +169,17 @@ impl Color {
let [r, g, b] = self.to_rgb();
Color::rgb(1.0 - r, 1.0 - g, 1.0 - b)
}
/// Linear interpolation in display RGB.
/// The colour `t` of the way to `other`. Process inks blend as inks (CMYK with CMYK or grey,
/// grey with grey), as InDesign blends a gradient's stops; anything else blends in RGB.
pub fn lerp(&self, other: &Color, t: f32) -> Color {
let l = |a: f32, b: f32| a + (b - a) * t;
match (*self, *other) {
(Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => return Color::cmyk(l(c, c2), l(m, m2), l(y, y2), l(k, k2)),
(Color::Gray { k }, Color::Gray { k: k2 }) => return Color::gray(l(k, k2)),
(Color::Gray { k }, Color::Cmyk { .. }) => return Color::cmyk(0.0, 0.0, 0.0, k).lerp(other, t),
(Color::Cmyk { .. }, Color::Gray { k }) => return self.lerp(&Color::cmyk(0.0, 0.0, 0.0, k), t),
_ => {}
}
let a = self.to_rgb();
let b = other.to_rgb();
Color::rgb(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t)
+1 -1
View File
@@ -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"] }
+17 -2
View File
@@ -81,15 +81,21 @@ impl Spacing {
if !self.justify || g.locked_advance {
return ([0.0; 3], [0.0; 3]);
}
// A space that is no break opportunity (No Break, U+00A0) is a box with word-space elasticity.
if g.is_justify_space() {
let (st, sh) = self.space_elastic(g);
return ([st, 0.0, 0.0], [sh, 0.0, 0.0]);
}
let natural = g.adv / self.glyph_desired.max(0.01);
(
[0.0, g.space * (self.letter_max - self.letter_desired).max(0.0), natural * (self.glyph_max - self.glyph_desired).max(0.0)],
[0.0, g.space * (self.letter_desired - self.letter_min).max(0.0), natural * (self.glyph_desired - self.glyph_min).max(0.0)],
)
}
/// Word-space stretch and shrink of space glyph `g` (only U+0020 is elastic).
/// Word-space stretch and shrink of space glyph `g` (see [`Glyph::is_word_space`]); none
/// inside a jidori run, whose width is fitted.
fn space_elastic(&self, g: &Glyph) -> (f64, f64) {
if g.ch != ' ' && !(g.ch == '\u{3000}' && g.ideographic_space_elastic) {
if !g.is_word_space() || g.locked_advance {
return (0.0, 0.0);
}
(g.space * (self.word_max - self.word_desired).max(0.0), g.space * (self.word_desired - self.word_min).max(0.0))
@@ -303,8 +309,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);
+34 -4
View File
@@ -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, &section_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());
}
}
+22 -1
View File
@@ -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();
+74 -13
View File
@@ -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);
@@ -1021,10 +1056,11 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
// Footnotes referenced on this line need room at the bottom of the column too
// (a line at the top of a column is set anyway).
let line_notes = if notes.active() { notes.refs_in(line_glyphs) } else { Vec::new() };
let col_w = cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)].width();
let col_w = notes.area(&cols[cur.fi], cur.col).map_or(0.0, |r| r.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;
@@ -1169,9 +1205,22 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
keep_violation: false,
drop_cap: drop_box,
});
let placed_notes = !line_notes.is_empty();
for k in line_notes {
notes.place(doc, k, cur.fi, cur.col, col_w, f, opts);
}
// Notes spanning the columns take room from the columns before this one too: re-lay
// the frame with that room left.
if placed_notes {
let r = notes.reserve(doc, cur.fi, cur.col, &[], col_w, f, opts);
if notes.earlier_columns_collide(&out.frames[cur.fi].lines, cur.fi, cur.col, &cols[cur.fi], r)
&& let Some(j) = out.frames[cur.fi].lines.first().map(|l| l.para)
{
rewind(j, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
pi = j;
continue 'paras;
}
}
if !sub_objects.is_empty() {
let ft = &mut out.frames[cur.fi];
let li = ft.lines.len() - 1;
@@ -1623,7 +1672,7 @@ fn apply_desired_spacing(glyphs: &mut [Glyph], pp: &ParaProps) {
if g.locked_advance {
continue;
}
if g.ch == ' ' || (g.ch == '\u{3000}' && g.ideographic_space_elastic) {
if g.is_word_space() {
g.adv += g.space * (ws - 1.0);
} else if !g.is_space() && g.adv > 0.0 {
g.adv = g.adv * gs + ls * g.space;
@@ -2296,15 +2345,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 +2379,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)))
}
@@ -2507,7 +2566,8 @@ fn layout_line(
// a line whose last tab is a right, centre, character or right-indent tab is not justified.
let seg = last_tab.map_or(0, |(i, _)| i + 1);
let seg_justifies = last_tab.is_none_or(|(_, left)| left);
let spaces: Vec<usize> = line.iter().enumerate().skip(seg).filter(|(_, g)| g.is_space() && !g.no_break).map(|(i, _)| i).collect();
// Spaces inside a jidori run keep the run's fitted width, as in the breaker.
let spaces: Vec<usize> = line.iter().enumerate().skip(seg).filter(|(_, g)| g.is_justify_space() && !g.locked_advance).map(|(i, _)| i).collect();
let align = match pp.align {
Align::TowardsSpine => {
if left_page {
@@ -2608,7 +2668,7 @@ fn layout_line(
let mut ratio_n = 0usize;
for &i in &spaces {
let g = &line[i];
if g.ch == ' ' && g.space > 0.0 {
if matches!(g.ch, ' ' | shape::NBSP) && g.space > 0.0 {
ratio_sum += (g.adv + add[i]) / g.space;
ratio_n += 1;
}
@@ -2794,7 +2854,7 @@ fn distribute(line: &[Glyph], spaces: &[usize], extra: f64, sp: &Spacing, add: &
.iter()
.map(|&i| {
let g = &line[i];
if g.ch != ' ' && !(g.ch == '\u{3000}' && g.ideographic_space_elastic) {
if !g.is_word_space() {
0.0
} else if stretch {
g.space * (sp.word_max - sp.word_desired).max(0.0)
@@ -2804,8 +2864,8 @@ fn distribute(line: &[Glyph], spaces: &[usize], extra: f64, sp: &Spacing, add: &
})
.collect();
// Letter gaps: between visible glyphs (not after the line's last glyph).
let last_box = line.iter().rposition(|g| !g.is_space() && g.adv > 0.0).unwrap_or(0);
let is_box = |i: usize, g: &Glyph| i < last_box && !g.is_space() && g.adv > 0.0 && (!g.locked_advance || g.break_after != Some(false));
let last_box = line.iter().rposition(|g| !g.is_justify_space() && g.adv > 0.0).unwrap_or(0);
let is_box = |i: usize, g: &Glyph| i < last_box && !g.is_justify_space() && g.adv > 0.0 && (!g.locked_advance || g.break_after != Some(false));
let letter: Vec<f64> = line
.iter()
.enumerate()
@@ -2822,7 +2882,7 @@ fn distribute(line: &[Glyph], spaces: &[usize], extra: f64, sp: &Spacing, add: &
let glyph: Vec<f64> = line
.iter()
.map(|g| {
if g.is_space() || g.adv <= 0.0 || g.locked_advance {
if g.is_justify_space() || g.adv <= 0.0 || g.locked_advance {
0.0
} else {
let natural = g.adv / sp.glyph_desired.max(0.01);
@@ -3709,6 +3769,7 @@ mod column_allocation_safety_tests {
vertical: false,
exclusions: vec![],
page_name: None,
section_marker: None,
page: None,
grid: None,
left_page: false,
+114 -26
View File
@@ -5,6 +5,14 @@
//! 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.
//!
//! With Span Footnotes Across Columns the notes of a frame share one area across all its columns,
//! composed at the frame's text width: every column of the frame makes room for it.
use std::collections::HashMap;
use std::ops::Range;
@@ -22,10 +30,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,15 +72,41 @@ 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>>,
/// Footnotes span the columns of their frame.
span: bool,
/// Room a frame's earlier columns had to leave for spanning notes placed in a later column;
/// kept across re-lays so the columns before the reference make room on the next pass.
floor: HashMap<usize, f64>,
}
impl Notes {
pub fn new(doc: &Document, story: &Story) -> Notes {
let refs: Vec<usize> = if story.notes.is_empty() { Vec::new() } else { story.text.match_indices(FOOTNOTE_REF).map(|(i, _)| i).collect() };
let start = if refs.is_empty() { 1 } else { doc.footnote_start(story.id) };
Notes { numbers: vec![start; refs.len()], refs, num: start, key: None, placed: Vec::new(), cache: HashMap::new(), notes: story.notes.clone() }
Notes {
numbers: vec![start; refs.len()],
refs,
num: start,
key: None,
placed: Vec::new(),
cache: HashMap::new(),
notes: story.notes.clone(),
span: doc.footnote_options.span_columns,
floor: HashMap::new(),
}
}
/// Whether a note placed in (fi, col) shares the footnote area of column (`fi`, `ci`).
fn same_area(&self, p: &Placed, fi: usize, ci: usize) -> bool {
p.fi == fi && (self.span || p.col == ci)
}
/// The footnote area of column `col` of a frame with columns `cols`: the column, or all of
/// them when notes span columns.
pub fn area(&self, cols: &[Rect], col: usize) -> Option<Rect> {
if self.span { cols.iter().copied().reduce(|a, b| a.union(b)) } else { cols.get(col.min(cols.len().saturating_sub(1))).copied() }
}
pub fn active(&self) -> bool {
@@ -88,7 +150,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 +164,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 +172,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 +193,36 @@ 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| self.same_area(p, fi, 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 }
let r = if ms.is_empty() { 0.0 } else { stack_height(&ms, space_between) + space_before };
if self.span { r.max(self.floor.get(&fi).copied().unwrap_or(0.0)) } else { r }
}
/// After spanning notes were placed in column `col` of frame `fi`: whether a line of an earlier
/// column now runs into their area. The area is then remembered for the frame, to re-lay it.
pub fn earlier_columns_collide(&mut self, lines: &[crate::Line], fi: usize, col: usize, cols: &[Rect], reserve: f64) -> bool {
if !self.span || col == 0 || reserve <= self.floor.get(&fi).copied().unwrap_or(0.0) + 0.01 {
return false;
}
let hit = lines
.iter()
.any(|l| (l.column as usize) < col && cols.get(l.column as usize).is_some_and(|c| l.baseline + l.descent > c.y1 - reserve + 0.01));
if hit {
self.floor.insert(fi, reserve);
}
hit
}
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.
@@ -152,14 +231,14 @@ impl Notes {
return;
}
let o = &doc.footnote_options;
let mut groups: Vec<(usize, usize)> = self.placed.iter().map(|p| (p.fi, p.col)).collect();
let mut groups: Vec<(usize, usize)> = self.placed.iter().map(|p| (p.fi, if self.span { 0 } else { p.col })).collect();
groups.dedup();
groups.sort_unstable();
groups.dedup();
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 (Some(_), Some(col)) = (frames.get(fi), cols.get(fi).filter(|c| ci < c.len()).and_then(|c| self.area(c, ci))) else { continue };
let here: Vec<&Placed> = self.placed.iter().filter(|p| self.same_area(p, fi, ci)).collect();
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 +250,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,
column: p.col 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));
}
}
}
+55 -1
View File
@@ -78,12 +78,24 @@ impl Glyph {
pub fn is_space(&self) -> bool {
matches!(self.ch, ' ' | '\u{2002}'..='\u{200A}') || (self.ch == '\u{3000}' && self.ideographic_space_elastic)
}
/// A space whose width justification and word spacing vary: U+0020, the nonbreaking space
/// U+00A0 and an elastic ideographic space. Fixed-width spaces (U+2002–U+200A, U+202F) get no
/// word-space stretch or shrink.
pub fn is_word_space(&self) -> bool {
matches!(self.ch, ' ' | NBSP) || (self.ch == '\u{3000}' && self.ideographic_space_elastic)
}
/// A space that justification treats as a word space rather than a letter: [`Self::is_space`]
/// plus U+00A0, which is never a break opportunity.
pub fn is_justify_space(&self) -> bool {
self.is_space() || self.ch == NBSP
}
pub fn is_letter(&self) -> bool {
self.ch.is_alphabetic() || matches!(self.ch, '\'' | '’')
}
}
pub const SOFT_HYPHEN: char = '\u{AD}';
pub const NBSP: char = '\u{A0}';
/// Shaped paragraph.
pub struct ShapedPara {
@@ -96,6 +108,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 +159,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 {
@@ -291,6 +305,7 @@ pub(crate) fn shape_para(
resolved.push((a, b, props, fmt, para_chars.clone()));
}
}
let resolved = marks_join_their_base(&story.text, resolved);
for (a, b, props, fmt, resolved_base) in resolved {
let style = table.intern(db, &props);
// A missing font's substitute stands in for the whole font: fallback fonts help it.
@@ -353,6 +368,38 @@ pub(crate) fn shape_para(
ShapedPara { glyphs, range }
}
/// Combining marks that start a run are shaped with the letter before them when the two runs
/// differ only in OpenType features (ligatures, kerning, `otf_features`): the shaper attaches each
/// mark to its base, in its base's face. A run in another font, size or colour keeps its marks.
fn marks_join_their_base<'f>(
text: &str,
resolved: Vec<(usize, usize, CharProps, &'f designcraft_doc::CharFormat, CharProps)>,
) -> Vec<(usize, usize, CharProps, &'f designcraft_doc::CharFormat, CharProps)> {
let same_but_features = |a: &CharProps, b: &CharProps| {
*b == CharProps { ligatures: b.ligatures, kerning: b.kerning, otf_features: b.otf_features.clone(), ..a.clone() }
};
let is_base = |c: char| !c.is_whitespace() && !c.is_control() && !matches!(c, designcraft_doc::FOOTNOTE_REF | designcraft_doc::ENDNOTE_REF);
let mut out: Vec<(usize, usize, CharProps, &designcraft_doc::CharFormat, CharProps)> = Vec::with_capacity(resolved.len());
for (a, b, props, fmt, base) in resolved {
let run = text.get(a..b).unwrap_or("");
let marks = run.char_indices().find(|(_, c)| !is_mark(*c)).map_or(run.len(), |(i, _)| i);
if let Some(prev) = out.last_mut()
&& marks > 0
&& prev.1 == a
&& text.get(prev.0..prev.1).and_then(|s| s.chars().next_back()).is_some_and(is_base)
&& same_but_features(&prev.2, &props)
{
prev.1 = a + marks;
if a + marks < b {
out.push((a + marks, b, props, fmt, base));
}
continue;
}
out.push((a, b, props, fmt, base));
}
out
}
/// Tate-chu-yoko: in vertical text each run of marked glyphs takes one em along the line, its
/// glyphs set side by side across it; elsewhere the mark is dropped.
pub(crate) fn collapse_tcy(glyphs: &mut [Glyph], vertical: bool) {
@@ -619,7 +666,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);
+1
View File
@@ -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,
+397 -3
View File
@@ -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.
@@ -565,6 +622,110 @@ fn hyphenation_skips_a_no_break_word_at_the_start() {
assert!(!first.hyphenated && first.range.end >= 15, "{:?}", &text[first.range.clone()]);
}
/// Natural advance of each glyph of `text` by byte, and a frame width that puts the first three
/// words on a justified line with one word space of extra to share.
fn three_word_measure(text: &str) -> (std::collections::HashMap<usize, f64>, f64) {
let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 1000.0, 100.0), ParaAttrs::default());
let cs = compose_story(&d, sid, &ComposeOptions::default());
let line = all_lines(&cs)[0];
let adv = line.glyphs.iter().map(|g| (g.byte, g.adv)).collect::<std::collections::HashMap<_, _>>();
let third_end = text.find(" dddd").unwrap();
let gap = line.glyphs.iter().find(|g| g.byte == third_end).unwrap();
(adv, gap.x - line.x0 + gap.adv + 2.0 * line.x0)
}
fn justified_first_line(text: &str, width: f64, no_break: Option<std::ops::Range<usize>>) -> Line {
let para = ParaAttrs { align: Some(Align::LeftJustified), word_space_max: Some(4.0), ..Default::default() };
let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, width, 1000.0), para);
if let Some(run) = no_break {
d.story_mut(sid).unwrap().format_chars(run, |f| f.over.no_break = Some(true));
}
let cs = compose_story(&d, sid, &ComposeOptions::default());
let line = all_lines(&cs)[0].clone();
assert!((line.end_x - line.x1).abs() < 0.1, "line ends at {} not {}", line.end_x, line.x1);
line
}
#[test]
fn no_break_spaces_stretch_on_justified_lines() {
let text = "aaaa bbbb cccc dddd eeee ffff gggg hhhh";
let (natural, width) = three_word_measure(text);
// The run holds one of the line's two spaces, then both.
for run in [0..9, 0..14] {
let line = justified_first_line(text, width, Some(run.clone()));
assert_eq!(text[line.range.clone()].trim_end(), "aaaa bbbb cccc");
let words = line.glyphs.iter().filter(|g| g.byte < 14);
let (spaces, letters): (Vec<_>, Vec<_>) = words.partition(|g| text.as_bytes()[g.byte] == b' ');
assert_eq!(spaces.len(), 2);
for s in &spaces {
assert!(s.adv > natural[&s.byte] + 1.0 && (s.adv - spaces[0].adv).abs() < 1e-6, "run {run:?}: space at {} is {}", s.byte, s.adv);
}
for g in letters {
assert!((g.adv - natural[&g.byte]).abs() < 1e-6, "run {run:?}: letter at {} is {}", g.byte, g.adv);
}
}
}
#[test]
fn nonbreaking_space_stretches_and_fixed_width_one_does_not() {
for (nb, stretches) in [('\u{A0}', true), ('\u{202F}', false)] {
let text = format!("aaaa{nb}bbbb cccc dddd eeee ffff gggg hhhh");
let (natural, width) = three_word_measure(&text);
let line = justified_first_line(&text, width, None);
let nb_glyph = line.glyphs.iter().find(|g| g.byte == 4).unwrap();
let space = line.glyphs.iter().find(|g| text.as_bytes()[g.byte] == b' ').unwrap();
assert!(space.adv > natural[&space.byte] + 1.0);
if stretches {
assert!((nb_glyph.adv - space.adv).abs() < 1e-6, "{nb:?}: {} vs {}", nb_glyph.adv, space.adv);
} else {
assert!((nb_glyph.adv - natural[&4]).abs() < 1e-6, "{nb:?}: {} vs {}", nb_glyph.adv, natural[&4]);
}
}
}
#[test]
fn jidori_run_keeps_its_width_on_a_justified_line() {
fn first_line(text: &str, width: f64, para: ParaAttrs, run: std::ops::Range<usize>) -> Line {
let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, width, 1000.0), para);
d.story_mut(sid).unwrap().format_chars(run, |f| f.over.jidori = Some(6));
let cs = compose_story(&d, sid, &ComposeOptions::default());
all_lines(&cs)[0].clone()
}
for sp in ['\u{A0}', ' '] {
let text = format!("aa{sp}aa bbbb cccc dddd eeee ffff gggg hhhh");
let run = 0..4 + sp.len_utf8();
let run_width = |l: &Line| l.glyphs.iter().filter(|g| run.contains(&g.byte)).map(|g| g.adv).sum::<f64>();
let ragged = first_line(&text, 1000.0, ParaAttrs::default(), run.clone());
let gap = ragged.glyphs.iter().find(|g| g.byte == text.find(" dddd").unwrap()).unwrap();
let width = gap.x - ragged.x0 + gap.adv + 2.0 * ragged.x0;
let para = ParaAttrs { align: Some(Align::LeftJustified), word_space_max: Some(4.0), ..Default::default() };
let line = first_line(&text, width, para, run.clone());
assert_eq!(text[line.range.clone()].trim_end(), format!("aa{sp}aa bbbb cccc"));
assert!((line.end_x - line.x1).abs() < 0.1, "{sp:?}: line ends at {} not {}", line.end_x, line.x1);
assert!((run_width(&line) - run_width(&ragged)).abs() < 1e-6, "{sp:?}: run is {} not {}", run_width(&line), run_width(&ragged));
}
}
#[test]
fn nonbreaking_space_alone_fills_a_justified_line() {
let text = "10\u{A0}km 1234";
let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 1000.0, 100.0), ParaAttrs::default());
let cs = compose_story(&d, sid, &ComposeOptions::default());
let ragged = all_lines(&cs)[0];
let natural = ragged.glyphs.iter().map(|g| (g.byte, g.adv)).collect::<std::collections::HashMap<_, _>>();
let m = ragged.glyphs.iter().find(|g| g.byte == 5).unwrap();
let width = m.x - ragged.x0 + m.adv + 15.0 + 2.0 * ragged.x0;
let line = justified_first_line(text, width, None);
assert_eq!(text[line.range.clone()].trim_end(), "10\u{A0}km");
for g in line.glyphs.iter().filter(|g| g.byte < 6) {
if g.byte == 2 {
assert!((g.adv - natural[&2] - 15.0).abs() < 0.1, "U+00A0 is {}", g.adv);
} else {
assert!((g.adv - natural[&g.byte]).abs() < 1e-6, "glyph at {} is {}", g.byte, g.adv);
}
}
}
/// Story of `n` one-line filler paragraphs followed by `extra` paragraphs, in a 2-column frame.
fn keep_doc(fillers: usize, extra: &[&str], height: f64) -> (Document, StoryId, Vec<usize>) {
let mut parts: Vec<String> = (0..fillers).map(|k| format!("Filler {k}")).collect();
@@ -1218,6 +1379,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
@@ -1269,6 +1476,44 @@ fn footnote_line_at_column_top_still_sets() {
assert_eq!(cs.frames[0].notes.len(), 1);
}
#[test]
fn footnotes_span_columns_across_the_frame_bottom() {
// Span Footnotes Across Columns: the note area runs under every column of the frame and each
// column's text ends above it, also the columns before the one holding the reference.
let text = [LOREM; 8].join(" ");
let note = "A long footnote, long enough to wrap if it is set one column wide rather than across the frame.";
let compose = |at: usize, span: bool| {
let (mut d, sid, fid) = doc_with(&text, Rect::new(36.0, 36.0, 436.0, 236.0), ParaAttrs::default());
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2;
d.footnote_options.span_columns = span;
d.story_mut(sid).unwrap().insert_note(at, note, ParaFormat::default());
let space_before = d.footnote_options.space_before;
(compose_story(&d, sid, &ComposeOptions::default()), space_before)
};
let plain = compose(10, false).0;
// A reference halfway down the second column.
let in_col1 = plain.frames[0].lines.iter().filter(|l| l.column == 1).nth(5).unwrap().range.start + 5;
for (at, ref_col) in [(10, 0), (in_col1, 1)] {
let (cs, space_before) = compose(at, true);
let ft = &cs.frames[0];
let (c0, c1) = (ft.columns[0], ft.columns[1]);
assert_eq!(ft.notes.len(), 1);
let n = &ft.notes[0];
assert_eq!(n.column, ref_col, "ref at {at}");
assert!((n.rect.x0 - c0.x0).abs() < 1e-6 && (n.rect.x1 - c1.x1).abs() < 1e-6, "ref at {at}: note {:?} spans the frame", n.rect);
assert!((n.rect.y1 - c0.y1).abs() < 1e-6, "ref at {at}: note ends at the frame bottom");
for col in 0..2 {
let last = ft.lines.iter().rfind(|l| l.column == col).unwrap();
assert!(last.baseline + last.descent <= n.rect.y0 - space_before + 0.01, "ref at {at}: column {col} text clears the note");
}
assert!(n.text.frames[0].lines.iter().all(|l| l.x1 - l.x0 > c0.width() + 1.0), "ref at {at}: note set at the frame width");
}
// Off: the note stays in the column holding its reference.
let n = &compose(in_col1, false).0.frames[0].notes[0];
assert_eq!(n.column, 1);
assert!((n.rect.x0 - plain.frames[0].columns[1].x0).abs() < 1e-6 && (n.rect.x1 - plain.frames[0].columns[1].x1).abs() < 1e-6);
}
#[test]
fn hj_severity_shades() {
use crate::hj_severity;
@@ -1481,6 +1726,27 @@ fn warichu_stacks_the_run_inside_the_line_and_closes_up() {
assert!((l.end_x - (z.x + z.adv)).abs() < 1e-3, "the line-end caret follows the close-up");
}
#[test]
fn warichu_line_spacing_from_a_document_stays_finite() {
for spacing in [1e308, -1e308, f64::INFINITY, f64::NAN] {
let mut d = Document::new(&designcraft_doc::build::NewDocument::default());
let lid = d.default_layer();
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 500.0, 200.0), lid, "ABCDEFGHZ", ParaFormat::default()).unwrap();
d.story_mut(sid).unwrap().format_chars(0..8, |f| {
f.over.warichu = Some(true);
f.over.warichu_lines = Some(16);
f.over.warichu_line_spacing = Some(spacing);
});
let cs = compose_story(&d, sid, &ComposeOptions::default());
let l = &cs.frames[0].lines[0];
assert!(
l.glyphs.iter().all(|g| g.x.is_finite() && g.y.is_finite() && g.y.abs() < 1e4),
"{spacing}: {:?}",
l.glyphs.iter().map(|g| g.y).collect::<Vec<_>>()
);
}
}
#[test]
fn warichu_break_minimum_keeps_a_short_run_on_one_line() {
let mut d = Document::new(&designcraft_doc::build::NewDocument::default());
@@ -2617,6 +2883,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());
@@ -3103,6 +3388,40 @@ fn missing_glyphs_are_the_fonts_box_unless_fallback_is_on() {
assert_ne!(gid, 0, "a fallback font draws it");
}
#[test]
fn a_mark_starting_a_run_is_shaped_with_its_base() {
use designcraft_fonts::testing::font_with;
const HEBREW: &str = "DC Test Hebrew Points";
designcraft_fonts::FontDb::global().add_font(font_with(HEBREW, &['ש', '\u{5C2}', 'ל']).unwrap());
// Shin, then sin dot and lamed in a run that only turns ligatures off.
let text = "ש\u{5C2}ל";
let mark = "ש".len();
let glyphs = |family: &str, mark_family: Option<&str>| {
let (mut d, sid, _) = doc_with(text, Rect::new(36.0, 36.0, 300.0, 100.0), ParaAttrs::default());
let story = d.story_mut(sid).unwrap();
story.format_chars(0..text.len(), |f| f.over.font_family = Some(family.into()));
story.format_chars(mark..text.len(), |f| {
f.over.ligatures = Some(false);
if let Some(m) = mark_family {
f.over.font_family = Some(m.into());
}
});
let cs = compose_story(&d, sid, &ComposeOptions::default());
all_lines(&cs).iter().flat_map(|l| l.glyphs.iter()).map(|g| (g.face, g.gid, g.byte)).collect::<Vec<_>>()
};
// A missing font: its substitute lacks both, so the letter comes from a fallback font, and the
// mark from the same font, in its letter's cluster.
let g = glyphs("DC Test No Such Family", None);
let (base, dot) = (g[0], g[1]);
assert_ne!(base.1, 0, "{g:?}");
assert_eq!((dot.0.family.as_str(), dot.2), (base.0.family.as_str(), 0), "the sin dot goes with its shin: {g:?}");
assert_ne!(dot.1, 0, "{g:?}");
// The run's own font lacks the mark: its box, whatever font the letter before it has.
let g = glyphs(HEBREW, Some(designcraft_fonts::DEFAULT_FAMILY));
let dot = g.iter().find(|x| x.2 == mark).unwrap();
assert_eq!((dot.0.family.as_str(), dot.1), (designcraft_fonts::DEFAULT_FAMILY, 0), "{g:?}");
}
#[test]
fn kenten_missing_from_the_font_are_its_box_unless_fallback_is_on() {
use designcraft_fonts::testing::font_with;
@@ -3135,7 +3454,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 +3484,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 +3774,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 +4379,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");
}
+17 -2
View File
@@ -84,7 +84,8 @@ fn line_count(len: usize, want: usize, before: usize, after: usize) -> usize {
let before = before.max(1);
let after = after.max(1);
while n >= 2 {
if before + after + n - 2 <= len {
// The minimums come from documents and commands: on 32-bit targets their sum can overflow.
if before.saturating_add(after).saturating_add(n - 2) <= len {
return n;
}
n -= 1;
@@ -220,7 +221,9 @@ fn layout(styles: &[RunStyle], line: &mut [PlacedGlyph], members: &[usize]) -> f
let small = st.size * scale;
// 0 is the default (one small em). Any other value, including a negative one, is added to
// that em and then clamped so the rows cannot swap order.
let gap = if st.warichu_line_spacing == 0.0 { small } else { (small + st.warichu_line_spacing).max(0.0) };
// Bounded, so a hostile value can't move a row to an infinite or huge coordinate.
let spacing = if st.warichu_line_spacing.is_finite() { st.warichu_line_spacing.clamp(-16.0 * st.size, 16.0 * st.size) } else { 0.0 };
let gap = if spacing == 0.0 { small } else { (small + spacing).max(0.0) };
let mut off = 0_usize;
for (li, &c) in counts.iter().enumerate() {
let w = widths[li];
@@ -264,3 +267,15 @@ fn layout(styles: &[RunStyle], line: &mut [PlacedGlyph], members: &[usize]) -> f
}
delta
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn huge_break_minimums_keep_one_line() {
assert_eq!(line_count(10, 2, usize::MAX, 1), 1);
assert_eq!(line_count(10, 3, 1, usize::MAX), 1);
assert_eq!(line_count(10, 2, 3, 3), 2);
}
}
+95 -1
View File
@@ -62,7 +62,7 @@ macro_rules! attr_set {
return Ok(());
}
)*
Err(format!("unknown attribute `{key}`"))
Err(unknown_attribute(key, &[ $( stringify!($field), )* ]))
}
/// Fields where `self` differs from `base`'s resolved value (used for "clear overrides").
pub fn diff_from(&self, base: &$props) -> $attrs {
@@ -104,6 +104,57 @@ fn attr_key(snake: &'static str) -> &'static str {
snake
}
/// `snake_case` → `camelCase`, as commands name attributes.
fn camel(snake: &str) -> String {
let mut out = String::with_capacity(snake.len());
let mut up = false;
for c in snake.chars() {
if c == '_' {
up = true;
} else if up {
out.push(c.to_ascii_uppercase());
up = false;
} else {
out.push(c);
}
}
out
}
/// Edits (insert, delete, replace) that turn `a` into `b`, ignoring case and underscores.
fn edit_distance(a: &str, b: &str) -> usize {
let norm = |s: &str| s.chars().filter(|c| *c != '_').map(|c| c.to_ascii_lowercase()).collect::<Vec<_>>();
let (a, b) = (norm(a), norm(b));
let mut prev: Vec<usize> = (0..=b.len()).collect();
for (i, ca) in a.iter().enumerate() {
let mut row = vec![i + 1];
for (j, cb) in b.iter().enumerate() {
let d = (prev[j] + usize::from(ca != cb)).min(prev[j + 1] + 1).min(row[j] + 1);
row.push(d);
}
prev = row;
}
prev.last().copied().unwrap_or(0)
}
/// The error for an attribute `key` that isn't one of `fields` (snake_case): the nearest one
/// (a typo, or a name it starts or ends: `font` → `fontFamily`), or else all of them.
fn unknown_attribute(key: &str, fields: &[&str]) -> String {
let k = key.to_ascii_lowercase().replace('_', "");
let flat = |f: &str| f.replace('_', "");
let near = fields
.iter()
.map(|f| (edit_distance(key, f), *f))
.filter(|(d, _)| *d <= (k.len() / 3).max(1))
.min()
.map(|(_, f)| f)
.or_else(|| fields.iter().copied().find(|f| k.len() >= 3 && (flat(f).starts_with(&k) || flat(f).ends_with(&k))));
match near {
Some(f) => format!("unknown attribute `{key}`; did you mean `{}`?", camel(f)),
None => format!("unknown attribute `{key}`; the attributes are: {}", fields.iter().map(|f| camel(f)).collect::<Vec<_>>().join(", ")),
}
}
// ---------- value types ----------
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
@@ -190,6 +241,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 +330,7 @@ pub fn language_tag(language: &str) -> Option<&'static str> {
"persian" | "farsi" => "fa",
"urdu" => "ur",
"hebrew" => "he",
"georgian" => "ka",
_ => return None,
})
}
@@ -753,6 +812,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,
@@ -974,6 +1036,23 @@ impl ParaProps {
mod tests {
use super::*;
/// #777: an unknown attribute names the nearest one, or lists them all.
#[test]
fn unknown_attributes_suggest_the_right_name() {
let mut c = CharAttrs::default();
assert_eq!(c.set_json("font", &serde_json::json!("X")).unwrap_err(), "unknown attribute `font`; did you mean `fontFamily`?");
assert_eq!(c.set_json("fontFamly", &serde_json::json!("X")).unwrap_err(), "unknown attribute `fontFamly`; did you mean `fontFamily`?");
let all = c.set_json("zzzzzz", &serde_json::json!(1)).unwrap_err();
assert!(
all.starts_with("unknown attribute `zzzzzz`; the attributes are: ") && all.contains("fontFamily") && all.contains("fontStyle"),
"{all}"
);
assert!(c.set_json("font_family", &serde_json::json!("X")).is_ok() && c.set_json("fontFamily", &serde_json::json!("X")).is_ok());
let mut p = ParaAttrs::default();
assert_eq!(p.set_json("spaceBefor", &serde_json::json!(3)).unwrap_err(), "unknown attribute `spaceBefor`; did you mean `spaceBefore`?");
assert_eq!(camel("first_line_indent"), "firstLineIndent");
}
#[test]
fn a_partial_rule_keeps_the_other_fields() {
let current = ParaProps {
@@ -989,6 +1068,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 +1097,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"));
+55 -8
View File
@@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
use crate::ids::{ItemId, LayerId, PageId, SpreadId};
use crate::item::Item;
use crate::page::{Columns, Margins, Page, PageSide, ParentInfo, Section, Spread};
use crate::page::{Columns, Guide, Margins, Orientation, Page, PageSide, ParentInfo, Section, Spread};
use crate::styles::Styles;
use crate::{DataMerge, DocError, DocSettings, Document, Intent, LAYER_COLORS, Layer, ParaFormat, Result, SpreadRef};
@@ -226,6 +226,7 @@ impl Document {
liquid: Default::default(),
transition: None,
view_rotation: 0,
show_parent_overlay: false,
}
}
@@ -267,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) {
@@ -326,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() {
@@ -373,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)));
@@ -549,9 +560,32 @@ impl Document {
let pi = self.parent_index(page.parent?)?;
let ps = &self.parents[pi];
// A parent spread can come without pages from a file.
let idx = if ps.pages.len() >= 2 && page.side == PageSide::Left { 0 } else { ps.pages.len().checked_sub(1)? };
// A single-sided page takes the parent's first page, like a left-hand page.
let first = ps.pages.len() >= 2 && matches!(page.side, PageSide::Left | PageSide::Single);
let idx = if first { 0 } else { ps.pages.len().checked_sub(1)? };
Some((pi, idx))
}
/// Ruler guides document page `abs` inherits from its applied parent page. They are stored on
/// the parent page only: positions are moved from the parent page's place in its spread to
/// this page's place in its own, so they draw and snap where the page is. Editing them
/// happens on the parent page.
pub fn inherited_guides(&self, abs: usize) -> Vec<Guide> {
let (Some(page), Some((pi, qi))) = (self.page(abs), self.parent_page_for(abs)) else { return Vec::new() };
let Some(parent) = self.parents.get(pi).and_then(|ps| ps.pages.get(qi)) else { return Vec::new() };
let dx = page.x - parent.x;
parent
.guides
.iter()
.map(|g| {
let mut g = g.clone();
if g.orientation == Orientation::Vertical {
g.position += dx;
}
g
})
.collect()
}
}
#[cfg(test)]
@@ -584,6 +618,19 @@ mod tests {
assert_eq!(d.parent_page_for(1), None);
}
/// A single-sided page applying a two-page parent shows the parent's first page.
#[test]
fn single_sided_page_shows_first_parent_page() {
let mut d = Document::new(&NewDocument { pages: 2, facing_pages: false, ..Default::default() });
let extra = d.parents[0].pages[0].clone();
Arc::make_mut(&mut d.parents[0]).pages.push(extra);
assert_eq!(d.parents[0].pages.len(), 2);
assert_eq!(d.parent_page_for(0), Some((0, 0)));
assert_eq!(d.parent_page_for(1), Some((0, 0)));
Arc::make_mut(&mut d.parents[0]).pages.clear();
assert_eq!(d.parent_page_for(0), None);
}
#[test]
fn non_facing_one_page_per_spread() {
let d = Document::new(&NewDocument { pages: 3, facing_pages: false, ..Default::default() });
+341
View File
@@ -0,0 +1,341 @@
//! Flex Layout (InDesign 2026): a frame whose objects are placed in rows or columns by direction,
//! wrap, justification, alignment, padding and spacing, like CSS Flexbox. The container is a frame
//! holding its objects as nested items (as Edit › Paste Into does), so it draws its fill, clips its
//! objects and strokes like any frame; [`layout`] moves the objects to where the settings put them.
use designcraft_geom::{Affine, Rect, Vec2, shapes};
use serde::{Deserialize, Serialize};
use crate::{Content, Item};
/// The main axis and the way along it (Flex Layout panel › Direction).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum FlexDirection {
#[default]
Row,
RowReverse,
Column,
ColumnReverse,
}
impl FlexDirection {
pub fn is_row(self) -> bool {
matches!(self, FlexDirection::Row | FlexDirection::RowReverse)
}
pub fn is_reverse(self) -> bool {
matches!(self, FlexDirection::RowReverse | FlexDirection::ColumnReverse)
}
}
/// How the objects of a row (or column) share its length (Flex Layout panel › Justification).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum FlexJustify {
#[default]
FlexStart,
Center,
FlexEnd,
SpaceBetween,
SpaceAround,
SpaceEvenly,
}
impl FlexJustify {
/// The space modes work the spacing out themselves (the panel shows it, greyed).
pub fn distributes(self) -> bool {
matches!(self, FlexJustify::SpaceBetween | FlexJustify::SpaceAround | FlexJustify::SpaceEvenly)
}
}
/// Where rows sit across the container, and objects across their row (the alignment grid's other
/// axis).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum FlexAlign {
#[default]
Start,
Center,
End,
}
/// Width or height of a container: Fixed, or Auto (it grows and shrinks with its objects).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum FlexSize {
#[default]
Fixed,
Auto,
}
/// A Flex Layout container's settings.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct FlexLayout {
pub direction: FlexDirection,
/// Wrap: objects that don't fit start a new row (column).
pub wrap: bool,
pub justify: FlexJustify,
pub align: FlexAlign,
pub width: FlexSize,
pub height: FlexSize,
/// Padding inside the container: top, left, bottom, right.
pub padding: [f64; 4],
/// Spacing between objects horizontally and vertically.
pub gap_h: f64,
pub gap_v: f64,
}
impl Default for FlexLayout {
fn default() -> Self {
FlexLayout {
direction: FlexDirection::Row,
wrap: false,
justify: FlexJustify::FlexStart,
align: FlexAlign::Start,
width: FlexSize::Fixed,
height: FlexSize::Fixed,
padding: [0.0; 4],
gap_h: 0.0,
gap_v: 0.0,
}
}
}
impl FlexLayout {
/// Spacing along the main axis and across it.
pub fn gaps(&self) -> (f64, f64) {
if self.direction.is_row() { (self.gap_h, self.gap_v) } else { (self.gap_v, self.gap_h) }
}
}
/// Where [`arrange`] puts things: the container's rectangle and each object's offset, in the
/// container's inner space.
#[derive(Clone, Debug, PartialEq)]
pub struct Arrangement {
pub rect: Rect,
pub moves: Vec<Vec2>,
/// The spacing the main axis ends up with between the first row's objects (what the panel shows
/// for a space mode).
pub main_gap: f64,
}
/// Lay out objects of the given bounds in `rect` (both in the container's inner space).
pub fn arrange(f: &FlexLayout, rect: Rect, bounds: &[Rect]) -> Arrangement {
let row = f.direction.is_row();
let fin = |v: f64| if v.is_finite() { v.max(0.0) } else { 0.0 };
let [pt, pl, pb, pr] = f.padding.map(fin);
let (gap_main, gap_cross) = f.gaps();
let (gap_main, gap_cross) = (fin(gap_main), fin(gap_cross));
let size = |b: &Rect| if row { (b.width(), b.height()) } else { (b.height(), b.width()) };
let (main_auto, cross_auto) =
if row { (f.width == FlexSize::Auto, f.height == FlexSize::Auto) } else { (f.height == FlexSize::Auto, f.width == FlexSize::Auto) };
let (pad_main_start, pad_main_end, pad_cross_start, pad_cross_end) = if row { (pl, pr, pt, pb) } else { (pt, pb, pl, pr) };
let (rect_main, rect_cross) = if row { (rect.width(), rect.height()) } else { (rect.height(), rect.width()) };
let mut main_len = (rect_main - pad_main_start - pad_main_end).max(0.0);
// Rows: the objects in each, broken where the next one wouldn't fit (Wrap on a fixed length).
let mut lines: Vec<Vec<usize>> = Vec::new();
let mut used = 0.0;
for (i, b) in bounds.iter().enumerate() {
let (m, _) = size(b);
let breaks = f.wrap && !main_auto && lines.last().is_some_and(|l| !l.is_empty() && used + gap_main + m > main_len + 1e-6);
match lines.last_mut() {
Some(l) if !breaks => {
used += if l.is_empty() { m } else { gap_main + m };
l.push(i);
}
_ => {
lines.push(vec![i]);
used = m;
}
}
}
let line_main = |l: &[usize]| l.iter().map(|&i| size(&bounds[i]).0).sum::<f64>() + gap_main * l.len().saturating_sub(1) as f64;
let line_cross = |l: &[usize]| l.iter().map(|&i| size(&bounds[i]).1).fold(0.0, f64::max);
if main_auto {
main_len = lines.iter().map(|l| line_main(l)).fold(0.0, f64::max);
}
let content_cross = lines.iter().map(|l| line_cross(l)).sum::<f64>() + gap_cross * lines.len().saturating_sub(1) as f64;
let cross_len = if cross_auto { content_cross } else { (rect_cross - pad_cross_start - pad_cross_end).max(0.0) };
// The container: Auto sides fit the objects, from its top left.
let (w, h) = if row {
(main_len + pad_main_start + pad_main_end, cross_len + pad_cross_start + pad_cross_end)
} else {
(cross_len + pad_cross_start + pad_cross_end, main_len + pad_main_start + pad_main_end)
};
let out_rect = Rect::new(
rect.x0,
rect.y0,
rect.x0 + if (row && main_auto) || (!row && cross_auto) { w } else { rect.width() },
rect.y0 + if (!row && main_auto) || (row && cross_auto) { h } else { rect.height() },
);
let shift = |free: f64| match f.align {
FlexAlign::Start => 0.0,
FlexAlign::Center => free / 2.0,
FlexAlign::End => free,
};
let mut moves = vec![Vec2::ZERO; bounds.len()];
let mut cross_at = shift(cross_len - content_cross);
let mut main_gap = gap_main;
for (li, l) in lines.iter().enumerate() {
let lc = line_cross(l);
let free = main_len - line_main(l);
let n = l.len() as f64;
let (start, gap) = match f.justify {
_ if free <= 0.0 => (0.0, gap_main),
FlexJustify::FlexStart => (0.0, gap_main),
FlexJustify::Center => (free / 2.0, gap_main),
FlexJustify::FlexEnd => (free, gap_main),
FlexJustify::SpaceBetween if l.len() > 1 => (0.0, gap_main + free / (n - 1.0)),
FlexJustify::SpaceBetween => (0.0, gap_main),
FlexJustify::SpaceAround => (free / (2.0 * n), gap_main + free / n),
FlexJustify::SpaceEvenly => (free / (n + 1.0), gap_main + free / (n + 1.0)),
};
if li == 0 {
main_gap = gap;
}
let mut at = start;
for &i in l {
let (m, c) = size(&bounds[i]);
// Reverse directions run from the end.
let main = if f.direction.is_reverse() { main_len - at - m } else { at };
let cross = cross_at + shift(lc - c);
let (x, y) = if row {
(out_rect.x0 + pad_main_start + main, out_rect.y0 + pad_cross_start + cross)
} else {
(out_rect.x0 + pad_cross_start + cross, out_rect.y0 + pad_main_start + main)
};
moves[i] = Vec2::new(x - bounds[i].x0, y - bounds[i].y0);
at += m + gap;
}
cross_at += lc + gap_cross;
}
Arrangement { rect: out_rect, moves, main_gap }
}
/// Lay out a container's objects (and size it, for Auto sides). Returns whether anything moved.
pub fn layout(it: &mut Item) -> bool {
let Some(f) = it.flex.clone() else { return false };
let Content::Group { items } = &it.content else { return false };
let rect = it.inner_bounds();
let bounds: Vec<Rect> = items.iter().map(|c| c.bounds()).collect();
let a = arrange(&f, rect, &bounds);
let mut changed = false;
if (a.rect.width() - rect.width()).abs() > 1e-6 || (a.rect.height() - rect.height()).abs() > 1e-6 {
it.path = shapes::rectangle(a.rect);
changed = true;
}
if let Content::Group { items } = &mut it.content {
for (c, d) in items.iter_mut().zip(&a.moves) {
if d.hypot() > 1e-6 {
let c = std::sync::Arc::make_mut(c);
c.xf = Affine::translate(*d) * c.xf;
changed = true;
}
}
}
changed
}
/// Lay out every container among `items` and the objects nested in them, inner containers first
/// (an Auto container's size is its parent's object size). Only changed items are copied.
pub fn layout_all(items: &mut [std::sync::Arc<Item>]) {
for a in items.iter_mut() {
if !contains_flex(a) {
continue;
}
let it = std::sync::Arc::make_mut(a);
if let Content::Group { items } = &mut it.content {
layout_all(items);
}
layout(it);
}
}
/// Is `it` a container, or does it hold one?
pub fn contains_flex(it: &Item) -> bool {
it.flex.is_some() || matches!(&it.content, Content::Group { items } if items.iter().any(|c| contains_flex(c)))
}
#[cfg(test)]
mod tests {
use super::*;
fn r(x0: f64, y0: f64, w: f64, h: f64) -> Rect {
Rect::new(x0, y0, x0 + w, y0 + h)
}
fn placed(f: &FlexLayout, rect: Rect, bounds: &[Rect]) -> (Rect, Vec<(f64, f64)>) {
let a = arrange(f, rect, bounds);
(a.rect, bounds.iter().zip(&a.moves).map(|(b, m)| ((b.x0 + m.x).round(), (b.y0 + m.y).round())).collect())
}
#[test]
fn a_row_justifies_and_aligns() {
let boxes = [r(500.0, 500.0, 100.0, 50.0), r(0.0, 0.0, 100.0, 80.0), r(0.0, 0.0, 100.0, 50.0)];
let mut f = FlexLayout { gap_h: 10.0, ..Default::default() };
let rect = r(0.0, 0.0, 400.0, 100.0);
assert_eq!(placed(&f, rect, &boxes).1, [(0.0, 0.0), (110.0, 0.0), (220.0, 0.0)]);
f.justify = FlexJustify::FlexEnd;
assert_eq!(placed(&f, rect, &boxes).1[0], (80.0, 0.0));
f.justify = FlexJustify::Center;
assert_eq!(placed(&f, rect, &boxes).1[0], (40.0, 0.0));
f.justify = FlexJustify::SpaceBetween;
assert_eq!(placed(&f, rect, &boxes).1, [(0.0, 0.0), (150.0, 0.0), (300.0, 0.0)]);
assert_eq!(arrange(&f, rect, &boxes).main_gap, 50.0);
f.justify = FlexJustify::SpaceEvenly;
assert_eq!(placed(&f, rect, &boxes).1[0], (20.0, 0.0));
f.justify = FlexJustify::SpaceAround;
assert_eq!(placed(&f, rect, &boxes).1[0], (13.0, 0.0));
// Across: the 80 tall row; the 50 tall boxes centre or end in it, and the row in the container.
f.justify = FlexJustify::FlexStart;
f.align = FlexAlign::Center;
assert_eq!(placed(&f, rect, &boxes).1, [(0.0, 25.0), (110.0, 10.0), (220.0, 25.0)]);
f.align = FlexAlign::End;
assert_eq!(placed(&f, rect, &boxes).1[1], (110.0, 20.0));
f.direction = FlexDirection::RowReverse;
f.align = FlexAlign::Start;
assert_eq!(placed(&f, rect, &boxes).1, [(300.0, 0.0), (190.0, 0.0), (80.0, 0.0)]);
}
/// The video's second layout: a column that wraps into two, 56 apart, 38 between objects,
/// centred in the container both ways.
#[test]
fn a_wrapped_column_centres() {
let boxes = [r(0.0, 0.0, 300.0, 400.0); 4];
let f = FlexLayout {
direction: FlexDirection::Column,
wrap: true,
justify: FlexJustify::Center,
align: FlexAlign::Center,
gap_h: 56.0,
gap_v: 38.0,
..Default::default()
};
let (rect, at) = placed(&f, r(0.0, 0.0, 868.0, 912.0), &boxes);
assert_eq!(rect, r(0.0, 0.0, 868.0, 912.0));
// Each column 400 + 38 + 400 = 838 tall, centred in 912; the two 300 wide columns and the
// 56 between them (656) centred in 868.
assert_eq!(at, [(106.0, 37.0), (106.0, 475.0), (462.0, 37.0), (462.0, 475.0)]);
}
#[test]
fn auto_sides_fit_the_objects_and_padding() {
let boxes = [r(0.0, 0.0, 100.0, 50.0), r(0.0, 0.0, 60.0, 70.0)];
let f = FlexLayout { width: FlexSize::Auto, height: FlexSize::Auto, gap_h: 20.0, padding: [5.0, 10.0, 15.0, 30.0], ..Default::default() };
let (rect, at) = placed(&f, r(40.0, 40.0, 10.0, 10.0), &boxes);
assert_eq!(rect, Rect::new(40.0, 40.0, 40.0 + 10.0 + 180.0 + 30.0, 40.0 + 5.0 + 70.0 + 15.0));
assert_eq!(at, [(50.0, 45.0), (170.0, 45.0)]);
// Auto ignores Wrap: one row.
let wrapped = FlexLayout { wrap: true, ..f };
assert_eq!(placed(&wrapped, r(40.0, 40.0, 10.0, 10.0), &boxes).1, at);
}
#[test]
fn hostile_numbers_dont_break_the_layout() {
let f = FlexLayout { gap_h: f64::NAN, padding: [f64::INFINITY, -5.0, 0.0, 0.0], wrap: true, ..Default::default() };
let a = arrange(&f, r(0.0, 0.0, 10.0, 10.0), &[r(0.0, 0.0, 50.0, 50.0), r(0.0, 0.0, 50.0, 50.0)]);
assert!(a.moves.iter().all(|m| m.x.is_finite() && m.y.is_finite()));
assert!(arrange(&f, r(0.0, 0.0, 10.0, 10.0), &[]).moves.is_empty());
}
}
+33 -2
View File
@@ -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('Ა'));
}
}
+107
View File
@@ -32,6 +32,43 @@ 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),
}
}
/// A linear gradient's axis on an object whose bounds (in its own space) are `b`: the
/// Gradient Swatch tool's vector, or the box's extent along the gradient's angle through its
/// centre. A box with no extent along that angle (a straight line, across it) takes its longer
/// side.
pub fn linear_axis(&self, b: Rect) -> (Point, Point) {
if let Some([x0, y0, x1, y1]) = self.gradient_vector {
return (Point::new(x0, y0), Point::new(x1, y1));
}
let a = self.gradient_angle.unwrap_or(0.0).to_radians();
let half = (b.width() * a.cos().abs() + b.height() * a.sin().abs()) / 2.0;
let half = if half > 1e-9 { half } else { b.width().max(b.height()).max(1e-3) / 2.0 };
let d = designcraft_geom::Vec2::new(a.cos(), -a.sin()) * half;
(b.center() - d, b.center() + d)
}
pub fn none() -> Self {
Fill { swatch: designcraft_color::swatch::NONE.into(), tint: 1.0, gradient_angle: None, gradient_vector: None, overprint: false }
}
@@ -224,6 +261,12 @@ pub struct Stroke {
/// Overprint Gap (Attributes panel).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub gap_overprint: bool,
/// A gradient swatch's angle on the stroke, as [`Fill::gradient_angle`].
#[serde(skip_serializing_if = "Option::is_none")]
pub gradient_angle: Option<f64>,
/// The Gradient Swatch tool's vector on the stroke, as [`Fill::gradient_vector`].
#[serde(skip_serializing_if = "Option::is_none")]
pub gradient_vector: Option<[f64; 4]>,
}
impl Default for Stroke {
@@ -244,11 +287,25 @@ impl Default for Stroke {
gap_tint: 1.0,
overprint: false,
gap_overprint: false,
gradient_angle: None,
gradient_vector: None,
}
}
}
impl Stroke {
/// The stroke's paint (swatch, tint and gradient geometry) as a fill, to paint it the same way:
/// a gradient swatch on a stroke runs across the object's box as on its fill.
pub fn as_fill(&self) -> Fill {
Fill {
swatch: self.swatch.clone(),
tint: self.tint,
gradient_angle: self.gradient_angle,
gradient_vector: self.gradient_vector,
overprint: self.overprint,
}
}
/// How far the painted stroke can reach beyond the path (arrowheads reach further).
pub fn extent(&self) -> f64 {
if self.start == Arrowhead::None && self.end == Arrowhead::None { self.weight } else { self.weight.max(0.5) * 5.5 }
@@ -1016,6 +1073,9 @@ pub struct Item {
pub isolate: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub knockout: bool,
/// Flex Layout: this frame lays out the objects nested in it (see [`crate::flex`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub flex: Option<crate::flex::FlexLayout>,
}
fn identity() -> Affine {
@@ -1061,6 +1121,7 @@ impl Item {
active_state: 0,
isolate: false,
knockout: false,
flex: None,
}
}
@@ -1109,6 +1170,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 +1349,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)));
+29
View File
@@ -19,6 +19,7 @@ pub mod cjk;
pub mod datamerge;
mod edit;
pub mod endnotes;
pub mod flex;
pub mod ids;
pub mod index;
pub mod item;
@@ -47,6 +48,7 @@ pub use designcraft_color as color;
pub use designcraft_geom as geom;
pub use edit::{ItemLoc, ItemPath, SpreadRef, item_hit as edit_hit};
pub use endnotes::{ENDNOTE_REF, EditorialNote, EndnoteOptions, NOTE_MARK};
pub use flex::{FlexAlign, FlexDirection, FlexJustify, FlexLayout, FlexSize};
pub use ids::*;
pub use index::{INDEX_MARK, IndexRef};
pub use item::*;
@@ -578,6 +580,30 @@ pub struct TocEntry {
/// 1-based nesting level.
#[serde(default = "one")]
pub level: u8,
/// Paragraph style of this style's entries; `None` is the generated "TOC Level n" style.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entry_style: Option<String>,
#[serde(default)]
pub page_number: TocPageNumber,
/// Text between the entry and its page number; `None` is a tab.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub separator: Option<String>,
}
impl TocEntry {
pub fn new(style: impl Into<String>, level: u8) -> Self {
Self { style: style.into(), level, entry_style: None, page_number: TocPageNumber::After, separator: None }
}
}
/// Where an entry's page number goes.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TocPageNumber {
#[default]
After,
Before,
None,
}
fn one() -> u8 {
@@ -592,6 +618,9 @@ pub struct Toc {
pub entries: Vec<TocEntry>,
#[serde(default)]
pub page_numbers: bool,
/// Paragraph style of the title; `None` is the generated "TOC Title" style.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title_style: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+1 -1
View File
@@ -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 {
+6
View File
@@ -114,6 +114,9 @@ pub struct Page {
/// Liquid page rule: how objects follow when the page changes size.
#[serde(default, skip_serializing_if = "LiquidRule::is_off")]
pub liquid: LiquidRule,
/// Page tool › Show Parent Page Overlay: the parent page's size drawn over this page.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub show_parent_overlay: bool,
/// Window › Interactive › Page Transitions (the spread's first page holds the spread's).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub transition: Option<PageTransition>,
@@ -208,6 +211,8 @@ pub enum LiquidRule {
GuideBased,
/// Each object's pins and resize settings decide (`Item::liquid`).
ObjectBased,
/// The rule of the page's parent page (InDesign's "Controlled by Parent"; Off without one).
ControlledByParent,
}
impl LiquidRule {
@@ -368,6 +373,7 @@ mod tests {
liquid: Default::default(),
transition: None,
view_rotation: 0,
show_parent_overlay: false,
}
}
+3
View File
@@ -80,6 +80,9 @@ pub struct Selection {
/// Key object for Align.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<ItemId>,
/// The page selected with the Page tool (absolute index).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub page: Option<usize>,
}
impl Selection {
+190 -13
View File
@@ -1,8 +1,10 @@
//! Formatted text slices: copy a range of a story (with paragraph and character formats,
//! footnotes, cross-references, index markers and tables) and insert it into another story.
//! footnotes, endnotes, editorial notes, cross-references, index markers and tables) and insert
//! it into another story.
use std::sync::Arc;
use crate::endnotes::{ENDNOTE_REF, NOTE_MARK};
use crate::ids::StoryId;
use crate::index::INDEX_MARK;
use crate::notes::FOOTNOTE_REF;
@@ -33,6 +35,12 @@ impl Story {
let count = |m: char, upto: usize| self.text[..upto].matches(m).count();
let span = |m: char| count(m, a)..count(m, b);
out.notes = self.notes[span(FOOTNOTE_REF)].to_vec();
// Malformed input can have fewer payloads than markers. Keep the available entries;
// insertion creates empty entries for any missing payloads.
let endnotes = span(ENDNOTE_REF);
out.endnotes = self.endnotes.iter().skip(endnotes.start).take(endnotes.len()).cloned().collect();
let editorial = span(NOTE_MARK);
out.editorial = self.editorial.iter().skip(editorial.start).take(editorial.len()).cloned().collect();
out.anchors = self.anchors[span(ANCHOR_MARK)].to_vec();
out.xrefs = self.xrefs[span(XREF_MARK)].to_vec();
out.index_refs = self.index_refs[span(INDEX_MARK)].to_vec();
@@ -57,24 +65,31 @@ 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() };
}
}
// Payloads of footnotes, cross-references and index markers, in order.
// Marker payloads in text order, keeping newly allocated destination note ids.
let first = |m: char| self.text[..pos].matches(m).count();
let (nf, nx, ni, na) = (first(FOOTNOTE_REF), first(XREF_MARK), first(INDEX_MARK), first(ANCHOR_MARK));
let no = first(crate::anchored::OBJECT_MARK);
@@ -89,6 +104,22 @@ impl Story {
*slot = Arc::new(crate::notes::Footnote { id, text: n.text.clone() });
}
}
// Limit payload copies to inserted markers so excess source entries cannot overwrite
// notes already following the insertion point.
let ne = first(ENDNOTE_REF);
for (k, n) in src.endnotes.iter().take(src.text.matches(ENDNOTE_REF).count()).enumerate() {
if let Some(slot) = self.endnotes.get_mut(ne + k) {
let id = slot.id;
*slot = Arc::new(crate::notes::Footnote { id, text: n.text.clone() });
}
}
let nn = first(NOTE_MARK);
for (k, n) in src.editorial.iter().take(src.text.matches(NOTE_MARK).count()).enumerate() {
if let Some(slot) = self.editorial.get_mut(nn + k) {
let id = slot.id;
*slot = Arc::new(crate::endnotes::EditorialNote { id, ..(**n).clone() });
}
}
for (k, x) in src.xrefs.iter().enumerate() {
if let Some(slot) = self.xrefs.get_mut(nx + k) {
*slot = x.clone();
@@ -106,25 +137,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 +224,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, &copy);
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, &copy);
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());
@@ -220,6 +277,93 @@ mod tests {
assert!(a.extract(2..8).notes.is_empty());
}
fn append_note_pair(st: &mut Story, text: &str) {
st.insert_endnote(st.len(), text, ParaFormat::default());
st.insert(st.len(), &NOTE_MARK.to_string());
let note = Arc::make_mut(st.editorial.last_mut().unwrap());
note.text = text.into();
note.author = format!("{text} author");
}
#[test]
fn clipped_endnotes_and_editorial_keep_payloads_with_fresh_destination_ids() {
let mut source = Story::new(StoryId(1));
append_note_pair(&mut source, "outside before");
let start = source.len();
append_note_pair(&mut source, "Citation é\nsecond");
let end = source.len();
let citation = &mut Arc::make_mut(&mut source.endnotes[1]).text;
citation.format_chars(0..8, |f| f.over.size = Some(19.0));
citation.paras[1].style = "Citation".into();
append_note_pair(&mut source, "outside after");
source.check().unwrap();
let original = source.clone();
let slice = source.extract(start..end);
slice.check().unwrap();
assert_eq!(slice.text, format!("{ENDNOTE_REF}{NOTE_MARK}"));
assert_eq!(slice.endnotes, vec![source.endnotes[1].clone()]);
assert_eq!(slice.editorial, vec![source.editorial[1].clone()]);
let mut dest = Story::new(StoryId(2));
append_note_pair(&mut dest, "destination before");
let at = dest.len();
append_note_pair(&mut dest, "destination after");
let before = dest.clone();
assert_eq!(dest.insert_story(at, &slice), at + slice.len());
dest.check().unwrap();
assert_eq!(dest.text, format!("{ENDNOTE_REF}{NOTE_MARK}").repeat(3));
assert_eq!(dest.endnotes[1].text, slice.endnotes[0].text);
assert_eq!(dest.editorial[1].text, "Citation é\nsecond");
assert_eq!(dest.editorial[1].author, "Citation é\nsecond author");
assert_eq!((dest.endnotes[1].id, dest.editorial[1].id), (3, 3));
assert_eq!(dest.endnotes[0], before.endnotes[0]);
assert_eq!(dest.endnotes[2], before.endnotes[1]);
assert_eq!(dest.editorial[0], before.editorial[0]);
assert_eq!(dest.editorial[2], before.editorial[1]);
Arc::make_mut(&mut dest.endnotes[1]).text.insert(0, "changed");
Arc::make_mut(&mut dest.editorial[1]).author = "changed".into();
assert_eq!(source, original);
assert_eq!(slice.endnotes[0], original.endnotes[1]);
assert_eq!(slice.editorial[0], original.editorial[1]);
}
#[test]
fn extract_tolerates_missing_endnote_and_editorial_payloads() {
let mut source = Story::new(StoryId(1));
for text in ["before", "selected", "missing"] {
append_note_pair(&mut source, text);
}
let pair_len = ENDNOTE_REF.len_utf8() + NOTE_MARK.len_utf8();
for available in [0, 1, 2] {
let mut damaged = source.clone();
damaged.endnotes.truncate(available);
damaged.editorial.truncate(available);
let original = damaged.clone();
let slice = damaged.extract(pair_len..damaged.len());
assert_eq!(slice.text, format!("{ENDNOTE_REF}{NOTE_MARK}").repeat(2));
let expected = if available == 2 { 1 } else { 0 };
assert_eq!((slice.endnotes.len(), slice.editorial.len()), (expected, expected));
if expected == 1 {
assert_eq!(slice.endnotes[0], source.endnotes[1]);
assert_eq!(slice.editorial[0], source.editorial[1]);
}
let mut dest = Story::new(StoryId(2));
append_note_pair(&mut dest, "untouched");
let tail = dest.clone();
dest.insert_story(0, &slice);
dest.check().unwrap();
assert_eq!(dest.text, format!("{ENDNOTE_REF}{NOTE_MARK}").repeat(3));
assert_eq!((dest.endnotes.len(), dest.editorial.len()), (3, 3));
assert_eq!(dest.endnotes[2], tail.endnotes[0]);
assert_eq!(dest.editorial[2], tail.editorial[0]);
assert_eq!(dest.endnotes[0].text.text, if expected == 1 { "selected" } else { "" });
assert_eq!(dest.editorial[0].text, if expected == 1 { "selected" } else { "" });
assert!(dest.endnotes[1].text.is_empty());
assert!(dest.editorial[1].text.is_empty());
assert_eq!(damaged, original);
}
}
#[test]
fn append_tables_preserves_sparse_and_wrapping_ids() {
for existing in [vec![1, 3, u64::MAX], vec![1, 3, u64::MAX - 1], vec![1, 3, 8]] {
@@ -250,6 +394,39 @@ mod tests {
}
}
#[test]
fn excess_note_payloads_do_not_overwrite_destination_neighbors() {
let mut payload = Story::new(StoryId(1));
append_note_pair(&mut payload, "copied");
for has_markers in [false, true] {
let mut src = if has_markers { payload.clone() } else { Story::with_text(StoryId(1), "plain", ParaFormat::default()) };
src.endnotes.push(payload.endnotes[0].clone());
src.editorial.push(payload.editorial[0].clone());
let original = src.clone();
let mut dest = Story::new(StoryId(2));
append_note_pair(&mut dest, "before");
let at = dest.len();
append_note_pair(&mut dest, "after");
let before = dest.clone();
assert_eq!(dest.insert_story(at, &src), at + src.len());
dest.check().unwrap();
let pair = format!("{ENDNOTE_REF}{NOTE_MARK}");
assert_eq!(dest.text, format!("{pair}{}{pair}", src.text));
let tail = if has_markers { 2 } else { 1 };
assert_eq!((dest.endnotes.len(), dest.editorial.len()), (tail + 1, tail + 1));
assert_eq!(dest.endnotes[0], before.endnotes[0]);
assert_eq!(dest.endnotes[tail], before.endnotes[1]);
assert_eq!(dest.editorial[0], before.editorial[0]);
assert_eq!(dest.editorial[tail], before.editorial[1]);
if has_markers {
assert_eq!(dest.endnotes[1].text, payload.endnotes[0].text);
assert_eq!((dest.editorial[1].text.as_str(), dest.editorial[1].author.as_str()), ("copied", "copied author"));
assert_eq!((dest.endnotes[1].id, dest.editorial[1].id), (3, 3));
}
assert_eq!(src, original);
}
}
#[test]
fn append_story_with_maximal_ids_does_not_overflow() {
let mut a = Story::with_text(StoryId(1), "Alpha", ParaFormat::default());
+49 -8
View File
@@ -155,6 +155,40 @@ pub struct Story {
/// Place and Link: the parent story this one copies, and the parent's revision when copied.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub link: Option<(StoryId, u64)>,
/// File › Place with Create Links: the text file the story was placed from (Links panel).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file_link: Option<TextFileLink>,
}
/// A linked text file: where a placed story came from, what the file held, and the Word Import
/// Options that brought it in, so Update Link can import it again the same way.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextFileLink {
pub path: String,
/// The file's size and FNV-1a hash (hex) when it was placed or last updated.
pub size: u64,
pub hash: String,
/// Its modification time then (seconds since 1970; 0: unknown), as Data Merge keeps for its
/// source: while size and time are unchanged, the Links panel doesn't read the file.
#[serde(default, skip_serializing_if = "is_zero_i64")]
pub mtime: i64,
/// Imported style name → document style (`styleMap`), including the names Auto Rename chose.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub style_map: BTreeMap<String, String>,
/// `styleConflicts`: useExisting, redefine or autoRename.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub style_conflicts: String,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub remove_styles: bool,
/// Hash of the story's text and formatting right after the import: a different one means it
/// was edited in the layout (an update replaces those edits).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub story_hash: String,
}
fn is_zero_i64(v: &i64) -> bool {
*v == 0
}
impl Story {
@@ -177,6 +211,7 @@ impl Story {
objects: Vec::new(),
editorial: Vec::new(),
link: None,
file_link: None,
}
}
@@ -255,7 +290,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 +368,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
View File
@@ -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");
+1
View File
@@ -19,6 +19,7 @@ designcraft-compose = { workspace = true }
designcraft-render = { workspace = true }
designcraft-format = { workspace = true }
designcraft-epub = { workspace = true }
designcraft-pptx = { workspace = true }
designcraft-textimport = { workspace = true }
designcraft-pdf = { workspace = true }
designcraft-tools = { workspace = true }
-5
View File
@@ -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.
+204 -2
View File
@@ -1,11 +1,13 @@
//! Anchor-point editing (the Pen tool group): Add Anchor Point, Delete Anchor Point and Convert
//! Direction Point. Positions are in spread coordinates; anchors are `(subpath, index)`.
//! Direction Point, and the Pathfinder panel's Join Path and Convert Point. Positions are in spread
//! coordinates; anchors are `(subpath, index)`.
use designcraft_doc::{Document, ItemId, Shape};
use designcraft_geom::{Affine, Anchor, AnchorKind, Point};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, point_param};
use super::object::path_editable;
use super::{CommandSpec, bad, cmd, has_doc, has_selection, point_param};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
@@ -55,9 +57,147 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
|s, p| erase_or_smooth(s, p, true)
),
cmd!(
"path.join",
"Join",
["Object", "Paths"],
None,
"{ids?} — one open path: closes it; two open paths: joins them where their ends are nearest (coincident ends become one point) into the first, and removes the second → {id}",
has_selection,
join_paths
),
cmd!(
"path.convertPoints",
"Convert Point",
[],
None,
"{kind: plain|corner|smooth|symmetrical, ids?} — the Direct Selection tool's selected points, or every point of the selected paths: plain (no direction handles), corner (independent handles), smooth (collinear handles), symmetrical (collinear, equal length) → {converted}",
has_selection,
convert_points
),
]
}
/// Join Path: close one open path, or join two open paths at their nearest ends.
fn join_paths(s: &mut Session, p: &Value) -> Result<Value> {
let ids = super::targets(s, p)?;
s.edit(|d, sel| {
let ids: Vec<ItemId> =
ids.iter().copied().filter(|id| d.item(*id).is_some_and(|it| !it.locked && !it.is_group() && it.text_frame().is_none())).collect();
let open = |d: &Document, id: ItemId| d.item(id).and_then(|it| it.path.subpaths.iter().position(|sp| !sp.closed && sp.anchors.len() > 1));
match ids.as_slice() {
[id] => {
let it = d.item_mut(*id).ok_or(designcraft_doc::DocError::NoItem(*id))?;
let mut closed = 0;
for sp in it.path.subpaths.iter_mut().filter(|sp| !sp.closed && sp.anchors.len() > 1) {
sp.closed = true;
closed += 1;
}
if closed == 0 {
return Err(bad("path.join", "the path is already closed"));
}
as_path(&mut it.shape);
Ok(json!({"id": id.0, "closed": closed}))
}
[a, b] => {
let (Some(sa), Some(sb)) = (open(d, *a), open(d, *b)) else {
return Err(bad("path.join", "select two open paths"));
};
// b's path in a's space.
let inv_a = to_inner(d, *a).ok_or(designcraft_doc::DocError::NoItem(*a))?;
let xf_b = to_inner(d, *b).ok_or(designcraft_doc::DocError::NoItem(*b))?.inverse();
let m = inv_a * xf_b;
let mut pb = d.item(*b).and_then(|it| it.path.subpaths.get(sb).cloned()).ok_or(designcraft_doc::DocError::NoItem(*b))?;
for an in &mut pb.anchors {
*an = Anchor { p: m * an.p, h_in: m * an.h_in, h_out: m * an.h_out, kind: an.kind };
}
let ita = d.item_mut(*a).ok_or(designcraft_doc::DocError::NoItem(*a))?;
let pa = ita.path.subpaths.get_mut(sa).ok_or_else(|| bad("path.join", "no such subpath"))?;
// The pair of ends nearest each other; then a's end meets b's start.
let ends = |sp: &designcraft_geom::SubPath| (sp.anchors.first().map(|x| x.p), sp.anchors.last().map(|x| x.p));
let ((Some(a0), Some(a1)), (Some(b0), Some(b1))) = (ends(pa), ends(&pb)) else {
return Err(bad("path.join", "select two open paths"));
};
let pairs = [(a1, b0, false, false), (a1, b1, false, true), (a0, b0, true, false), (a0, b1, true, true)];
let Some(&(_, _, rev_a, rev_b)) = pairs.iter().min_by(|x, y| (x.0 - x.1).hypot().total_cmp(&(y.0 - y.1).hypot())) else {
return Err(bad("path.join", "select two open paths"));
};
if rev_a {
pa.reverse();
}
if rev_b {
pb.reverse();
}
let mut rest = pb.anchors.into_iter();
if let Some(first) = rest.next() {
match pa.anchors.last_mut() {
Some(last) if (last.p - first.p).hypot() < 0.01 => *last = Anchor::with_handles(last.p, last.h_in, first.h_out),
_ => pa.anchors.push(first),
}
}
pa.anchors.extend(rest);
as_path(&mut ita.shape);
d.remove_item(*b)?;
*sel = designcraft_doc::Selection::items(vec![*a]);
Ok(json!({"id": a.0}))
}
_ => Err(bad("path.join", "select one or two open paths")),
}
})
}
/// Convert Point: the selected points (or every point of the selected paths) to a point type.
fn convert_points(s: &mut Session, p: &Value) -> Result<Value> {
let kind = super::str_param(p, "kind").unwrap_or("plain").to_string();
if !matches!(kind.as_str(), "plain" | "corner" | "smooth" | "symmetrical") {
return Err(bad("path.convertPoints", format!("unknown kind `{kind}`")));
}
let mut points: Vec<(ItemId, usize, usize)> = if p.get("ids").is_none() { s.doc()?.selection.anchors.clone() } else { Vec::new() };
if points.is_empty() {
for id in super::targets(s, p)? {
if let Some(it) = s.doc()?.doc.item(id) {
points.extend(it.path.anchors().map(|(si, ai, _)| (id, si, ai)));
}
}
}
s.edit(|d, _| {
let mut converted = 0;
for (id, si, ai) in &points {
let Some(it) = d.item_mut(*id) else { continue };
if it.locked || it.is_group() {
continue;
}
let Some(sp) = it.path.subpaths.get_mut(*si) else { continue };
let Some(a) = sp.anchors.get(*ai).copied() else { continue };
let auto = smooth_auto(&sp.anchors, *ai, sp.closed);
// The handles: the point's own, or ones worked out from its neighbours.
let (h_in, h_out) = if a.has_in() || a.has_out() { (a.h_in, a.h_out) } else { (auto.h_in, auto.h_out) };
let new = match kind.as_str() {
"plain" => Anchor::corner(a.p),
"corner" => Anchor { p: a.p, h_in, h_out, kind: AnchorKind::Corner },
_ => {
let dir = if (h_out - h_in).hypot() > 1e-9 { h_out - h_in } else { auto.h_out - auto.h_in };
let len = dir.hypot();
if len < 1e-9 {
a
} else {
let u = dir / len;
let (li, lo) = ((a.p - h_in).hypot(), (h_out - a.p).hypot());
let (li, lo) = if kind == "symmetrical" { ((li + lo) / 2.0, (li + lo) / 2.0) } else { (li, lo) };
Anchor { p: a.p, h_in: a.p - u * li, h_out: a.p + u * lo, kind: AnchorKind::Smooth }
}
}
};
if let Some(slot) = sp.anchors.get_mut(*ai) {
*slot = new;
converted += 1;
}
as_path(&mut it.shape);
}
Ok(json!({"converted": converted}))
})
}
/// Erase / Smooth: the stretch of one subpath touched by the drag.
fn erase_or_smooth(s: &mut Session, p: &Value, smooth: bool) -> Result<Value> {
let cmd = if smooth { "path.smooth" } else { "path.erase" };
@@ -69,6 +209,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 +276,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 +296,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 +324,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"))?;
@@ -253,6 +397,64 @@ pub fn anchor_near(d: &Document, id: ItemId, at: Point, tol: f64) -> Option<(usi
mod tests {
use super::*;
fn line(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> u64 {
s.execute("line.create", &json!({"a": a, "b": b})).unwrap()["id"].as_u64().unwrap()
}
/// Join Path closes one open path, and joins two open paths at their nearest ends.
#[test]
fn join_path_closes_one_path_and_joins_two() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = line(&mut s, [100.0, 100.0], [200.0, 100.0]);
// b runs back towards a: its end meets a's end at (200, 100).
let b = line(&mut s, [200.0, 200.0], [200.0, 100.0]);
let r = s.execute("path.join", &json!({"ids": [a, b]})).unwrap();
assert_eq!(r["id"], a);
let d = s.doc().unwrap().doc.clone();
assert!(d.item(ItemId(b)).is_none());
let sp = &d.item(ItemId(a)).unwrap().path.subpaths[0];
assert_eq!(sp.anchors.len(), 3, "the coincident ends become one point");
assert!(!sp.closed);
let bb = d.item(ItemId(a)).unwrap().bounds();
assert!((bb.width() - 100.0).abs() < 0.5 && (bb.height() - 100.0).abs() < 0.5, "{bb:?}");
// Joining it again closes it.
s.execute("path.join", &json!({"ids": [a]})).unwrap();
assert!(s.doc().unwrap().doc.item(ItemId(a)).unwrap().path.subpaths[0].closed);
assert!(s.execute("path.join", &json!({"ids": [a]})).is_err(), "nothing left to close");
// Ends apart are joined with a straight segment.
let c = line(&mut s, [300.0, 300.0], [400.0, 300.0]);
let e = line(&mut s, [300.0, 400.0], [400.0, 400.0]);
s.execute("path.join", &json!({"ids": [c, e]})).unwrap();
assert_eq!(s.doc().unwrap().doc.item(ItemId(c)).unwrap().path.subpaths[0].anchors.len(), 4);
}
/// Convert Point turns the selected points (or a whole path's) plain, corner, smooth or symmetrical.
#[test]
fn convert_point_kinds() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = s.execute("frame.create", &json!({"rect": [100, 100, 300, 200]})).unwrap()["id"].as_u64().unwrap();
let anchor = |s: &Session, i: usize| s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.subpaths[0].anchors[i];
s.execute("path.convertPoints", &json!({"kind": "smooth", "ids": [id]})).unwrap();
let a = anchor(&s, 0);
assert_eq!(a.kind, AnchorKind::Smooth);
assert!(a.has_in() && a.has_out());
let (di, dout) = (a.p - a.h_in, a.h_out - a.p);
assert!((di.x * dout.y - di.y * dout.x).abs() < 1e-6, "collinear handles");
s.execute("path.convertPoints", &json!({"kind": "symmetrical", "ids": [id]})).unwrap();
let a = anchor(&s, 1);
assert!(((a.p - a.h_in).hypot() - (a.h_out - a.p).hypot()).abs() < 1e-6, "equal handles");
s.execute("path.convertPoints", &json!({"kind": "plain", "ids": [id]})).unwrap();
let a = anchor(&s, 2);
assert!(!a.has_in() && !a.has_out() && a.kind == AnchorKind::Corner);
s.execute("path.convertPoints", &json!({"kind": "corner", "ids": [id]})).unwrap();
let a = anchor(&s, 2);
assert!(a.has_in() && a.has_out() && a.kind == AnchorKind::Corner, "a corner point gets independent handles");
assert!(s.execute("path.convertPoints", &json!({"kind": "nope", "ids": [id]})).is_err());
assert_eq!(s.doc().unwrap().doc.item(ItemId(id)).unwrap().shape, Shape::Path);
}
#[test]
fn erase_and_smooth_along_a_drag() {
let mut s = Session::new();
+10 -16
View File
@@ -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})),
+2 -2
View File
@@ -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();
+2 -1
View File
@@ -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")]
+2 -2
View File
@@ -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) {
+26 -10
View File
@@ -24,6 +24,13 @@ use parse::{
tile,
};
#[cfg(not(target_arch = "wasm32"))]
pub(crate) use parse::fingerprint_from;
pub(crate) fn fingerprint_from_path(path: &str) -> Option<designcraft_doc::Fingerprint> {
fingerprint_of(Path::new(path))
}
const PARENT_WARNING: &str = "A parent-page placeholder was left unfilled.";
pub fn specs() -> Vec<CommandSpec> {
@@ -619,7 +626,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 +637,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;
@@ -767,17 +774,26 @@ fn read_path(path: &str) -> std::result::Result<Vec<u8>, String> {
read_path_capped(path, MAX_DATA_FILE_BYTES)
}
fn read_path_capped(path: &str, cap: u64) -> std::result::Result<Vec<u8>, String> {
match std::fs::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")),
/// The file at `path`, refused when it is larger than `cap` bytes. The read itself stops past
/// `cap`, so a device or a file that grows while it is read can't exhaust memory.
pub(crate) fn read_path_capped(path: &str, cap: u64) -> std::result::Result<Vec<u8>, String> {
let too_big = || format!("{path}: the file is larger than {} MiB", cap / (1024 * 1024));
match crate::io::metadata(path) {
Ok(m) if m.len() > cap => return Err(too_big()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(format!("{path}: the file is missing")),
_ => {}
}
match std::fs::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}")),
// The roots' own message when they refuse the path.
crate::file_access::check_read(path)?;
let b = match crate::io::read_capped(path, cap) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(format!("{path}: the file is missing")),
Err(e) => return Err(format!("{path}: {e}")),
};
if b.len() as u64 > cap {
return Err(too_big());
}
Ok(b)
}
fn parent_dir(path: &str) -> Option<PathBuf> {
+6 -2
View File
@@ -330,14 +330,18 @@ 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()?;
Some(fingerprint_from(&crate::io::metadata(path).ok()?))
}
/// Size and modification time (whole seconds) from a file's metadata.
pub fn fingerprint_from(meta: &crate::io::Metadata) -> Fingerprint {
let mtime = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
.unwrap_or(0);
Some(Fingerprint { size: meta.len(), mtime })
Fingerprint { size: meta.len(), mtime }
}
pub fn status_of(path: &Path, previous: Option<Fingerprint>) -> SourceStatus {
+9
View File
@@ -1086,6 +1086,15 @@ fn oversized_data_file_is_refused_before_reading() {
assert_eq!(read_path_capped(&path, 4096).unwrap().len(), 2048);
}
/// A device reports no length; the read still stops at the cap (an EPUB `coverImage` of
/// `/dev/zero` must not read until memory runs out).
#[cfg(unix)]
#[test]
fn endless_file_is_refused_at_the_cap() {
let msg = read_path_capped("/dev/zero", 1024).unwrap_err();
assert!(msg.contains("larger than"), "{msg}");
}
/// #198: with facing pages on, one record per page must not stack the copies at identical
/// coordinates on one spread (leaving a later page empty).
#[test]
+90 -6
View File
@@ -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);
}
}
+315 -21
View File
@@ -12,7 +12,7 @@ pub fn specs() -> Vec<CommandSpec> {
"{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), flatten?: high|medium|low|ppi (Transparency Flattener: spreads with transparency are rasterised), spreads?: bool, fullScreen?: bool, bookmarksPanel?: bool, pageLayout?: single|continuous|twoUp|twoUpCover|twoUpContinuous, view?: fitPage|fitWidth|actual, advanceSeconds?: number (interactive PDF), bleed?: bool (document bleed), marks?: bool | {crop?, bleed?, pageInfo?, weight?, offset?}, standard?: \"none\"|\"x4\"|\"a2b\", compressImages?: bool, tagged?: bool (structure tree: stories as paragraphs, figures with alt text), media?: bool (interactive: embed placed video and sound), title?, author?} → {path, bytes, pages, warnings} (no path: {base64, …})",
has_doc, export_pdf),
cmd!(noundo "file.exportEpub", "Export EPUB (Reflowable)…", ["File"], None,
"{path?, title?, author?, language?: \"en\", cover?: bool (the first page as the cover image), fixedLayout?: bool (pre-paginated: each page as an image with its text)} → {path, bytes} (no path: {base64, bytes})",
"{path?, title?, author?, language?: \"en\", identifier?: str (dc:identifier; a stable urn:uuid by default), coverImage?: path (a PNG or JPEG as the cover), tocTitle?: str (the table of contents' title; from the language by default), cover?: bool (the first page as the cover image), embedFonts?: bool (package the stylesheet's fonts whose licence allows it, whole files), fixedLayout?: bool (pre-paginated: each page as an image with its text)} → {path, bytes, warnings} (no path: {base64, bytes, warnings}); warnings name images without alt text, exported as decorative, and fonts that could not be embedded",
has_doc, export_epub),
cmd!(noundo "file.exportFixedEpub", "Export EPUB (Fixed Layout)…", ["File"], None,
"{path?, title?, author?, language?} — pre-paginated EPUB → {path, bytes} (no path: {base64, bytes})",
@@ -25,6 +25,9 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "file.exportHtml", "Export HTML…", ["File"], None,
"{path?, title?, language?} — one self-contained page (styles inline, images embedded), stories and graphics in reading order → {path, bytes} (no path: {text, bytes})",
has_doc, export_html),
cmd!(noundo "file.exportPptx", "Export PowerPoint…", ["File"], None,
"{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), lineBreaks?: keep|reflow (keep: DesignCraft's lines, spacing and leading; reflow: one box per frame, PowerPoint sets the lines), rasterize?: bool (default true: objects PowerPoint can't show — placed PDF/EPS, type on a path, some effects — go in as 300 ppi images), embedFonts?: bool (default true: the TrueType fonts whose licences allow it), title?, author?} — every page as a slide of editable shapes, pictures, text boxes and tables → {path, bytes, slides, warnings} (no path: {base64, …})",
has_doc, export_pptx),
cmd!(noundo "file.exportText", "Export Text…", ["File"], None,
"{path?, format?: \"txt\"|\"rtf\"|\"tagged\" (Tagged Text; default from the path, else txt), story?, frame?} — the story being edited or of the selected frame → {path, bytes} (no path: {text, bytes})",
has_story_target, export_text),
@@ -44,7 +47,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})),
@@ -53,19 +56,59 @@ fn print_booklet(s: &mut Session, p: &Value) -> Result<Value> {
fn export_html(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let opts =
designcraft_epub::HtmlOptions { title: str_param(p, "title").map(str::to_string), language: str_param(p, "language").map(str::to_string) };
let opts = designcraft_epub::HtmlOptions {
title: str_param(p, "title").map(str::to_string),
language: str_param(p, "language").map(str::to_string),
generic_families: generic_families(&st.doc),
};
let text = designcraft_epub::export_html(&st.doc, &opts);
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})),
}
}
fn export_pptx(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "file.exportPptx";
let st = s.doc()?;
let line_breaks = match str_param(p, "lineBreaks") {
None | Some("keep") => designcraft_pptx::LineBreaks::Keep,
Some("reflow") => designcraft_pptx::LineBreaks::Reflow,
Some(x) => return Err(bad(ID, format!("unknown lineBreaks `{x}` (keep, reflow)"))),
};
let opts = designcraft_pptx::PptxOptions {
pages: pages_param(ID, p, st.doc.page_count())?,
line_breaks,
title: str_param(p, "title").map(str::to_string),
author: str_param(p, "author").map(str::to_string),
created: None,
embed_fonts: p.get("embedFonts").and_then(Value::as_bool).unwrap_or(true),
};
// Object Export Options › Rasterize, and what PowerPoint has no objects for (a threaded frame
// stays text: a picture of it would drop the rest of its story).
let auto = p.get("rasterize").and_then(Value::as_bool).unwrap_or(true);
let (raster, rasterized) = rasterize_where(&st.doc, &s.cache, 300.0, |it| {
!threaded(&st.doc, it) && (it.export_options.rasterize || (auto && designcraft_pptx::needs_raster(&st.doc, it)))
});
let doc: &designcraft_doc::Document = raster.as_ref().unwrap_or(&st.doc);
let mut r = designcraft_pptx::export_pptx(doc, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
if rasterized > 0 {
r.warnings.push(format!("{rasterized} object(s) went in as images"));
}
match str_param(p, "path") {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
crate::io::write_file(path, &r.bytes).map_err(EngineError::Other)?;
Ok(json!({"path": path, "bytes": r.bytes.len(), "slides": r.slides, "warnings": r.warnings}))
}
None => Ok(json!({"base64": super::file::base64_encode(&r.bytes), "bytes": r.bytes.len(), "slides": r.slides, "warnings": r.warnings})),
}
}
fn has_story_target(s: &Session) -> std::result::Result<(), String> {
super::text::story_of(s, &Value::Null).map(|_| ()).ok_or_else(|| "edit text or select a text frame".into())
}
@@ -91,7 +134,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})),
@@ -120,6 +163,49 @@ fn page_text(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache,
out
}
/// The CSS generic family of each font family the EPUB/HTML stylesheet names, from the fonts'
/// classification (PANOSE, OS/2 class, name), for the `font-family` fallbacks.
fn generic_families(d: &designcraft_doc::Document) -> std::collections::BTreeMap<String, String> {
use designcraft_fonts::FontClass;
let db = designcraft_fonts::FontDb::global().scoped(d.font_scope);
let mut out = std::collections::BTreeMap::new();
for (family, _) in designcraft_epub::stylesheet_fonts(d) {
if out.contains_key(&family) {
continue;
}
let generic = match db.family_kind(&family).map(|(k, _)| k.class) {
Some(FontClass::Serif | FontClass::SlabSerif) => "serif",
Some(FontClass::SansSerif) => "sans-serif",
Some(FontClass::Monospaced) => "monospace",
Some(FontClass::Script) => "cursive",
Some(FontClass::Decorative) => "fantasy",
Some(FontClass::Symbol | FontClass::Other) | None => continue,
};
out.insert(family, generic.to_string());
}
out
}
/// The faces the EPUB stylesheet names, to package with it; `warnings` gets the ones left out
/// (not available, a licence that forbids embedding, or part of a font collection).
fn embedded_fonts(d: &designcraft_doc::Document, warnings: &mut Vec<String>) -> Vec<designcraft_epub::EmbeddedFont> {
let db = designcraft_fonts::FontDb::global().scoped(d.font_scope);
let mut out = Vec::new();
for (family, style) in designcraft_epub::stylesheet_fonts(d) {
let face = db.face(&family, &style);
if !face.family.eq_ignore_ascii_case(&family) {
warnings.push(format!("font `{family} {style}` is not available; not embedded"));
} else if face.restricted_licence() || face.bitmap_only() {
warnings.push(format!("font `{family} {style}`: its licence doesn't allow embedding; not embedded"));
} else if face.data().starts_with(b"ttcf") {
warnings.push(format!("font `{family} {style}` is part of a font collection; not embedded"));
} else {
out.push(designcraft_epub::EmbeddedFont { family, style: face.style.clone(), data: face.data().to_vec() });
}
}
out
}
fn export_epub(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
// Object Export Options › Rasterize: those objects go in as images.
@@ -130,13 +216,35 @@ fn export_epub(s: &mut Session, p: &Value) -> Result<Value> {
let mut rr = designcraft_render::Renderer::new();
rr.threads = designcraft_render::default_threads();
let ropts = designcraft_render::RenderOptions { printing_only: true, ..Default::default() };
let cover = (want_cover && !fixed).then(|| rr.render_page(&st.doc, &s.cache, 0, 2.0, false, &ropts).map(|img| img.to_png())).flatten();
let mut warnings: Vec<String> = Vec::new();
// A cover image file (PNG or JPEG), else the first page rendered when `cover` asks for it.
let cover = match p.get("coverImage").and_then(Value::as_str).filter(|_| !fixed) {
Some(path) => Some(super::datamerge::read_path_capped(path, super::datamerge::MAX_IMAGE_FILE_BYTES).map_err(crate::EngineError::Other)?),
None => (want_cover && !fixed).then(|| rr.render_page(&st.doc, &s.cache, 0, 2.0, false, &ropts).map(|img| img.to_png())).flatten(),
};
if let Some(c) = &cover
&& !(c.starts_with(b"\x89PNG") || c.starts_with(&[0xFF, 0xD8, 0xFF]))
{
return Err(crate::EngineError::Other("the cover image must be a PNG or JPEG file".into()));
}
let opts = designcraft_epub::EpubOptions {
title: p.get("title").and_then(Value::as_str).map(str::to_string),
author: p.get("author").and_then(Value::as_str).map(str::to_string),
language: p.get("language").and_then(Value::as_str).unwrap_or("en").to_string(),
identifier: None,
identifier: p.get("identifier").and_then(Value::as_str).map(str::to_string),
cover,
toc_title: p.get("tocTitle").and_then(Value::as_str).map(str::to_string),
generic_families: generic_families(&st.doc),
fonts: if p.get("embedFonts").and_then(Value::as_bool).unwrap_or(false) && !fixed {
embedded_fonts(&st.doc, &mut warnings)
} else {
Vec::new()
},
// The export time; on the web the document's own save time stands in.
#[cfg(not(target_arch = "wasm32"))]
modified: std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok().and_then(|d| i64::try_from(d.as_secs()).ok()),
#[cfg(target_arch = "wasm32")]
modified: None,
};
if fixed {
let d = &st.doc;
@@ -157,36 +265,46 @@ fn export_epub(s: &mut Session, p: &Value) -> Result<Value> {
};
}
let bytes = designcraft_epub::export_epub(epub_doc, &opts).map_err(|e| crate::EngineError::Other(e.to_string()))?;
warnings.extend(
designcraft_epub::images_without_alt(epub_doc)
.into_iter()
.map(|name| format!("{name}: no alt text (Object Export Options); exported as decorative (alt=\"\")")),
);
match p.get("path").and_then(Value::as_str) {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
crate::atomic::write_file(path, &bytes)?;
Ok(serde_json::json!({"path": path, "bytes": bytes.len()}))
crate::io::write_file(path, &bytes).map_err(crate::EngineError::Other)?;
Ok(serde_json::json!({"path": path, "bytes": bytes.len(), "warnings": warnings}))
}
None => Ok(serde_json::json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len()})),
None => Ok(serde_json::json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len(), "warnings": warnings})),
}
}
pub(crate) fn options(p: &Value, page_count: usize) -> Result<PdfOptions> {
const ID: &str = "file.exportPdf";
let pages = match p.get("pages") {
/// The `pages` parameter: a range string ("1-3,5"), a list or a number of 1-based positions.
fn pages_param(id: &str, p: &Value, page_count: usize) -> Result<Option<Vec<usize>>> {
Ok(match p.get("pages") {
None | Some(Value::Null) => None,
Some(Value::String(s)) => Some(designcraft_pdf::parse_page_range(s, page_count).map_err(|e| bad(ID, e.to_string()))?),
Some(Value::String(s)) => Some(designcraft_pdf::parse_page_range(s, page_count).map_err(|e| bad(id, e.to_string()))?),
Some(Value::Array(a)) => {
let mut v = Vec::new();
for x in a {
let n = x.as_u64().filter(|n| *n >= 1 && (*n as usize) <= page_count).ok_or_else(|| bad(ID, format!("bad page {x}")))?;
let n = x.as_u64().filter(|n| *n >= 1 && (*n as usize) <= page_count).ok_or_else(|| bad(id, format!("bad page {x}")))?;
v.push(n as usize - 1);
}
Some(v)
}
Some(Value::Number(n)) => {
let n = n.as_u64().filter(|n| *n >= 1 && (*n as usize) <= page_count).ok_or_else(|| bad(ID, format!("bad page {n}")))?;
let n = n.as_u64().filter(|n| *n >= 1 && (*n as usize) <= page_count).ok_or_else(|| bad(id, format!("bad page {n}")))?;
Some(vec![n as usize - 1])
}
Some(v) => return Err(bad(ID, format!("bad `pages`: {v}"))),
};
Some(v) => return Err(bad(id, format!("bad `pages`: {v}"))),
})
}
pub(crate) fn options(p: &Value, page_count: usize) -> Result<PdfOptions> {
const ID: &str = "file.exportPdf";
let pages = pages_param(ID, p, page_count)?;
let spreads = p.get("spreads").and_then(Value::as_bool).unwrap_or(false);
let bleed = p.get("bleed").and_then(Value::as_bool).unwrap_or(false);
let marks = match p.get("marks") {
@@ -598,7 +716,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<()> {
@@ -659,6 +777,7 @@ mod text_tests {
s.execute("file.new", &json!({"pages": 2})).unwrap();
s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Page one words", "caret": false})).unwrap();
let r = s.execute("file.exportEpub", &json!({"cover": true})).unwrap();
assert_eq!(r["warnings"], json!([]), "no images, nothing to report");
let mut z = unzip(r["base64"].as_str().unwrap());
let opf = read(&mut z, "OEBPS/content.opf");
assert!(opf.contains("cover-image") && opf.contains("<itemref idref=\"cover\"/>"));
@@ -672,6 +791,88 @@ mod text_tests {
assert!(p1.contains("width=612, height=792") && p1.contains("Page one words"), "{p1}");
}
/// #775: the fallback follows the font's class, and embedFonts packages the stylesheet's fonts
/// (and reports those it can't).
#[test]
fn epub_font_fallbacks_and_embedding() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1})).unwrap();
s.execute("style.paragraph.create", &json!({"name": "Sans", "chars": {"fontFamily": "Source Sans 3", "fontStyle": "Regular"}})).unwrap();
s.execute("style.paragraph.create", &json!({"name": "Gone", "chars": {"fontFamily": "No Such Font Family", "fontStyle": "Regular"}}))
.unwrap();
s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Words", "caret": false})).unwrap();
let r = s.execute("file.exportEpub", &json!({})).unwrap();
let mut z = unzip(r["base64"].as_str().unwrap());
let css = read(&mut z, "OEBPS/style.css");
assert!(css.contains("font-family: \"Source Sans 3\", sans-serif;"), "{css}");
assert!(!css.contains("@font-face") && r["warnings"] == json!([]), "nothing embedded unless asked");
let r = s.execute("file.exportEpub", &json!({"embedFonts": true})).unwrap();
let mut z = unzip(r["base64"].as_str().unwrap());
let css = read(&mut z, "OEBPS/style.css");
assert!(css.contains("@font-face { font-family: \"Source Sans 3\"; src: url(\"fonts/"), "{css}");
let opf = read(&mut z, "OEBPS/content.opf");
let font = opf.split("href=\"fonts/").nth(1).and_then(|r| r.split('"').next()).map(|n| format!("OEBPS/fonts/{n}"));
assert!(font.as_ref().is_some_and(|f| z.by_name(f).is_ok_and(|e| e.size() > 1000)), "{opf}");
let warnings = r["warnings"].as_array().unwrap();
assert!(warnings.iter().any(|w| w.as_str().unwrap().contains("No Such Font Family")), "{warnings:?}");
let html = s.execute("file.exportHtml", &json!({})).unwrap()["text"].as_str().unwrap().to_string();
assert!(html.contains("\"Source Sans 3\", sans-serif;"));
}
/// A font whose licence allows embedding bitmaps only (fsType bit 9) is not packaged whole,
/// and a Semibold style asks for the Semibold face's weight, not Bold's.
#[test]
fn epub_embedding_follows_bitmap_only_licences_and_weights() {
use designcraft_fonts::testing::{font_with, with_fs_type};
const BITMAP: &str = "DocFont Epub Bitmap Only";
for fs_type in [0x0200, 0x0208] {
designcraft_fonts::FontDb::global()
.add_font(with_fs_type(font_with(&format!("{BITMAP} {fs_type:04x}"), &['W']).unwrap(), fs_type).unwrap());
}
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1})).unwrap();
for fs_type in [0x0200, 0x0208] {
let family = format!("{BITMAP} {fs_type:04x}");
s.execute("style.paragraph.create", &json!({"name": family, "chars": {"fontFamily": family, "fontStyle": "Regular"}})).unwrap();
}
s.execute("style.paragraph.create", &json!({"name": "Semi", "chars": {"fontFamily": "Source Sans 3", "fontStyle": "Semibold"}})).unwrap();
s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Words", "caret": false})).unwrap();
let r = s.execute("file.exportEpub", &json!({"embedFonts": true})).unwrap();
let mut z = unzip(r["base64"].as_str().unwrap());
let css = read(&mut z, "OEBPS/style.css");
assert!(!css.contains(&format!("@font-face {{ font-family: \"{BITMAP}")), "{css}");
let warnings = r["warnings"].to_string();
for fs_type in [0x0200, 0x0208] {
assert!(warnings.contains(&format!("{BITMAP} {fs_type:04x} Regular`: its licence doesn't allow embedding")), "{warnings}");
}
let semi = css.lines().find(|l| l.contains(".semi {")).unwrap_or_default();
assert!(semi.contains("font-weight: 600;"), "{css}");
}
/// #771, #773: a cover image file and an identifier through the command; the package's
/// modified time is the export's.
#[test]
fn epub_cover_image_file_and_identifier() {
let dir = std::env::temp_dir().join(format!("dc-epub-cover-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let jpg = dir.join("cover.jpg");
std::fs::write(&jpg, [0xFF, 0xD8, 0xFF, 0xE0, 0, 16]).unwrap();
let bad = dir.join("cover.txt");
std::fs::write(&bad, b"not an image").unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1})).unwrap();
s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Words", "caret": false})).unwrap();
let r = s.execute("file.exportEpub", &json!({"coverImage": jpg.to_str().unwrap(), "identifier": "urn:isbn:9780000000002"})).unwrap();
let mut z = unzip(r["base64"].as_str().unwrap());
let opf = read(&mut z, "OEBPS/content.opf");
assert!(opf.contains("<dc:identifier id=\"bookid\">urn:isbn:9780000000002</dc:identifier>"), "{opf}");
assert!(opf.contains("href=\"images/cover.jpg\" media-type=\"image/jpeg\""), "{opf}");
assert!(!opf.contains("2026-01-01T00:00:00Z"), "the export time, not a fixed date: {opf}");
assert!(z.by_name("OEBPS/images/cover.jpg").is_ok());
assert!(s.execute("file.exportEpub", &json!({"coverImage": bad.to_str().unwrap()})).is_err());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn flattener_rasterises_spreads_with_transparency() {
let mut s = Session::new();
@@ -850,6 +1051,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)]
@@ -899,3 +1139,57 @@ mod print_group_tests {
assert!(t.contains("/CS/DeviceRGB") && designcraft_pdf::has_rgb_groups(t.as_bytes()));
}
}
#[cfg(test)]
mod pptx_tests {
use serde_json::json;
use crate::Session;
fn unzip(b64: &str) -> zip::ZipArchive<std::io::Cursor<Vec<u8>>> {
zip::ZipArchive::new(std::io::Cursor::new(super::super::file::base64_decode(b64))).unwrap()
}
fn read(z: &mut zip::ZipArchive<std::io::Cursor<Vec<u8>>>, name: &str) -> String {
let mut s = String::new();
std::io::Read::read_to_string(&mut z.by_name(name).unwrap(), &mut s).unwrap();
s
}
#[test]
fn sample_exports_editable_slides() {
let mut s = Session::new();
s.execute("file.newSample", &json!({})).unwrap();
let r = s.execute("file.exportPptx", &json!({})).unwrap();
assert_eq!(r["slides"], 4);
let mut z = unzip(r["base64"].as_str().unwrap());
// The swatch table is a PowerPoint table; the cover picture is embedded; text is text.
assert!(read(&mut z, "ppt/slides/slide4.xml").contains("<a:tbl>"));
assert!(read(&mut z, "ppt/slides/slide1.xml").contains("<p:pic>"));
let text: String = read(&mut z, "ppt/slides/slide2.xml").split("<a:t>").skip(1).filter_map(|t| t.split("</a:t>").next()).collect();
assert!(text.contains("Notes on the Grid"), "{text}");
assert!(read(&mut z, "ppt/presentation.xml").contains("<p:embeddedFontLst>"));
let r = s.execute("file.exportPptx", &json!({"pages": "2-3", "embedFonts": false, "lineBreaks": "reflow"})).unwrap();
assert_eq!(r["slides"], 2);
assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("fonts are not embedded")));
assert!(s.execute("file.exportPptx", &json!({"lineBreaks": "wrap"})).is_err());
assert!(s.execute("file.exportPptx", &json!({"pages": [9]})).is_err());
}
#[test]
fn rasterize_option_places_a_picture() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 200, 200], "shape": "ellipse"})).unwrap();
s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [r["id"]]})).unwrap();
let shapes = |s: &mut Session| {
let r = s.execute("file.exportPptx", &json!({})).unwrap();
read(&mut unzip(r["base64"].as_str().unwrap()), "ppt/slides/slide1.xml")
};
assert!(shapes(&mut s).contains("prst=\"ellipse\""));
s.execute("object.exportOptions", &json!({"ids": [r["id"]], "rasterize": true})).unwrap();
let x = shapes(&mut s);
assert!(x.contains("<p:pic>") && !x.contains("prst=\"ellipse\""), "{x}");
}
}
+120 -35
View File
@@ -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(&copy);
#[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?, pdfPage?: n (1-based, Image Import Options), pdfCrop?: crop|trim|bleed|art|media, layoutPage?: n (IDML / .designcraft: that page's objects as a group)} — 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` — {autoflow?: adds pages with threaded frames until the text fits, removeStyles?, styleMap?: {imported name: document style}, styleConflicts?: useExisting|redefine|autoRename}",
"{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?, 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)} — 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` — {autoflow?: adds pages with threaded frames until the text fits, removeStyles?, styleMap?: {imported name: document style}, styleConflicts?: useExisting|redefine|autoRename, link?: keep the file linked (Links panel; default: the linkTextFiles preference)}",
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);
@@ -264,11 +291,14 @@ fn file_save(s: &mut Session, p: &Value) -> Result<Value> {
}
fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
// The file's size and time before it is read (a linked text file remembers them).
let mut before = None;
let (bytes, name, link) = if let Some(b) = str_param(p, "base64") {
(base64_decode(b), str_param(p, "name").unwrap_or("image").to_string(), None)
} else if let Some(path) = str_param(p, "path") {
before = super::datamerge::fingerprint_from_path(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());
@@ -295,7 +325,7 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
}
// Text files (plain, Word, RTF) flow into frames.
if designcraft_textimport::is_text_file(&name) {
return super::place_text::place_text(s, p, &name, &bytes);
return super::place_text::place_text(s, p, &name, &bytes, link, before);
}
// Image Import Options: which page of a PDF (1-based).
let pdf_page = match p.get("pdfPage").and_then(Value::as_u64) {
@@ -340,6 +370,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 +470,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 +481,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 +530,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 +545,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 +573,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 +585,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 +663,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 +709,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 +936,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";
File diff suppressed because it is too large Load Diff
+581
View File
@@ -0,0 +1,581 @@
//! Flex Layout: Object › Flex Layout and the Flex Layout panel. A container is a frame whose
//! nested objects [`designcraft_doc::flex::layout`] places; [`sync`] lays every container out
//! after each edit, so moving, resizing or editing an object inside one reflows it.
use std::sync::Arc;
use designcraft_doc::flex::{FlexAlign, FlexDirection, FlexJustify, FlexLayout, FlexSize};
use designcraft_doc::{Content, Document, Item, ItemId, Selection, Shape};
use designcraft_geom::{Rect, shapes};
use serde_json::{Value, json};
use super::object::RemoveKeep;
use super::{CommandSpec, bad, cmd, has_doc, has_selection, id_param, targets};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"flex.add",
"Add to Flex Layout",
["Object", "Flex Layout"],
None,
"{ids?} — the selected objects (top level, one spread) in a new Flex Layout container where they are; aligned objects get that direction, Auto size and Space Between, others a wrapping row of their width. One group or empty rectangle frame is converted instead (flex.convert) → {id}",
has_selection,
add
),
cmd!(
"flex.convert",
"Convert to Flex Layout",
["Object", "Flex Layout"],
None,
"{ids?} — a group (its objects) or a rectangle frame (with what is pasted into it) becomes a Flex Layout container → {id}",
has_selection,
convert
),
cmd!(
"flex.remove",
"Remove Flex Layout",
["Object", "Flex Layout"],
None,
"{ids?} — the container goes; its objects stay where they are, grouped when there are several (a container with its own fill or stroke stays as a frame holding them) → {ids}",
is_container,
remove
),
cmd!(
"flex.set",
"Flex Layout Options",
[],
None,
"{ids?, direction?: row|rowReverse|column|columnReverse, wrap?: bool, justify?: flexStart|center|flexEnd|spaceBetween|spaceAround|spaceEvenly, align?: start|center|end (across), cell?: 0..8 (the alignment grid, row by row from the top left: sets justification and alignment by direction), width?: auto|fixed|number, height?: auto|fixed|number, padding?: number|[top, left, bottom, right], gapH?, gapV?} → {changed}",
is_container,
set
),
cmd!(
"flex.reorder",
"Reorder in Flex Layout",
[],
None,
"{id (an object in a container), to?: index | by?: ±n} — its place in the container's order → {index}",
has_doc,
reorder
),
]
}
fn is_container(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
let st = s.active().ok_or("no document")?;
if st.selection.items.iter().any(|i| st.doc.item(*i).is_some_and(|it| it.flex.is_some())) {
Ok(())
} else {
Err("select a Flex Layout container".into())
}
}
/// Lay out every container (called after each edit). Spreads without one aren't copied.
pub(crate) fn sync(d: &mut Document) {
for sp in d.spreads.iter_mut().chain(d.parents.iter_mut()) {
if sp.items.iter().any(|i| designcraft_doc::flex::contains_flex(i)) {
designcraft_doc::flex::layout_all(&mut Arc::make_mut(sp).items);
}
}
}
/// InDesign's detection when objects become a container: objects in a row (or column) keep it,
/// with Auto size and Space Between at their own spacing; others wrap in a row of their width.
fn detect(bounds: &[Rect]) -> FlexLayout {
let tol = 0.5;
let in_line = |row: bool| {
let mut v: Vec<&Rect> = bounds.iter().collect();
v.sort_by(|a, b| if row { a.x0.total_cmp(&b.x0) } else { a.y0.total_cmp(&b.y0) });
let apart = v.windows(2).all(|w| if row { w[1].x0 >= w[0].x1 - tol } else { w[1].y0 >= w[0].y1 - tol });
let gaps: Vec<f64> = v.windows(2).map(|w| if row { w[1].x0 - w[0].x1 } else { w[1].y0 - w[0].y1 }).collect();
(apart && bounds.len() > 1).then(|| gaps.iter().sum::<f64>() / gaps.len().max(1) as f64)
};
let same = |f: &dyn Fn(&Rect) -> f64| bounds.windows(2).all(|w| (f(&w[0]) - f(&w[1])).abs() <= tol);
let align = |row: bool| {
let (a, c, e): (&dyn Fn(&Rect) -> f64, &dyn Fn(&Rect) -> f64, &dyn Fn(&Rect) -> f64) =
if row { (&|r| r.y0, &|r| r.center().y, &|r| r.y1) } else { (&|r| r.x0, &|r| r.center().x, &|r| r.x1) };
if same(a) {
FlexAlign::Start
} else if same(c) {
FlexAlign::Center
} else if same(e) {
FlexAlign::End
} else {
FlexAlign::Start
}
};
let auto = |gap: f64, row: bool| FlexLayout {
direction: if row { FlexDirection::Row } else { FlexDirection::Column },
justify: FlexJustify::SpaceBetween,
align: align(row),
width: FlexSize::Auto,
height: FlexSize::Auto,
gap_h: if row { gap.max(0.0) } else { 0.0 },
gap_v: if row { 0.0 } else { gap.max(0.0) },
..Default::default()
};
match (in_line(true), in_line(false)) {
(Some(gap), _) => auto(gap, true),
(None, Some(gap)) => auto(gap, false),
_ => FlexLayout { wrap: true, height: FlexSize::Auto, ..Default::default() },
}
}
fn add(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
if let [one] = ids.as_slice()
&& s.doc()?.doc.item(*one).is_some_and(convertible)
{
return convert(s, p);
}
if ids.is_empty() {
return Err(bad("flex.add", "select the objects to put in a Flex Layout"));
}
s.edit(|d, sel| {
let sr = d.find(ids[0]).map(|l| l.spread).ok_or(designcraft_doc::DocError::NoItem(ids[0]))?;
let order: Vec<ItemId> = d.spread(sr).map(|sp| sp.items.iter().map(|i| i.id).filter(|i| ids.contains(i)).collect()).unwrap_or_default();
if order.len() != ids.len() {
return Err(bad("flex.add", "select objects on one spread, not inside a group or frame"));
}
let front = d.spread(sr).and_then(|sp| sp.items.iter().position(|i| Some(&i.id) == order.last())).unwrap_or(0);
let layer = d.item(order[0]).map(|i| i.layer).unwrap_or_default();
let mut kids: Vec<Item> = Vec::new();
for id in &order {
kids.push(d.remove_item_keep_story(*id)?);
}
// Their order in the container: along the line they are in, else top to bottom, left to right.
let bounds: Vec<Rect> = kids.iter().map(Item::bounds).collect();
let f = detect(&bounds);
// A row reads left to right (wrapped: row by row); a column top to bottom (wrapped: column by
// column).
let key = |r: &Rect| if f.direction.is_row() != f.wrap { (r.x0, r.y0) } else { (r.y0, r.x0) };
kids.sort_by(|a, b| {
let (ka, kb) = (key(&a.bounds()), key(&b.bounds()));
ka.0.total_cmp(&kb.0).then(ka.1.total_cmp(&kb.1))
});
let rect = bounds.iter().copied().reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO);
let cid = ItemId(d.alloc());
let mut c = Item::new(cid, layer, Shape::Rectangle, shapes::rectangle(rect));
c.content = Content::Group { items: kids.into_iter().map(Arc::new).collect() };
c.flex = Some(f);
d.insert_item(sr, c, Some(front + 1 - order.len()))?;
*sel = Selection::items(vec![cid]);
Ok(json!({"id": cid.0}))
})
}
/// A group, or a rectangle frame holding nothing or pasted-in objects.
fn convertible(it: &Item) -> bool {
it.flex.is_none() && (it.is_group() || it.shape == Shape::Rectangle && matches!(it.content, Content::Unassigned | Content::Group { .. }))
}
fn convert(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let [id] = ids.as_slice() else { return Err(bad("flex.convert", "select one group or rectangle frame")) };
let id = *id;
s.edit(|d, sel| {
let it = d.item(id).ok_or(designcraft_doc::DocError::NoItem(id))?.clone();
if !convertible(&it) {
return Err(bad("flex.convert", "select one group or rectangle frame"));
}
let kids: Vec<Arc<Item>> = match &it.content {
Content::Group { items } => items.clone(),
_ => Vec::new(),
};
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
if it.is_group() {
// The group's box becomes the container's, in its own space.
let rect = kids.iter().map(|k| k.bounds()).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO);
it.shape = Shape::Rectangle;
it.path = shapes::rectangle(rect);
}
let bounds: Vec<Rect> = kids.iter().map(|k| k.bounds()).collect();
it.flex = Some(if bounds.is_empty() { FlexLayout::default() } else { detect(&bounds) });
it.content = Content::Group { items: kids };
*sel = Selection::items(vec![id]);
Ok(json!({"id": id.0}))
})
}
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
s.edit(|d, sel| {
let mut out = Vec::new();
for id in &ids {
let Some(loc) = d.find(*id) else { continue };
let Some(c) = d.item(*id).cloned() else { continue };
if c.flex.is_none() {
continue;
}
let kids = match &c.content {
Content::Group { items } => items.clone(),
_ => Vec::new(),
};
// A frame with its own look stays one, holding its objects.
if !c.fill.is_none() || !c.stroke.is_none() || kids.is_empty() {
if let Some(it) = d.item_mut(*id) {
it.flex = None;
}
out.push(*id);
continue;
}
if loc.path.len() != 1 {
return Err(bad("flex.remove", "a Flex Layout inside a group or another container keeps its objects; remove the outer one first"));
}
let Some(sp) = d.spread_mut(loc.spread) else { continue };
sp.items.remove(loc.path[0]);
let new = if let [one] = kids.as_slice() {
let mut k = (**one).clone();
k.xf = c.xf * k.xf;
k
} else {
let mut g = Item::new(c.id, c.layer, Shape::Group, designcraft_geom::PathData::default());
g.xf = c.xf;
g.content = Content::Group { items: kids };
g
};
out.push(new.id);
sp.items.insert(loc.path[0], Arc::new(new));
}
*sel = Selection::items(out.clone());
Ok(json!({"ids": out.iter().map(|i| i.0).collect::<Vec<_>>()}))
})
}
fn parse<T: serde::de::DeserializeOwned>(p: &Value, key: &str) -> Result<Option<T>> {
match p.get(key) {
None => Ok(None),
Some(v) => serde_json::from_value(v.clone()).map(Some).map_err(|_| bad("flex.set", format!("`{key}`: unknown value {v}"))),
}
}
fn set(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let direction: Option<FlexDirection> = parse(p, "direction")?;
let justify: Option<FlexJustify> = parse(p, "justify")?;
let align: Option<FlexAlign> = parse(p, "align")?;
let cell = p.get("cell").and_then(Value::as_u64);
if cell.is_some_and(|c| c > 8) {
return Err(bad("flex.set", "`cell` is 0..8"));
}
let num = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 100_000.0));
let padding: Option<[f64; 4]> = match p.get("padding") {
None => None,
Some(Value::Number(n)) => n.as_f64().filter(|v| v.is_finite()).map(|v| [v.clamp(0.0, 100_000.0); 4]),
Some(v) => {
let a: Vec<f64> = serde_json::from_value(v.clone()).map_err(|_| bad("flex.set", "`padding`: a number or [top, left, bottom, right]"))?;
let [t, l, b, r] = a.as_slice() else { return Err(bad("flex.set", "`padding`: a number or [top, left, bottom, right]")) };
Some([*t, *l, *b, *r].map(|v| if v.is_finite() { v.clamp(0.0, 100_000.0) } else { 0.0 }))
}
};
let size = |k: &str| -> Result<Option<(FlexSize, Option<f64>)>> {
match p.get(k) {
None => Ok(None),
Some(Value::String(s)) if s == "auto" => Ok(Some((FlexSize::Auto, None))),
Some(Value::String(s)) if s == "fixed" => Ok(Some((FlexSize::Fixed, None))),
Some(Value::Number(n)) => Ok(n.as_f64().filter(|v| v.is_finite() && *v > 0.0).map(|v| (FlexSize::Fixed, Some(v.min(100_000.0))))),
Some(v) => Err(bad("flex.set", format!("`{k}`: auto, fixed or a size, not {v}"))),
}
};
let (width, height) = (size("width")?, size("height")?);
let (gap_h, gap_v, wrap) = (num("gapH"), num("gapV"), p.get("wrap").and_then(Value::as_bool));
s.edit(|d, _| {
let mut changed = 0;
for id in &ids {
let Some(it) = d.item_mut(*id) else { continue };
let Some(f) = it.flex.as_mut() else { continue };
if let Some(v) = direction {
f.direction = v;
}
if let Some(v) = wrap {
f.wrap = v;
}
if let Some(v) = justify {
f.justify = v;
}
if let Some(v) = align {
f.align = v;
}
if let Some(c) = cell {
// The grid is what you see: its column is left/centre/right, its row top/middle/bottom.
let pos = |i: u64| [FlexAlign::Start, FlexAlign::Center, FlexAlign::End][i as usize % 3];
let (h, v) = (pos(c % 3), pos(c / 3));
let (main, cross) = if f.direction.is_row() { (h, v) } else { (v, h) };
let main = if f.direction.is_reverse() {
match main {
FlexAlign::Start => FlexAlign::End,
FlexAlign::End => FlexAlign::Start,
m => m,
}
} else {
main
};
if !f.justify.distributes() {
f.justify = match main {
FlexAlign::Start => FlexJustify::FlexStart,
FlexAlign::Center => FlexJustify::Center,
FlexAlign::End => FlexJustify::FlexEnd,
};
}
f.align = cross;
}
if let Some(v) = padding {
f.padding = v;
}
if let Some(v) = gap_h {
f.gap_h = v;
}
if let Some(v) = gap_v {
f.gap_v = v;
}
let r = it.inner_bounds();
let mut rect = r;
if let Some((mode, value)) = width {
if let Some(f) = it.flex.as_mut() {
f.width = mode;
}
if let Some(w) = value {
rect.x1 = rect.x0 + w;
}
}
if let Some((mode, value)) = height {
if let Some(f) = it.flex.as_mut() {
f.height = mode;
}
if let Some(h) = value {
rect.y1 = rect.y0 + h;
}
}
if rect != r {
it.path = shapes::rectangle(rect);
}
changed += 1;
}
Ok(json!({"changed": changed}))
})
}
fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("flex.reorder", "missing `id`"))?;
let to = p.get("to").and_then(Value::as_i64);
let by = p.get("by").and_then(Value::as_i64);
if to.is_none() && by.is_none() {
return Err(bad("flex.reorder", "give `to` or `by`"));
}
s.edit(|d, sel| {
let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
let (last, parent_path) = loc.path.split_last().ok_or(designcraft_doc::DocError::NoItem(id))?;
let parent_loc = designcraft_doc::ItemLoc { spread: loc.spread, path: parent_path.to_vec() };
let parent = d
.item_at(&parent_loc)
.filter(|c| c.flex.is_some())
.map(|c| c.id)
.ok_or_else(|| bad("flex.reorder", "the object isn't in a Flex Layout"))?;
let c = d.item_mut(parent).ok_or(designcraft_doc::DocError::NoItem(parent))?;
let Content::Group { items } = &mut c.content else { return Err(bad("flex.reorder", "the object isn't in a Flex Layout")) };
let n = items.len() as i64;
let from = *last as i64;
let target = to.unwrap_or(from.saturating_add(by.unwrap_or(0))).clamp(0, n - 1);
let moved = items.remove(from as usize);
items.insert(target as usize, moved);
*sel = Selection::items(vec![id]);
Ok(json!({"index": target}))
})
}
/// An object moved inside a container goes to the place it was dropped on: before the nearest
/// object if it lies before that one's centre along the main axis, else after it.
pub(crate) fn drop_in_container(d: &mut Document, id: ItemId) {
let Some(loc) = d.find(id) else { return };
let Some((&at, parent)) = loc.path.split_last() else { return };
let parent_loc = designcraft_doc::ItemLoc { spread: loc.spread, path: parent.to_vec() };
let Some(cid) = d.item_at(&parent_loc).filter(|c| c.flex.is_some()).map(|c| c.id) else { return };
let Some(c) = d.item_mut(cid) else { return };
let Some(f) = c.flex.clone() else { return };
let Content::Group { items } = &mut c.content else { return };
if at >= items.len() {
return;
}
let moved = items.remove(at);
let m = moved.bounds().center();
let along = |p: designcraft_geom::Point| if f.direction.is_row() { p.x } else { p.y };
let nearest = items.iter().enumerate().map(|(i, k)| (i, k.bounds().center())).min_by(|a, b| (a.1 - m).hypot().total_cmp(&(b.1 - m).hypot()));
let index = match nearest {
Some((i, k)) => {
let before = (along(m) < along(k)) != f.direction.is_reverse();
if before { i } else { i + 1 }
}
None => 0,
};
items.insert(index.min(items.len()), moved);
}
#[cfg(test)]
mod tests {
use super::*;
fn session_with_boxes(boxes: &[[f64; 4]]) -> (Session, Vec<u64>) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 1920, "height": 1080, "margins": 84})).unwrap();
let ids = boxes.iter().map(|r| s.execute("frame.create", &json!({"rect": r})).unwrap()["id"].as_u64().unwrap()).collect();
(s, ids)
}
fn rect_of(s: &Session, id: u64) -> Rect {
s.doc().unwrap().doc.item(ItemId(id)).unwrap().bounds()
}
#[test]
fn reorder_by_a_huge_step_clamps_instead_of_overflowing() {
let (mut s, ids) = session_with_boxes(&[[84.0, 84.0, 184.0, 184.0], [284.0, 84.0, 384.0, 184.0]]);
s.execute("selection.set", &json!({"ids": ids})).unwrap();
s.execute("flex.add", &json!({})).unwrap();
// `from + by` used to overflow (a panic with overflow checks on).
s.execute("flex.reorder", &json!({"id": ids[0], "by": i64::MAX})).unwrap();
s.execute("flex.reorder", &json!({"id": ids[0], "by": i64::MIN})).unwrap();
}
/// The video: four objects in a row become a container that keeps them where they are (Row,
/// Auto size, Space Between at their spacing, aligned to the top), then a fixed 2-column layout.
#[test]
fn four_objects_in_a_row_then_two_wrapped_columns() {
// As in the video after Shift+Cmd shrinks them: 400 × 430, 22 apart.
let boxes = [[84.0, 461.0, 484.0, 891.0], [506.0, 461.0, 906.0, 891.0], [928.0, 461.0, 1328.0, 891.0], [1350.0, 461.0, 1750.0, 891.0]];
let (mut s, ids) = session_with_boxes(&boxes);
s.execute("selection.set", &json!({"ids": ids})).unwrap();
let c = s.execute("flex.add", &json!({})).unwrap()["id"].as_u64().unwrap();
let d = s.doc().unwrap().doc.clone();
let it = d.item(ItemId(c)).unwrap();
let f = it.flex.clone().unwrap();
assert_eq!(
(f.direction, f.justify, f.align, f.width, f.height, f.wrap),
(FlexDirection::Row, FlexJustify::SpaceBetween, FlexAlign::Start, FlexSize::Auto, FlexSize::Auto, false)
);
assert!((f.gap_h - 22.0).abs() < 1e-9);
assert_eq!(it.bounds(), Rect::new(84.0, 461.0, 1750.0, 891.0));
for (id, b) in ids.iter().zip(&boxes) {
assert_eq!(rect_of(&s, *id), Rect::new(b[0], b[1], b[2], b[3]), "nothing moves");
}
assert_eq!(d.spreads[0].items.len(), 1, "the objects are in the container");
// Resize the container (Fixed), Flex Start, a wrapping column, spacing, centred.
s.execute(
"flex.set",
&json!({"width": 868, "height": 912, "justify": "flexStart", "direction": "column", "wrap": true, "gapH": 56, "gapV": 38}),
)
.unwrap();
s.execute("flex.set", &json!({"cell": 4})).unwrap();
let f = s.doc().unwrap().doc.item(ItemId(c)).unwrap().flex.clone().unwrap();
assert_eq!((f.justify, f.align, f.width), (FlexJustify::Center, FlexAlign::Center, FlexSize::Fixed));
// Two columns of two: 400 + 56 + 400 across 868, 430 + 38 + 430 down 912, centred.
let r: Vec<Rect> = ids.iter().map(|i| rect_of(&s, *i)).collect();
assert_eq!(r[0].x0, r[1].x0);
assert_eq!(r[2].x0, r[3].x0);
assert!((r[2].x0 - r[0].x1 - 56.0).abs() < 1e-6);
assert!((r[1].y0 - r[0].y1 - 38.0).abs() < 1e-6);
let c_rect = s.doc().unwrap().doc.item(ItemId(c)).unwrap().bounds();
assert!((r[0].x0 - c_rect.x0 - 6.0).abs() < 1e-6 && (r[0].y0 - c_rect.y0 - 7.0).abs() < 1e-6, "{r:?} in {c_rect:?}");
// Reorder: 2 goes to the top of the second column.
s.execute("flex.reorder", &json!({"id": ids[1], "to": 2})).unwrap();
assert_eq!(rect_of(&s, ids[1]).x0, r[2].x0, "now in the second column");
assert_eq!(rect_of(&s, ids[1]).y0, r[0].y0);
// One undo step per command; undo the reorder.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(rect_of(&s, ids[1]), r[1]);
}
/// Resizing the container by its handles changes its box (Fixed), not its objects' size.
#[test]
fn resizing_a_container_reflows_instead_of_scaling() {
let (mut s, ids) = session_with_boxes(&[[100.0, 100.0, 200.0, 200.0], [220.0, 100.0, 320.0, 200.0]]);
s.execute("selection.set", &json!({"ids": ids})).unwrap();
let c = s.execute("flex.add", &json!({})).unwrap()["id"].as_u64().unwrap();
s.execute("flex.set", &json!({"justify": "flexEnd"})).unwrap();
s.execute("transform.resize", &json!({"from": [100, 100, 320, 200], "to": [100, 100, 520, 300]})).unwrap();
let it = s.doc().unwrap().doc.item(ItemId(c)).unwrap().clone();
let b = it.bounds();
assert!((b.x1 - 520.0).abs() < 1e-6 && (b.y1 - 300.0).abs() < 1e-6 && b.x0 == 100.0, "{b:?}");
let f = it.flex.unwrap();
assert_eq!((f.width, f.height), (FlexSize::Fixed, FlexSize::Fixed));
let r1 = rect_of(&s, ids[1]);
assert!((r1.x1 - 520.0).abs() < 1e-6 && (r1.width() - 100.0).abs() < 1e-6 && r1.y0 == 100.0, "same size, at the end of the row: {r1:?}");
assert_eq!(rect_of(&s, ids[0]).width(), 100.0);
s.execute("transform.set", &json!({"width": 300, "ref": 0})).unwrap();
assert!((rect_of(&s, ids[1]).x1 - 400.0).abs() < 1e-6);
}
/// Moving an object inside the container is undone by the layout (it places them); a new
/// object in the container is laid out at once.
#[test]
fn the_layout_follows_every_edit() {
let (mut s, ids) = session_with_boxes(&[[0.0, 0.0, 100.0, 50.0], [150.0, 0.0, 250.0, 50.0]]);
s.execute("selection.set", &json!({"ids": ids})).unwrap();
s.execute("flex.add", &json!({})).unwrap();
s.execute("transform.set", &json!({"ids": [ids[0]], "width": 300, "ref": 0})).unwrap();
// Auto width: the container grows, the second object follows at the spacing.
assert_eq!(rect_of(&s, ids[1]).x0, 350.0);
}
#[test]
fn remove_groups_the_objects_where_they_are() {
let (mut s, ids) = session_with_boxes(&[[0.0, 0.0, 100.0, 50.0], [150.0, 0.0, 250.0, 50.0]]);
s.execute("selection.set", &json!({"ids": ids})).unwrap();
s.execute("flex.add", &json!({})).unwrap();
s.execute("flex.set", &json!({"justify": "flexStart", "gapH": 10})).unwrap();
let before = rect_of(&s, ids[1]);
let r = s.execute("flex.remove", &json!({})).unwrap();
let g = r["ids"][0].as_u64().unwrap();
let d = s.doc().unwrap().doc.clone();
assert!(d.item(ItemId(g)).unwrap().is_group());
assert_eq!(rect_of(&s, ids[1]), before);
assert_eq!(before.x0, 110.0);
// A group converts back.
s.execute("flex.convert", &json!({"ids": [g]})).unwrap();
assert!(s.doc().unwrap().doc.item(ItemId(g)).unwrap().flex.is_some());
}
/// Dragging an object in a container drops it in the nearest place (the video moves group 2 to
/// the top of the second column).
#[test]
fn dragging_an_object_reorders_the_container() {
let (mut s, ids) = session_with_boxes(&[[0.0, 0.0, 100.0, 50.0], [110.0, 0.0, 210.0, 50.0], [220.0, 0.0, 320.0, 50.0]]);
s.execute("selection.set", &json!({"ids": ids})).unwrap();
s.execute("flex.add", &json!({})).unwrap();
s.execute("flex.set", &json!({"justify": "flexStart"})).unwrap();
// The first box dropped past the third's centre: it goes last.
s.execute("transform.move", &json!({"ids": [ids[0]], "dx": 250, "dy": 5})).unwrap();
let x: Vec<f64> = ids.iter().map(|i| rect_of(&s, *i).x0.round()).collect();
assert_eq!(x, [220.0, 0.0, 110.0]);
// Back before the second (now at 0): first again.
s.execute("transform.move", &json!({"ids": [ids[0]], "dx": -225, "dy": 0})).unwrap();
let x: Vec<f64> = ids.iter().map(|i| rect_of(&s, *i).x0.round()).collect();
assert_eq!(x, [0.0, 110.0, 220.0]);
}
/// Flex settings are saved with the document.
#[test]
fn a_container_round_trips_through_the_file() {
let (mut s, ids) = session_with_boxes(&[[0.0, 0.0, 100.0, 50.0], [110.0, 0.0, 210.0, 50.0]]);
s.execute("selection.set", &json!({"ids": ids})).unwrap();
let c = s.execute("flex.add", &json!({})).unwrap()["id"].as_u64().unwrap();
s.execute("flex.set", &json!({"direction": "columnReverse", "wrap": true, "padding": [1, 2, 3, 4], "gapV": 7})).unwrap();
let d = s.doc().unwrap().doc.clone();
let back = crate::cmd::from_bytes(&crate::cmd::to_bytes(&d)).unwrap();
assert_eq!(back.item(ItemId(c)).unwrap().flex, d.item(ItemId(c)).unwrap().flex);
assert!(back.item(ItemId(c)).unwrap().flex.is_some());
}
#[test]
fn bad_params_are_refused() {
let (mut s, ids) = session_with_boxes(&[[0.0, 0.0, 100.0, 50.0]]);
s.execute("selection.set", &json!({"ids": ids})).unwrap();
s.execute("flex.add", &json!({})).unwrap();
assert!(s.execute("flex.set", &json!({"direction": "diagonal"})).is_err());
assert!(s.execute("flex.set", &json!({"cell": 9})).is_err());
assert!(s.execute("flex.set", &json!({"padding": [1, 2]})).is_err());
assert!(s.execute("flex.reorder", &json!({"id": ids[0]})).is_err());
s.execute("flex.set", &json!({"gapH": f64::MAX, "width": -5})).unwrap();
}
}
+19 -1
View File
@@ -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}");
}
}
+58 -2
View File
@@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Guide",
[],
None,
"{orientation: horizontal|vertical, position (spread coordinate), spread?, page? (index in the spread; default: the page under `at` or the position), at? (the other coordinate), spreadGuide?: bool} → {page, index}",
"{orientation: horizontal|vertical, position (spread coordinate), spread?, page? (index in the spread; default: the page under `at` or the position), at? (the other coordinate), spreadGuide?: bool, liquid?: bool (a liquid guide, as the Page tool draws from the rulers)} → {page, index}",
has_doc,
add
),
@@ -98,6 +98,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
let pos = finite_number(p, "position", "guide.add")?.ok_or_else(|| bad("guide.add", "missing position"))?;
let r = super::spread_param(p, "guide.add")?;
let spread_guide = p.get("spreadGuide").and_then(Value::as_bool).unwrap_or(false);
let liquid = p.get("liquid").and_then(Value::as_bool).unwrap_or(false);
let d = &s.doc()?.doc;
let pi = match optional_usize(p, "page", "guide.add")? {
Some(pi) => pi,
@@ -114,7 +115,14 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
s.edit(|d, _| {
let sp = d.spread_mut(r).ok_or_else(|| bad("guide.add", "no such spread"))?;
let pg = sp.pages.get_mut(pi).ok_or_else(|| bad("guide.add", "no such page"))?;
pg.guides.push(Guide { orientation: o, position: pos, spread: spread_guide, locked: false, layer: Some(layer), liquid: false });
pg.guides.push(Guide {
orientation: o,
position: pos,
spread: spread_guide,
locked: false,
layer: Some(layer),
liquid: liquid && !spread_guide,
});
Ok(json!({"page": pi, "index": pg.guides.len() - 1}))
})
}
@@ -387,4 +395,52 @@ mod layer_tests {
let d = s.doc().unwrap().doc.clone();
assert!(g(&s).visible_in(&d) && !g(&s).editable_in(&d));
}
#[test]
fn create_guides_goes_to_the_named_spread_and_page_only() {
use designcraft_doc::SpreadRef;
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 3})).unwrap();
// The UI passes the spread/page being worked on as a serialised SpreadRef (page 3).
let spread = serde_json::to_value(SpreadRef::Doc(1)).unwrap();
s.execute("layout.createGuides", &json!({"spread": spread, "page": 1, "columns": 2, "columnGutter": 0.0})).unwrap();
let l = s.execute("guide.list", &json!({"spread": 1})).unwrap();
let l = l.as_array().unwrap();
assert!(!l.is_empty() && l.iter().all(|g| g["page"] == 1), "only page 3: {l:?}");
let first = s.execute("guide.list", &json!({"spread": 0})).unwrap();
assert!(first.as_array().unwrap().is_empty(), "nothing on the first page");
}
#[test]
fn guides_on_a_parent_page_show_on_the_pages_that_use_it() {
use designcraft_doc::{PageSide, SpreadRef};
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 4, "facingPages": true})).unwrap();
// A vertical guide 88 pt inside the parent's right page, and a horizontal one.
let parent = serde_json::to_value(SpreadRef::Parent(0)).unwrap();
let right_x = s.doc().unwrap().doc.parents[0].pages.last().unwrap().x;
s.execute("guide.add", &json!({"spread": parent, "orientation": "vertical", "position": right_x + 88.0})).unwrap();
s.execute("guide.add", &json!({"spread": parent, "orientation": "horizontal", "position": 200.0, "at": right_x + 10.0})).unwrap();
let d = &s.doc().unwrap().doc;
let mut seen = 0;
for abs in 0..d.page_count() {
let page = d.page(abs).unwrap();
let (pi, qi) = d.parent_page_for(abs).unwrap();
let inherited = d.inherited_guides(abs);
if d.parents[pi].pages[qi].guides.is_empty() {
assert!(inherited.is_empty(), "page {abs} uses the parent's left page, which has no guides");
continue;
}
seen += 1;
assert_eq!(page.side, PageSide::Right);
let v = inherited.iter().find(|g| g.orientation == designcraft_doc::Orientation::Vertical).unwrap();
// Moved to this page's own place in its spread: still 88 pt inside the page.
assert!((v.position - (page.x + 88.0)).abs() < 1e-6, "page {abs}: {v:?} vs x {}", page.x);
let h = inherited.iter().find(|g| g.orientation == designcraft_doc::Orientation::Horizontal).unwrap();
assert!((h.position - 200.0).abs() < 1e-6);
}
assert!(seen >= 2, "pages on the parent's right side inherit: {seen}");
// The guides stay on the parent: nothing is copied onto document pages.
assert!((0..d.page_count()).all(|a| d.page(a).unwrap().guides.is_empty()));
}
}
+11 -5
View File
@@ -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);
+380 -37
View File
@@ -75,6 +75,25 @@ fn dist(r: designcraft_geom::Rect, x: f64) -> f64 {
}
}
/// Apply the `margins`, `columns` and `gutter` params of `layout.marginsAndColumns` to one page.
fn set_margins_columns(pg: &mut designcraft_doc::Page, p: &Value) {
match p.get("margins") {
Some(Value::Number(n)) => pg.margins = Margins::uniform(n.as_f64().unwrap_or(36.0)),
Some(v @ Value::Object(_)) => {
if let Ok(m) = serde_json::from_value(v.clone()) {
pg.margins = m;
}
}
_ => {}
}
if let Some(c) = p.get("columns").and_then(Value::as_u64) {
pg.columns.count = (c as u32).clamp(1, 216);
}
if let Some(g) = p.get("gutter").and_then(Value::as_f64) {
pg.columns.gutter = g.max(0.0);
}
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -88,7 +107,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)?;
@@ -105,7 +124,13 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
|s, p| {
let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize;
let angle = p.get("angle").and_then(Value::as_i64).unwrap_or(90);
let angle = match p.get("angle") {
None => 90,
Some(v) => v.as_i64().ok_or_else(|| bad("layout.rotateSpreadView", "angle must be an integer multiple of 90 degrees"))?,
};
if angle % 90 != 0 {
return Err(bad("layout.rotateSpreadView", "angle must be an integer multiple of 90 degrees"));
}
s.edit(|d, _| {
let sp = d.spreads.get_mut(si).ok_or_else(|| bad("layout.rotateSpreadView", "no such spread"))?;
let pg = std::sync::Arc::make_mut(sp).pages.first_mut().ok_or_else(|| bad("layout.rotateSpreadView", "empty spread"))?;
@@ -135,21 +160,63 @@ pub fn specs() -> Vec<CommandSpec> {
ok()
})
}),
cmd!("layout.pages.duplicateSpread", "Duplicate Spread", ["Layout", "Pages"], None, "{spread}", has_doc, |s, p| {
let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize;
s.edit(|d, _| {
let sp = d.spreads.get(si).cloned().ok_or_else(|| bad("layout.pages.duplicateSpread", "no such spread"))?;
let after = d.first_page_of_spread(si) + sp.pages.len() - 1;
let parent = sp.pages.first().and_then(|p| p.parent);
d.insert_pages(Some(after), sp.pages.len(), parent)?;
// Copy items onto the new spread(s) at the same positions.
let src = d.clone();
let ids: Vec<_> = sp.items.iter().map(|i| i.id).collect();
let (nsi, _) = d.page_loc(after + 1).ok_or_else(|| bad("layout.pages.duplicateSpread", "insert failed"))?;
super::object::duplicate_from(d, &src, &ids, designcraft_doc::SpreadRef::Doc(nsi), designcraft_geom::Vec2::ZERO)?;
ok()
})
}),
cmd!(
"layout.pages.duplicateSpread",
"Duplicate Spread",
["Layout", "Pages"],
None,
"{spread, atEnd?: bool (Duplicate Spread at End of Document; the Pages panel's New Page button takes a dropped page there), to?: page index the copied pages move to, in the same undo step (the Pages panel's Alt-drag)} → {page: the copy's first page (0-based)} — the spread's pages (size, margins, columns, guides, liquid rule) and items",
has_doc,
|s, p| {
let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize;
let at_end = p.get("atEnd").and_then(Value::as_bool).unwrap_or(false);
let to = p.get("to").and_then(Value::as_u64).map(|t| t as usize);
s.edit(|d, _| {
let sp = d.spreads.get(si).cloned().ok_or_else(|| bad("layout.pages.duplicateSpread", "no such spread"))?;
// The copies land at `to`..`to + n` of the document with them in it.
if to.is_some_and(|t| t > d.page_count()) {
return Err(bad("layout.pages.duplicateSpread", "no such page"));
}
let after = if at_end { d.page_count() - 1 } else { d.first_page_of_spread(si) + sp.pages.len() - 1 };
let parent = sp.pages.first().and_then(|p| p.parent);
d.insert_pages(Some(after), sp.pages.len(), parent)?;
// The copies are the pages they copy: size, margins, columns, guides, liquid rule.
for (k, src) in sp.pages.iter().enumerate() {
let Some((nsi, npi)) = d.page_loc(after + 1 + k) else { continue };
let nsp = std::sync::Arc::make_mut(&mut d.spreads[nsi]);
let pg = &mut nsp.pages[npi];
let dx = pg.x - src.x;
(pg.width, pg.height, pg.margins, pg.columns, pg.liquid) =
(src.width, src.height, src.margins, src.columns.clone(), src.liquid);
pg.guides = src.guides.clone();
for g in pg.guides.iter_mut().filter(|g| g.orientation == designcraft_doc::Orientation::Vertical) {
g.position += dx;
}
nsp.relayout();
}
// Copy items onto the new spread(s) at the same positions.
let src = d.clone();
let ids: Vec<_> = sp.items.iter().map(|i| i.id).collect();
let (nsi, _) = d.page_loc(after + 1).ok_or_else(|| bad("layout.pages.duplicateSpread", "insert failed"))?;
super::object::duplicate_from(d, &src, &ids, designcraft_doc::SpreadRef::Doc(nsi), designcraft_geom::Vec2::ZERO)?;
let first = after + 1;
let Some(to) = to else { return Ok(json!({"page": first})) };
// Moving a page shifts the ones between; order the moves so each copy keeps
// its place among the others.
let n = sp.pages.len();
if first < to {
for k in (0..n).rev() {
d.move_page(first + k, to + k)?;
}
} else {
for k in 0..n {
d.move_page(first + k, to + k)?;
}
}
Ok(json!({"page": to}))
})
}
),
cmd!(
"layout.pages.applyParent",
"Apply Parent to Pages…",
@@ -191,33 +258,51 @@ pub fn specs() -> Vec<CommandSpec> {
"Margins and Columns…",
["Layout"],
None,
"{pages?: [index] (default all), margins?: number|{top,bottom,inside,outside}, columns?, gutter?, adjustLayout?: bool (objects follow the margins)}",
"{pages?: [index] (default all), parent?: index (edit that parent spread instead; `pages` then index its pages), margins?: number|{top,bottom,inside,outside}, columns?, gutter?, adjustLayout?: bool (objects follow the margins)}",
has_doc,
|s, p| {
let pages: Option<Vec<usize>> =
p.get("pages").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|v| v as usize).collect());
let p = p.clone();
let adjust = p.get("adjustLayout").and_then(Value::as_bool).unwrap_or(false);
let parent = p.get("parent").and_then(Value::as_u64).map(|v| v as usize);
s.edit(|d, _| {
let before = adjust.then(|| margin_boxes(d));
let all: Vec<usize> = pages.clone().unwrap_or_else(|| (0..d.page_count()).collect());
for abs in all {
let Some((si, pi)) = d.page_loc(abs) else { continue };
let pg = &mut std::sync::Arc::make_mut(&mut d.spreads[si]).pages[pi];
match p.get("margins") {
Some(Value::Number(n)) => pg.margins = Margins::uniform(n.as_f64().unwrap_or(36.0)),
Some(v @ Value::Object(_)) => {
if let Ok(m) = serde_json::from_value(v.clone()) {
pg.margins = m;
if let Some(pi) = parent {
// A parent spread: its own pages change, and so do the document pages based
// on it that still have the parent's old margins and columns (not overridden).
let Some(ps) = d.parents.get(pi) else {
return Err(bad("layout.marginsAndColumns", format!("no parent spread {pi}")));
};
let idxs: Vec<usize> = pages.clone().unwrap_or_else(|| (0..ps.pages.len()).collect());
let old: Vec<(usize, Margins, designcraft_doc::Columns)> =
idxs.iter().filter_map(|&qi| ps.pages.get(qi).map(|pg| (qi, pg.margins, pg.columns.clone()))).collect();
if let Some(ps) = d.parents.get_mut(pi) {
let ps = std::sync::Arc::make_mut(ps);
for &qi in &idxs {
if let Some(pg) = ps.pages.get_mut(qi) {
set_margins_columns(pg, &p);
}
}
_ => {}
}
if let Some(c) = p.get("columns").and_then(Value::as_u64) {
pg.columns.count = (c as u32).clamp(1, 216);
for abs in 0..d.page_count() {
let Some((ppi, qi)) = d.parent_page_for(abs) else { continue };
if ppi != pi {
continue;
}
let Some((_, om, oc)) = old.iter().find(|o| o.0 == qi) else { continue };
let Some((si, pj)) = d.page_loc(abs) else { continue };
let Some(pg) = d.spreads.get_mut(si).and_then(|sp| std::sync::Arc::make_mut(sp).pages.get_mut(pj)) else { continue };
if pg.margins == *om && pg.columns == *oc {
set_margins_columns(pg, &p);
}
}
if let Some(g) = p.get("gutter").and_then(Value::as_f64) {
pg.columns.gutter = g.max(0.0);
} else {
let all: Vec<usize> = pages.clone().unwrap_or_else(|| (0..d.page_count()).collect());
for abs in all {
let Some((si, pi)) = d.page_loc(abs) else { continue };
let Some(pg) = d.spreads.get_mut(si).and_then(|sp| std::sync::Arc::make_mut(sp).pages.get_mut(pi)) else { continue };
set_margins_columns(pg, &p);
}
}
if let Some(b) = before {
@@ -376,10 +461,48 @@ pub fn specs() -> Vec<CommandSpec> {
"Page Size",
[],
None,
"{pages: [1-based page numbers], width?, height?, preset?: \"A4\"|\"Letter\"|…} — pages of their own size (Page tool); objects on the pages keep their place on them",
"{pages: [1-based page numbers], width?, height?, preset?: \"A4\"|\"Letter\"|…, anchor?: 0–8 (reference point that stays put, row-major from the top-left; default 0)} — pages of their own size (Page tool); objects keep their place relative to the anchor, or follow the page's liquid rule",
has_doc,
page_size
),
cmd!(
"layout.pageOptions",
"Page Options",
[],
None,
"{pages: [1-based], showParentOverlay?: bool} — Page tool options (Show Parent Page Overlay: the parent page's size drawn over the page)",
has_doc,
page_options
),
cmd!(
noundo "layout.selectPage",
"Select Page",
[],
None,
"{page: 1-based | null, ids?: [item ids] (objects selected with it: the Page tool on an object-based page)} — the page the Page tool selects (its handles, the Control panel)",
has_doc,
|s, p| {
let n = s.doc()?.doc.page_count();
let page = match p.get("page") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_u64().filter(|v| *v >= 1 && *v as usize <= n).ok_or_else(|| bad("layout.selectPage", format!("`page`: 1–{n}")))? as usize - 1),
};
let ids: Vec<designcraft_doc::ItemId> = {
let d = &s.doc()?.doc;
p.get("ids")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(Value::as_u64).map(designcraft_doc::ItemId).filter(|id| d.item(*id).is_some()).collect())
.unwrap_or_default()
};
let st = s.doc_mut()?;
if page.is_some() {
st.selection = designcraft_doc::Selection::items(ids);
}
st.selection.page = page;
st.revision += 1;
Ok(json!({"page": page.map(|p| p + 1)}))
}
),
cmd!(
"layout.section",
"Numbering & Section Options…",
@@ -396,11 +519,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(),
@@ -428,6 +556,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<_>>()}))
})
}
@@ -640,8 +770,32 @@ fn page_size(s: &mut crate::Session, p: &Value) -> Result<Value> {
w = Some(pr.width);
h = Some(pr.height);
}
let anchor = match p.get("anchor") {
None | Some(Value::Null) => 0,
Some(v) => v.as_u64().filter(|a| *a <= 8).ok_or_else(|| bad("layout.pageSize", "`anchor`: 0–8"))? as usize,
};
s.edit(|d, _| {
resize_pages(d, &pages, w, h);
resize_pages_from(d, &pages, w, h, anchor);
Ok(json!({"pages": pages.len()}))
})
}
fn page_options(s: &mut crate::Session, p: &Value) -> Result<Value> {
let pages: Vec<usize> =
p.get("pages").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|n| n as usize).collect()).unwrap_or_default();
let n = s.doc()?.doc.page_count();
if pages.is_empty() || pages.iter().any(|x| *x == 0 || *x > n) {
return Err(bad("layout.pageOptions", "give `pages` (1-based)"));
}
let overlay = p.get("showParentOverlay").and_then(Value::as_bool);
s.edit(|d, _| {
for page in &pages {
let Some((si, pi)) = d.page_loc(page - 1) else { continue };
let pg = &mut std::sync::Arc::make_mut(&mut d.spreads[si]).pages[pi];
if let Some(v) = overlay {
pg.show_parent_overlay = v;
}
}
Ok(json!({"pages": pages.len()}))
})
}
@@ -649,14 +803,35 @@ fn page_size(s: &mut crate::Session, p: &Value) -> Result<Value> {
/// Give `pages` (1-based) their own size. Objects stay on their page; pages with a liquid rule
/// re-flow their objects by it.
pub(crate) fn resize_pages(d: &mut designcraft_doc::Document, pages: &[usize], w: Option<f64>, h: Option<f64>) {
resize_pages_from(d, pages, w, h, 0);
}
/// [`resize_pages`] keeping reference point `anchor` (0–8, row-major from the top-left) in place:
/// the page grows away from it, so objects keep their place relative to it.
pub(crate) fn resize_pages_from(d: &mut designcraft_doc::Document, pages: &[usize], w: Option<f64>, h: Option<f64>, anchor: usize) {
let (fx, fy) = ((anchor % 3) as f64 / 2.0, (anchor / 3).min(2) as f64 / 2.0);
{
for page in pages {
let Some((si, pi)) = d.page_loc(page - 1) else { continue };
let sp = std::sync::Arc::make_mut(&mut d.spreads[si]);
let old: Vec<f64> = sp.pages.iter().map(|x| x.x).collect();
let old_bounds = sp.pages[pi].bounds();
// The page's own objects (outer pages take the pasteboard beside them).
let last = sp.pages.len() - 1;
let (lo, hi) = (if pi == 0 { f64::NEG_INFINITY } else { old_bounds.x0 }, if pi == last { f64::INFINITY } else { old_bounds.x1 });
let own: Vec<usize> = sp
.items
.iter()
.enumerate()
.filter(|(_, it)| {
let c = it.bounds().center().x;
c >= lo && c < hi
})
.map(|(i, _)| i)
.collect();
let pg = &mut sp.pages[pi];
let dw = w.map_or(0.0, |w| w.max(1.0) - pg.width);
let dh = h.map_or(0.0, |h| h.max(1.0) - pg.height);
if let Some(w) = w {
pg.width = w.max(1.0);
}
@@ -693,8 +868,22 @@ pub(crate) fn resize_pages(d: &mut designcraft_doc::Document, pages: &[usize], w
}
}
}
// Liquid rules: objects were carried along with the page's left edge.
let moved = old_bounds + designcraft_geom::Vec2::new(new[pi] - old[pi], 0.0);
// Growing away from the anchor: the page's objects and guides keep their place
// relative to it.
let shift = designcraft_geom::Vec2::new(fx * dw, fy * dh);
if shift != designcraft_geom::Vec2::ZERO {
for i in &own {
if let Some(it) = sp.items.get_mut(*i) {
let it = std::sync::Arc::make_mut(it);
it.xf = designcraft_geom::Affine::translate(shift) * it.xf;
}
}
for g in &mut sp.pages[pi].guides {
g.position += if g.orientation == designcraft_doc::Orientation::Vertical { shift.x } else { shift.y };
}
}
// Liquid rules: objects were carried along with the page's left edge (and the anchor).
let moved = old_bounds + designcraft_geom::Vec2::new(new[pi] - old[pi], 0.0) + shift;
super::liquid::apply(d, designcraft_doc::SpreadRef::Doc(si), pi, moved);
}
}
@@ -895,10 +1084,33 @@ 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)]
mod adjust_tests {
use designcraft_doc::Margins;
use serde_json::json;
use crate::Session;
@@ -920,4 +1132,135 @@ mod adjust_tests {
let b2 = s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds();
assert_eq!(b, b2);
}
#[test]
fn parent_margins_change_the_parent_and_follow_unoverridden_pages() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap();
// Page 3 has its own margins (an override); pages 1 and 2 still follow the parent.
s.execute("layout.marginsAndColumns", &json!({"pages": [2], "margins": 100})).unwrap();
let parent_before = s.doc().unwrap().doc.parents[0].pages[0].margins;
assert_eq!(parent_before, Margins::uniform(36.0));
s.execute("layout.marginsAndColumns", &json!({"parent": 0, "margins": 60, "columns": 2})).unwrap();
let d = &s.doc().unwrap().doc;
// The parent page itself changed (this is what was drawn on screen).
assert_eq!(d.parents[0].pages[0].margins, Margins::uniform(60.0));
assert_eq!(d.parents[0].pages[0].columns.count, 2);
// Pages that matched the parent follow; the overridden page keeps its margins.
assert_eq!(d.page(0).unwrap().margins, Margins::uniform(60.0));
assert_eq!(d.page(1).unwrap().margins, Margins::uniform(60.0));
assert_eq!(d.page(2).unwrap().margins, Margins::uniform(100.0));
// An unknown parent is an error, not a silent no-op.
assert!(s.execute("layout.marginsAndColumns", &json!({"parent": 9, "margins": 10})).is_err());
}
}
#[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:?}");
}
}
#[cfg(test)]
mod spread_rotation_input_tests {
use serde_json::json;
#[test]
fn quarter_turns_work_and_other_angles_are_rejected_without_mutation() {
let mut s = crate::Session::new();
s.execute("file.new", &json!({})).unwrap();
let rotation = |s: &crate::Session| s.doc().unwrap().doc.spreads[0].pages[0].view_rotation;
for bad in [json!(45), json!(-45), json!(91), json!("90"), json!(0.5)] {
assert!(s.execute("layout.rotateSpreadView", &json!({"angle": bad})).is_err(), "{bad}");
assert_eq!(rotation(&s), 0, "invalid input must not change the view");
}
s.execute("layout.rotateSpreadView", &json!({"angle": 90})).unwrap();
assert_eq!(rotation(&s), 1);
s.execute("layout.rotateSpreadView", &json!({"angle": -90})).unwrap();
assert_eq!(rotation(&s), 0);
s.execute("layout.rotateSpreadView", &json!({"angle": 180})).unwrap();
assert_eq!(rotation(&s), 2);
s.execute("layout.rotateSpreadView", &json!({"angle": 0})).unwrap();
assert_eq!(rotation(&s), 0, "zero resets the spread view");
}
}
+2 -2
View File
@@ -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()))
+2 -2
View File
@@ -11,8 +11,8 @@ use crate::Result;
fn copy_into(d: &mut Document, parent: StoryId, child: StoryId) -> Result<()> {
let src = d.story(parent).ok_or_else(|| bad("story.link", format!("no story {}", parent.0)))?.clone();
let st = d.story_mut(child).ok_or_else(|| bad("story.link", format!("no story {}", child.0)))?;
let (frames, rev) = (std::mem::take(&mut st.frames), st.rev);
*st = Story { id: child, frames, rev: rev + 1, link: Some((parent, src.rev)), ..src };
let (frames, rev, file_link) = (std::mem::take(&mut st.frames), st.rev, st.file_link.take());
*st = Story { id: child, frames, rev: rev + 1, link: Some((parent, src.rev)), file_link, ..src };
Ok(())
}
+546 -26
View File
@@ -4,10 +4,13 @@
//! Every placed graphic keeps its bytes in the document; a linked graphic also remembers its
//! file. Its status compares that file with the copy in the document: missing, modified (the
//! file's bytes differ) or OK.
//!
//! A linked text file (File › Place with Create Links) remembers its file's size and hash on the
//! story instead; updating it imports the file again into the story.
use std::sync::Arc;
use designcraft_doc::{AssetId, Content, Document, ItemId, Selection};
use designcraft_doc::{AssetId, Content, Document, Fingerprint, ItemId, Selection, Story, StoryId, TextFileLink};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, str_param};
@@ -15,15 +18,16 @@ use crate::{EngineError, Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "links.list", "Links", [], None, "{} → [{asset, name, path, status: ok|modified|missing|embedded, pixels, uses: [{id, page, ppi}]}]", has_doc, |s, _| {
Ok(Value::Array(list(&s.doc()?.doc)))
cmd!(query "links.list", "Links", [], None, "{} → [{asset, name, path, status: ok|modified|missing|embedded, pixels, uses: [{id, page, ppi}]}], then linked text files: [{story, name, path, status (also tooLarge: unchanged size, too big to check), edited (the story was changed in the layout), uses: [{id, page}]}]", has_doc, |s, _| {
let d = s.doc()?.doc.clone();
Ok(Value::Array(list(&d, &mut s.text_link_checks)))
}),
cmd!(
"links.relink",
"Relink…",
["Window", "Links"],
None,
"{asset, path} — use another file for every placement of the graphic",
"{asset | story, path} — use another file for every placement of the graphic, or import another text file into the linked story",
has_doc,
relink
),
@@ -41,22 +45,38 @@ pub fn specs() -> Vec<CommandSpec> {
"Update Link",
["Window", "Links"],
None,
"{asset?} — reload modified links from their files (all when no asset)",
"{asset? | story?} — reload modified links from their files (all when neither); a linked text file is imported again into its story with the import options it was placed with, replacing edits made in the layout → {updated, warnings}",
has_doc,
update
),
cmd!("links.embed", "Embed Link", ["Window", "Links"], None, "{asset} — keep only the copy in the document", has_doc, |s, p| {
let aid = asset_param(p, "links.embed")?;
s.edit(|d, _| {
let a = d.assets.get_mut(&aid).ok_or_else(|| bad("links.embed", "no such graphic"))?;
Arc::make_mut(a).link = None;
Ok(Value::Null)
})
}),
cmd!(noundo "links.goTo", "Go To Link", ["Window", "Links"], None, "{asset} — selects the frames showing the graphic", has_doc, |s, p| {
let aid = asset_param(p, "links.goTo")?;
cmd!(
"links.embed",
"Embed Link",
["Window", "Links"],
None,
"{asset | story} — keep only the copy in the document (a text file: unlink the story)",
has_doc,
|s, p| {
if let Some(sid) = story_param(p) {
return s.edit(|d, _| {
d.story_mut(sid).ok_or_else(|| bad("links.embed", "no such story"))?.file_link = None;
Ok(Value::Null)
});
}
let aid = asset_param(p, "links.embed")?;
s.edit(|d, _| {
let a = d.assets.get_mut(&aid).ok_or_else(|| bad("links.embed", "no such graphic"))?;
Arc::make_mut(a).link = None;
Ok(Value::Null)
})
}
),
cmd!(noundo "links.goTo", "Go To Link", ["Window", "Links"], None, "{asset | story} — selects the frames showing the graphic or the linked text", has_doc, |s, p| {
let st = s.doc_mut()?;
let ids = uses(&st.doc, aid);
let ids = match story_param(p) {
Some(sid) => st.doc.story(sid).map(|s| s.frames.clone()).unwrap_or_default(),
None => uses(&st.doc, asset_param(p, "links.goTo")?),
};
if ids.is_empty() {
return Err(bad("links.goTo", "the graphic isn't placed"));
}
@@ -71,6 +91,21 @@ fn asset_param(p: &Value, cmd: &str) -> Result<AssetId> {
p.get("asset").and_then(Value::as_u64).map(AssetId).ok_or_else(|| bad(cmd, "missing asset"))
}
fn story_param(p: &Value) -> Option<StoryId> {
p.get("story").and_then(Value::as_u64).map(StoryId)
}
/// FNV-1a (64-bit) of `bytes`, in hex: a linked text file's fingerprint.
pub(crate) fn fnv_hex(bytes: &[u8]) -> String {
let h = bytes.iter().fold(0xcbf2_9ce4_8422_2325_u64, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x0000_0100_0000_01b3));
format!("{h:016x}")
}
/// Fingerprint of a story's text and formatting (edits made in the layout change it).
pub(crate) fn story_hash(st: &Story) -> String {
fnv_hex(serde_json::to_string(&(&st.text, &st.paras, &st.chars)).unwrap_or_default().as_bytes())
}
/// Frames (graphic content) showing an asset, top-level or in groups.
fn uses(d: &Document, aid: AssetId) -> Vec<ItemId> {
let mut out = Vec::new();
@@ -93,11 +128,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",
@@ -111,7 +146,89 @@ pub fn status(a: &designcraft_doc::Asset) -> &'static str {
}
}
fn list(d: &Document) -> Vec<Value> {
/// The largest linked text file read (the Data Merge source limit). Only regular files are read,
/// so a link to a FIFO or a device (e.g. `/dev/zero`) can't block or fill memory.
const MAX_TEXT_LINK_BYTES: u64 = super::datamerge::MAX_DATA_FILE_BYTES;
/// Can a linked text file this size be checked and updated?
pub(crate) fn checkable_size(len: usize) -> bool {
len as u64 <= MAX_TEXT_LINK_BYTES
}
/// Can a size and time be trusted to change when the file does? Times are whole seconds, so not
/// while the file was changed in the last two: another change in that second keeps the time. A
/// time in the future (clock skew, a file copied from another machine) counts as settled, or such
/// a file would be read on every poll; a later save gives it a time unlike the remembered one.
pub(crate) fn settled(f: &Fingerprint) -> bool {
#[cfg(not(target_arch = "wasm32"))]
{
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX));
f.mtime > now || now.saturating_sub(f.mtime) >= 2
}
#[cfg(target_arch = "wasm32")]
{
let _ = f;
false
}
}
/// Checked linked text files: path → (size and time, hash of the contents then).
pub(crate) type TextChecks = std::collections::HashMap<String, (Fingerprint, String)>;
/// Link status of a linked text file: `tooLarge` when it is the recorded size but over
/// [`MAX_TEXT_LINK_BYTES`], so its contents aren't checked. The file is read only when its size
/// is the recorded one but its time isn't, and then once per new time (`checks`): a file saved
/// or synced again without changes stays OK.
pub(crate) fn text_status(l: &TextFileLink, checks: &mut TextChecks) -> &'static str {
#[cfg(not(target_arch = "wasm32"))]
{
if !crate::io::is_file(&l.path) {
return "missing";
}
let now = match crate::io::metadata(&l.path) {
Err(_) => return "missing",
Ok(m) => super::datamerge::fingerprint_from(&m),
};
if now.size != l.size {
return "modified";
}
if l.mtime != 0 && now.mtime == l.mtime {
return "ok";
}
let hash = match checks.get(&l.path) {
Some((at, hash)) if *at == now => hash.clone(),
_ if now.size > MAX_TEXT_LINK_BYTES => return "tooLarge",
_ => match read_text(&l.path) {
Ok((b, at)) => {
let hash = fnv_hex(&b);
// The time from before the read: a change during it is seen next time.
if settled(&at) {
checks.insert(l.path.clone(), (at, hash.clone()));
}
hash
}
Err(_) => return "missing",
},
};
if hash == l.hash { "ok" } else { "modified" }
}
#[cfg(target_arch = "wasm32")]
{
let _ = (l, checks);
"ok"
}
}
fn file_name(path: &str) -> String {
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string())
}
fn list(d: &Document, checks: &mut TextChecks) -> Vec<Value> {
let texts = d.stories.values().filter_map(|st| {
let l = st.file_link.as_ref()?;
let uses: Vec<Value> = st.frames.iter().map(|id| json!({"id": id.0, "page": d.page_of_item(*id).map(|p| d.page_name(p))})).collect();
Some(json!({"story": st.id.0, "name": file_name(&l.path), "path": l.path, "status": text_status(l, checks), "edited": story_hash(st) != l.story_hash, "uses": uses}))
});
let used = d.used_assets();
d.assets
.values()
@@ -140,12 +257,50 @@ fn list(d: &Document) -> Vec<Value> {
.collect();
json!({"asset": a.id.0, "name": a.name, "path": a.link, "status": status(a), "pixels": a.pixels, "uses": uses})
})
.chain(texts)
.collect()
}
/// A linked text file's bytes, with its size and time from before they were read.
#[cfg(not(target_arch = "wasm32"))]
fn read_text(path: &str) -> Result<(Vec<u8>, Fingerprint)> {
let err = |e: std::io::Error| EngineError::Other(format!("{path}: {e}"));
// The roots' own message when they refuse the path.
crate::file_access::check_read(path).map_err(EngineError::Other)?;
let m = crate::io::metadata(path).map_err(err)?;
if !crate::io::is_file(path) {
return Err(EngineError::Other(format!("{path}: not a file")));
}
let too_large = || EngineError::Other(format!("{path}: larger than {} MiB", MAX_TEXT_LINK_BYTES / (1024 * 1024)));
if m.len() > MAX_TEXT_LINK_BYTES {
return Err(too_large());
}
// At most one byte over the limit, in case the file grew since.
let b = crate::io::read_capped(path, MAX_TEXT_LINK_BYTES).map_err(err)?;
if b.len() as u64 > MAX_TEXT_LINK_BYTES {
return Err(too_large());
}
Ok((b, super::datamerge::fingerprint_from(&m)))
}
#[cfg(target_arch = "wasm32")]
fn read_text(path: &str) -> Result<(Vec<u8>, Fingerprint)> {
Err(EngineError::Other(format!("{path}: files can't be read on the web")))
}
/// Import a linked text file again; a warning says when edits made in the layout were replaced.
fn reimport(d: &mut Document, sid: StoryId, path: &str, bytes: &[u8], before: Fingerprint) -> Result<Vec<String>> {
let edited = d.story(sid).is_some_and(|st| st.file_link.as_ref().is_some_and(|l| story_hash(st) != l.story_hash));
let mut warnings = super::place_text::reimport(d, sid, path, bytes, Some(before))?;
if edited {
warnings.insert(0, format!("{}: edits made to story {} in the layout were replaced by the file's text", file_name(path), sid.0));
}
Ok(warnings)
}
#[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))
}
@@ -156,8 +311,16 @@ fn reload(path: &str) -> Result<(Vec<u8>, (u32, u32))> {
}
fn relink(s: &mut Session, p: &Value) -> Result<Value> {
let aid = asset_param(p, "links.relink")?;
let path = str_param(p, "path").ok_or_else(|| bad("links.relink", "missing path"))?.to_string();
if let Some(sid) = story_param(p) {
let (bytes, before) = read_text(&path)?;
let name = file_name(&path);
return s.edit(|d, _| {
let warnings = reimport(d, sid, &path, &bytes, before)?;
Ok(json!({"name": name, "warnings": warnings}))
});
}
let aid = asset_param(p, "links.relink")?;
let (bytes, px) = reload(&path)?;
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone());
s.edit(|d, _| {
@@ -174,6 +337,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 +361,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());
@@ -209,19 +376,33 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result<Value> {
}
fn update(s: &mut Session, p: &Value) -> Result<Value> {
let d = &s.doc()?.doc;
let d = s.doc()?.doc.clone();
let checks = &mut s.text_link_checks;
let (only_asset, only_story) = (p.get("asset").and_then(Value::as_u64), story_param(p));
let all = only_asset.is_none() && only_story.is_none();
let which: Vec<(AssetId, String)> = d
.assets
.values()
.filter(|a| p.get("asset").and_then(Value::as_u64).is_none_or(|x| x == a.id.0))
.filter(|a| all || only_asset == Some(a.id.0))
.filter(|a| status(a) == "modified")
.filter_map(|a| a.link.clone().map(|l| (a.id, l)))
.collect();
let texts: Vec<(StoryId, String)> = d
.stories
.values()
.filter(|st| all || only_story == Some(st.id))
.filter_map(|st| st.file_link.as_ref().filter(|l| text_status(l, checks) == "modified").map(|l| (st.id, l.path.clone())))
.collect();
let mut fresh = Vec::new();
for (aid, path) in which {
fresh.push((aid, reload(&path)?));
}
let n = fresh.len();
let mut fresh_texts = Vec::new();
for (sid, path) in texts {
let (bytes, before) = read_text(&path)?;
fresh_texts.push((sid, path, bytes, before));
}
let n = fresh.len() + fresh_texts.len();
if n == 0 {
return Ok(json!({"updated": 0}));
}
@@ -233,7 +414,11 @@ fn update(s: &mut Session, p: &Value) -> Result<Value> {
a.pixels = Some(*px);
}
}
Ok(json!({"updated": n}))
let mut warnings = Vec::new();
for (sid, path, bytes, before) in &fresh_texts {
warnings.extend(reimport(d, *sid, path, bytes, *before)?);
}
Ok(json!({"updated": n, "warnings": warnings}))
})
}
@@ -427,4 +612,339 @@ 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));
}
}
#[cfg(test)]
mod text_link_tests {
use serde_json::{Value, json};
use crate::Session;
use designcraft_doc::{ItemId, StoryId};
/// A small Word file: a Title paragraph, then Body paragraphs (`body` is WordprocessingML).
fn docx(body: &str) -> Vec<u8> {
use std::io::Write;
const W: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#;
let styles = format!(
r#"<w:styles {W}><w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:rPr><w:sz w:val="56"/></w:rPr></w:style>
<w:style w:type="paragraph" w:styleId="Body"><w:name w:val="Body"/><w:rPr><w:sz w:val="20"/></w:rPr></w:style></w:styles>"#
);
let doc = format!(
r#"<w:document {W}><w:body><w:p><w:pPr><w:pStyle w:val="Title"/></w:pPr><w:r><w:t>Head</w:t></w:r></w:p>
<w:p><w:pPr><w:pStyle w:val="Body"/></w:pPr>{body}</w:p></w:body></w:document>"#
);
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default();
for (n, c) in [("word/document.xml", doc), ("word/styles.xml", styles)] {
w.start_file(n, o).unwrap();
w.write_all(c.as_bytes()).unwrap();
}
w.finish().unwrap().into_inner()
}
fn text_rows(s: &mut Session) -> Vec<Value> {
let l = s.execute("links.list", &json!({})).unwrap();
l.as_array().unwrap().iter().filter(|r| r.get("story").is_some()).cloned().collect()
}
fn story(s: &Session, sid: StoryId) -> designcraft_doc::Story {
s.doc().unwrap().doc.story(sid).unwrap().clone()
}
fn styles(st: &designcraft_doc::Story) -> Vec<String> {
st.paras.iter().map(|p| p.style.clone()).collect()
}
#[test]
fn linked_word_file_status_update_relink_and_unlink() {
let dir = std::env::temp_dir().join(format!("dc-text-link-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("moved")).unwrap();
let path = dir.join("chapter.docx");
std::fs::write(&path, docx("<w:r><w:t>First draft.</w:t></w:r>")).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 2})).unwrap();
s.execute("style.paragraph.create", &json!({"name": "Body", "chars": {"size": 9}})).unwrap();
s.execute("style.paragraph.create", &json!({"name": "Headline", "chars": {"size": 30}})).unwrap();
// Two threaded frames.
let a = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text"})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "spread": 1, "content": "text"})).unwrap()["id"].as_u64().unwrap();
s.edit(|d, _| Ok(d.thread(ItemId(a), ItemId(b))?)).unwrap();
s.execute("edit.deselectAll", &json!({})).unwrap();
// Place and link, with a style map and Auto Rename.
let r = s
.execute(
"file.place",
&json!({"path": path.to_string_lossy(), "frame": a, "link": true, "styleMap": {"Title": "Headline"}, "styleConflicts": "autoRename"}),
)
.unwrap();
let sid = StoryId(r["story"].as_u64().unwrap());
let placed = story(&s, sid);
assert_eq!(placed.text, "Head\nFirst draft.");
assert_eq!(styles(&placed), ["Headline", "Body_wrd_1"]);
let link = placed.file_link.clone().unwrap();
assert_eq!(link.path, path.to_string_lossy());
assert_eq!(link.style_conflicts, "autoRename");
assert_eq!(link.style_map.get("Title").map(String::as_str), Some("Headline"));
assert_eq!(link.style_map.get("Body").map(String::as_str), Some("Body_wrd_1"), "Auto Rename's choice is kept");
let rows = text_rows(&mut s);
assert_eq!(rows.len(), 1);
assert_eq!((rows[0]["story"].as_u64(), rows[0]["name"].as_str()), (Some(sid.0), Some("chapter.docx")));
assert_eq!((rows[0]["status"].as_str(), rows[0]["edited"].as_bool()), (Some("ok"), Some(false)));
assert_eq!(rows[0]["uses"].as_array().unwrap().len(), 2, "both threaded frames");
// The file changes (with tracked changes): Modified; Update imports it as if accepted, with
// the saved style map, into the same frames, without new renamed styles.
std::fs::write(
&path,
docx(r#"<w:r><w:t xml:space="preserve">Second </w:t></w:r><w:ins w:id="1" w:author="A"><w:r><w:t>revised </w:t></w:r></w:ins><w:del w:id="2" w:author="A"><w:r><w:delText>old </w:delText></w:r></w:del><w:r><w:t>draft.</w:t></w:r>"#),
)
.unwrap();
assert_eq!(text_rows(&mut s)[0]["status"], "modified");
let r = s.execute("links.update", &json!({"story": sid.0})).unwrap();
assert_eq!(r["updated"], 1);
assert_eq!(r["warnings"], json!([]), "no edits in the layout to replace");
let updated = story(&s, sid);
assert_eq!(updated.text, "Head\nSecond revised draft.");
assert_eq!(styles(&updated), ["Headline", "Body_wrd_1"]);
assert_eq!(updated.frames, [ItemId(a), ItemId(b)], "frames and thread kept");
assert!(s.doc().unwrap().doc.styles.para("Body_wrd_2").is_none());
assert!(s.doc().unwrap().doc.styles.para("Title").is_none(), "mapped styles aren't imported");
assert_eq!(text_rows(&mut s)[0]["status"], "ok");
// An edit in the layout, then the file changes again: Update replaces the edit and warns.
s.execute("story.setText", &json!({"story": sid.0, "text": "Fixed in layout"})).unwrap();
assert_eq!(text_rows(&mut s)[0]["edited"], true);
std::fs::write(&path, docx("<w:r><w:t>Third draft.</w:t></w:r>")).unwrap();
let r = s.execute("links.update", &json!({})).unwrap();
assert_eq!(r["updated"], 1);
let w = r["warnings"][0].as_str().unwrap();
assert!(w.contains("chapter.docx") && w.contains("replaced"), "{w}");
assert_eq!(story(&s, sid).text, "Head\nThird draft.");
assert_eq!(text_rows(&mut s)[0]["edited"], false);
// Undo brings the edit back.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(story(&s, sid).text, "Fixed in layout");
s.execute("edit.redo", &json!({})).unwrap();
// Saved and opened again: the link and its options are kept.
let back = designcraft_format::load(&designcraft_format::save(&s.doc().unwrap().doc).unwrap()).unwrap();
assert_eq!(back.story(sid).unwrap().file_link, story(&s, sid).file_link);
assert_eq!(super::text_status(back.story(sid).unwrap().file_link.as_ref().unwrap(), &mut Default::default()), "ok");
// The file moves: Missing; Relink to it imports it into the story.
let moved = dir.join("moved").join("chapter.docx");
std::fs::write(&moved, docx("<w:r><w:t>Moved draft.</w:t></w:r>")).unwrap();
std::fs::remove_file(&path).unwrap();
assert_eq!(text_rows(&mut s)[0]["status"], "missing");
s.execute("links.relink", &json!({"story": sid.0, "path": moved.to_string_lossy()})).unwrap();
let rows = text_rows(&mut s);
assert_eq!((rows[0]["status"].as_str(), rows[0]["path"].as_str()), (Some("ok"), Some(moved.to_string_lossy().as_ref())));
assert_eq!(story(&s, sid).text, "Head\nMoved draft.");
// Go To selects the story's frames; Unlink keeps the text and drops the file.
let r = s.execute("links.goTo", &json!({"story": sid.0})).unwrap();
assert_eq!(r["ids"], json!([a, b]));
s.execute("links.embed", &json!({"story": sid.0})).unwrap();
assert!(text_rows(&mut s).is_empty());
assert_eq!(story(&s, sid).text, "Head\nMoved draft.");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn unchanged_linked_text_files_are_not_read_again() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("dc-text-link-poll-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("note.txt");
std::fs::write(&path, "Hello").unwrap();
// Unreadable: a status that reads the file would say missing.
let readable = |on: bool| std::fs::set_permissions(&path, std::fs::Permissions::from_mode(if on { 0o644 } else { 0o000 })).unwrap();
// A time `secs` ago (a file changed in the last two seconds is always read).
let touch = |secs: u64| {
let f = std::fs::File::options().write(true).open(&path).unwrap();
f.set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(secs)).unwrap();
};
touch(60);
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("file.place", &json!({"path": path.to_string_lossy(), "link": true})).unwrap();
let status = |s: &mut Session| text_rows(s)[0]["status"].as_str().unwrap().to_string();
// Size and time as placed: not read.
readable(false);
if std::fs::read(&path).is_ok() {
// Running as root: permissions don't stop reads, so this can't be observed.
return;
}
assert_eq!(status(&mut s), "ok");
// Saved again unchanged (a new time): read once, then not again.
readable(true);
touch(30);
assert_eq!(status(&mut s), "ok");
readable(false);
assert_eq!(status(&mut s), "ok", "the second poll doesn't read it");
// Changed, same size, new time: modified.
readable(true);
std::fs::write(&path, "Jello").unwrap();
touch(20);
assert_eq!(status(&mut s), "modified");
// Changed again in the same second, same size: still seen (just-changed files are read).
std::fs::write(&path, "Hello").unwrap();
assert_eq!(status(&mut s), "ok");
std::fs::write(&path, "Jello").unwrap();
assert_eq!(status(&mut s), "modified");
touch(10);
// Updated: the new size and time are kept, so the next poll doesn't read it.
s.execute("links.update", &json!({})).unwrap();
readable(false);
assert_eq!(status(&mut s), "ok");
// A time in the future (clock skew): read once, then not on every poll.
readable(true);
let f = std::fs::File::options().write(true).open(&path).unwrap();
f.set_modified(std::time::SystemTime::now() + std::time::Duration::from_secs(3600)).unwrap();
assert_eq!(status(&mut s), "ok");
readable(false);
assert_eq!(status(&mut s), "ok", "a future time is remembered");
readable(true);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn create_links_when_placing_text_files_preference() {
let dir = std::env::temp_dir().join(format!("dc-text-link-pref-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("notes.txt");
std::fs::write(&path, "Plain text").unwrap();
let place = |s: &mut Session, extra: Value| {
let mut p = json!({"path": path.to_string_lossy(), "rect": [72, 72, 300, 200]});
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
s.execute("edit.deselectAll", &json!({})).unwrap();
let r = s.execute("file.place", &p).unwrap();
(s.doc().unwrap().doc.story(StoryId(r["story"].as_u64().unwrap())).unwrap().file_link.is_some(), r)
};
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
assert!(!place(&mut s, json!({})).0, "off by default");
assert!(place(&mut s, json!({"link": true})).0);
s.execute("prefs.set", &json!({"linkTextFiles": true})).unwrap();
assert!(place(&mut s, json!({})).0, "the preference links");
assert!(!place(&mut s, json!({"link": false})).0, "`link` overrides it");
// Placed from bytes: no file to link.
let b64 = crate::cmd::base64_encode(b"Plain text");
let r = s.execute("file.place", &json!({"base64": b64, "name": "notes.txt", "rect": [72, 300, 300, 400]})).unwrap();
assert!(s.doc().unwrap().doc.story(StoryId(r["story"].as_u64().unwrap())).unwrap().file_link.is_none());
// Into an insertion point: not linked, with a warning.
let fr = s.execute("frame.create", &json!({"rect": [72, 500, 300, 600], "content": "text", "text": "Start end"})).unwrap();
let sid = fr["story"].as_u64().unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 6, "focus": 6})).unwrap();
let r = s.execute("file.place", &json!({"path": path.to_string_lossy()})).unwrap();
assert!(r["warnings"][0].as_str().unwrap().contains("not linked"), "{r}");
assert!(s.doc().unwrap().doc.story(StoryId(sid)).unwrap().file_link.is_none());
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(unix)]
#[cfg(test)]
mod text_link_read_tests {
use std::time::Duration;
use designcraft_doc::TextFileLink;
use super::{MAX_TEXT_LINK_BYTES, read_text, text_status};
/// `f` on another thread: a call that blocks fails the test instead of hanging it.
fn promptly<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> T {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(f());
});
// A timeout means the call blocked.
rx.recv_timeout(Duration::from_secs(10)).unwrap()
}
fn status_of(path: &std::path::Path, size: u64) -> &'static str {
let l = TextFileLink { path: path.to_string_lossy().to_string(), size, hash: "0".into(), ..Default::default() };
promptly(move || text_status(&l, &mut Default::default()))
}
fn read(path: &std::path::Path) -> Result<usize, String> {
let p = path.to_string_lossy().to_string();
promptly(move || read_text(&p).map(|(b, _)| b.len()).map_err(|e| e.to_string()))
}
#[test]
fn links_to_fifos_devices_folders_and_huge_files_are_not_read() {
let dir = std::env::temp_dir().join(format!("dc-text-link-read-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
// A FIFO with no writer: reading it would block for ever.
let fifo = dir.join("pipe.docx");
assert!(std::process::Command::new("mkfifo").arg(&fifo).status().unwrap().success());
assert_eq!(status_of(&fifo, 0), "missing");
assert!(read(&fifo).is_err());
// A device that never ends, recorded with size 0 (as its metadata reports).
let zero = std::path::Path::new("/dev/zero");
assert_eq!(status_of(zero, 0), "missing");
assert!(read(zero).is_err());
// A folder.
assert_eq!(status_of(&dir, 0), "missing");
assert!(read(&dir).is_err());
// Over the limit (sparse, so cheap): not read; the same size is reported as too large
// to check rather than OK, another size as modified.
let big = dir.join("big.txt");
std::fs::File::create(&big).unwrap().set_len(MAX_TEXT_LINK_BYTES + 1).unwrap();
assert_eq!(status_of(&big, MAX_TEXT_LINK_BYTES + 1), "tooLarge");
assert_eq!(status_of(&big, 10), "modified");
assert!(read(&big).unwrap_err().contains("larger than"));
assert!(!super::checkable_size(MAX_TEXT_LINK_BYTES as usize + 1), "placing it doesn't link it");
let _ = std::fs::remove_dir_all(&dir);
}
}
+20 -4
View File
@@ -35,7 +35,7 @@ fn axis(lo: f64, hi: f64, o: (f64, f64), n: (f64, f64), pin_lo: bool, pin_hi: bo
fn target(rule: LiquidRule, b: Rect, old: Rect, new: Rect, liquid: Option<ObjectLiquid>, vguides: &[f64], hguides: &[f64]) -> Rect {
let (ox, oy, nx, ny) = ((old.x0, old.x1), (old.y0, old.y1), (new.x0, new.x1), (new.y0, new.y1));
match rule {
LiquidRule::Off => b,
LiquidRule::Off | LiquidRule::ControlledByParent => b,
LiquidRule::ReCenter => b + (new.center() - old.center()),
LiquidRule::Scale => {
let k = (new.width() / old.width()).min(new.height() / old.height());
@@ -63,7 +63,7 @@ fn target(rule: LiquidRule, b: Rect, old: Rect, new: Rect, liquid: Option<Object
pub(crate) fn apply(d: &mut Document, sr: SpreadRef, pi: usize, old: Rect) {
let Some(sp) = d.spread(sr) else { return };
let Some(pg) = sp.pages.get(pi) else { return };
let (rule, new) = (pg.liquid, pg.bounds());
let (rule, new) = (effective_rule(d, sr, pi), pg.bounds());
if rule == LiquidRule::Off || old == new || old.width() < 1e-6 || old.height() < 1e-6 {
return;
}
@@ -108,8 +108,24 @@ pub(crate) fn apply(d: &mut Document, sr: SpreadRef, pi: usize, old: Rect) {
}
}
/// The rule page `pi` of spread `sr` follows: its own, or its parent page's when it's controlled
/// by the parent (Off when that's unset too).
pub(crate) fn effective_rule(d: &Document, sr: SpreadRef, pi: usize) -> LiquidRule {
let Some(pg) = d.spread(sr).and_then(|sp| sp.pages.get(pi)) else { return LiquidRule::Off };
if pg.liquid != LiquidRule::ControlledByParent {
return pg.liquid;
}
let SpreadRef::Doc(si) = sr else { return LiquidRule::Off };
let abs = d.first_page_of_spread(si) + pi;
match d.parent_page_for(abs).and_then(|(ppi, ppg)| d.parents.get(ppi)?.pages.get(ppg).map(|p| p.liquid)) {
Some(LiquidRule::ControlledByParent) | None => LiquidRule::Off,
Some(r) => r,
}
}
fn rule_param(p: &Value, id: &str) -> Result<LiquidRule> {
serde_json::from_value(p.get("rule").cloned().unwrap_or(json!("off"))).map_err(|_| bad(id, "`rule`: off|scale|reCenter|guideBased|objectBased"))
serde_json::from_value(p.get("rule").cloned().unwrap_or(json!("off")))
.map_err(|_| bad(id, "`rule`: off|scale|reCenter|guideBased|objectBased|controlledByParent"))
}
pub fn specs() -> Vec<CommandSpec> {
@@ -119,7 +135,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Liquid Page Rule",
["Layout", "Liquid Layout"],
None,
"{rule: off|scale|reCenter|guideBased|objectBased, pages?: [1-based] (default: all)}",
"{rule: off|scale|reCenter|guideBased|objectBased|controlledByParent (the parent page's rule), pages?: [1-based] (default: all)}",
has_doc,
|s, p| {
let rule = rule_param(p, "liquid.pageRule")?;
+4 -1
View File
@@ -15,7 +15,9 @@ mod editorial;
mod endnotes;
mod export;
mod file;
mod find;
pub(crate) mod find;
pub use find::same_find_domain;
pub(crate) mod flex;
mod fonts;
mod guides;
mod index;
@@ -161,6 +163,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(anchors::specs());
v.extend(articles::specs());
v.extend(paths::specs());
v.extend(flex::specs());
v.extend(book::specs());
v.extend(buttons::specs());
v.extend(captions::specs());
+192 -26
View File
@@ -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};
@@ -179,12 +180,35 @@ pub fn specs() -> Vec<CommandSpec> {
)
}
),
cmd!("object.fill", "Fill", [], None, "{swatch (a swatch's name, e.g. [Paper]), tint?: 0..1, ids?}", has_selection, |s, p| {
let sw = str_param(p, "swatch").ok_or_else(|| bad("object.fill", "missing swatch"))?.to_string();
known_swatch(&s.doc()?.doc, &sw, "object.fill", "swatch")?;
let tint = f64_or(p, "tint", 1.0) as f32;
set_flag(s, p, move |i| i.fill = Fill { swatch: sw.clone(), tint, ..i.fill.clone() }, false)
}),
cmd!(
"object.fill",
"Fill",
[],
None,
"{swatch? (a swatch's name, e.g. [Paper]), tint?: 0..1, ids?} — at least one; a tint alone keeps each object's own swatch (the Swatches panel's Tint field)",
has_selection,
|s, p| {
let sw = str_param(p, "swatch").map(str::to_string);
let tint = match p.get("tint") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_f64().filter(|t| (0.0..=1.0).contains(t)).ok_or_else(|| bad("object.fill", "`tint` is 0..1"))? as f32),
};
match &sw {
Some(sw) => known_swatch(&s.doc()?.doc, sw, "object.fill", "swatch")?,
None if tint.is_none() => return Err(bad("object.fill", "missing swatch")),
None => {}
}
set_flag(
s,
p,
move |i| match &sw {
Some(sw) => i.fill = Fill { swatch: sw.clone(), tint: tint.unwrap_or(1.0), ..i.fill.clone() },
None => i.fill.tint = tint.unwrap_or(i.fill.tint),
},
false,
)
}
),
cmd!(
"object.swapFillStroke",
"Swap Fill and Stroke",
@@ -217,10 +241,15 @@ pub fn specs() -> Vec<CommandSpec> {
}
}
let p2 = p.clone();
// A tint alone (the Swatches panel's Tint field) recolours strokes, it doesn't add one.
let tint_only = p.as_object().is_some_and(|o| o.contains_key("tint") && o.keys().all(|k| k == "tint" || k == "ids"));
set_flag(
s,
p,
move |i| {
if tint_only && i.stroke.is_none() {
return;
}
let mut st = if i.stroke.is_none() && p2.get("swatch").is_none() { Stroke::default() } else { i.stroke.clone() };
if let Some(v) = p2.get("swatch").and_then(Value::as_str) {
st.swatch = v.into();
@@ -586,7 +615,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fitting",
["Object", "Fitting"],
None,
"{mode: fillProportionally|fitProportionally|fitContentToFrame|centerContent|fitFrameToContent, ids?}",
"{mode: fillProportionally (preserves aspect ratio, covers frame, clips overflow)|fitProportionally (preserves aspect ratio, shows whole image, may leave empty space)|fitContentToFrame (stretches content to frame)|centerContent (keeps current size, positions at fitting alignment)|fitFrameToContent (resizes frame to content), ids?}",
has_selection,
fit
),
@@ -966,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) => {
@@ -984,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);
}
}
@@ -1003,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() {
@@ -1058,6 +1095,12 @@ fn transform_move(s: &mut Session, p: &Value) -> Result<Value> {
}
it.xf = Affine::translate(v) * it.xf;
}
// An object dragged inside a Flex Layout takes the place it was dropped on.
if !copy {
for id in &ids {
super::flex::drop_in_container(d, *id);
}
}
Ok(json!({"moved": ids.len(), "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
})
}
@@ -1131,6 +1174,8 @@ fn transform_resize(s: &mut Session, p: &Value) -> Result<Value> {
it.xf = Affine::translate(m * c - c) * it.xf;
} else if scale_content {
scale_item(it, m, (sx * sy).abs().sqrt(), strokes);
} else if it.flex.is_some() {
resize_container(it, m);
} else if matches!(it.content, Content::Group { .. }) {
it.xf = m * it.xf;
} else {
@@ -1150,16 +1195,36 @@ fn transform_resize(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// A Flex Layout container takes the new box (its objects keep their size; the layout places them),
/// and a side whose length changes becomes Fixed, as dragging a container's handle does in InDesign.
fn resize_container(it: &mut Item, m: Affine) {
let before = it.inner_bounds();
let inner = it.xf.inverse() * m * it.xf;
bake(it, inner);
let after = it.inner_bounds();
if let Some(f) = it.flex.as_mut() {
if (after.width() - before.width()).abs() > 1e-6 {
f.width = designcraft_doc::FlexSize::Fixed;
}
if (after.height() - before.height()).abs() > 1e-6 {
f.height = designcraft_doc::FlexSize::Fixed;
}
}
}
fn union_bounds(d: &Document, ids: &[ItemId]) -> Rect {
ids.iter().filter_map(|i| d.item(*i)).map(Item::bounds).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO)
}
/// Transform an item's geometry in its own space: the path and the gradient vector with it.
/// Transform an item's geometry in its own space: the path and the gradient vectors (fill and
/// stroke) with it.
pub(crate) fn bake(it: &mut Item, inner: Affine) {
it.path.transform(inner);
if let Some([x0, y0, x1, y1]) = it.fill.gradient_vector {
let (a, b) = (inner * Point::new(x0, y0), inner * Point::new(x1, y1));
it.fill.gradient_vector = Some([a.x, a.y, b.x, b.y]);
for v in [&mut it.fill.gradient_vector, &mut it.stroke.gradient_vector] {
if let Some([x0, y0, x1, y1]) = *v {
let (a, b) = (inner * Point::new(x0, y0), inner * Point::new(x1, y1));
*v = Some([a.x, a.y, b.x, b.y]);
}
}
}
@@ -1418,7 +1483,9 @@ fn transform_set(s: &mut Session, p: &Value) -> Result<Value> {
* Affine::translate((-geom.x0, -geom.y0));
for id in &ids {
if let Some(it) = d.item_mut(*id) {
if matches!(it.content, Content::Group { .. }) {
if it.flex.is_some() {
resize_container(it, m);
} else if matches!(it.content, Content::Group { .. }) {
it.xf = m * it.xf;
} else {
let inner = it.xf.inverse() * m * it.xf;
@@ -1655,6 +1722,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())?;
@@ -2241,6 +2322,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))?;
@@ -2276,12 +2358,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(),
@@ -2323,12 +2403,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() {
@@ -2350,6 +2437,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"));
@@ -2372,6 +2460,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).
@@ -3466,6 +3555,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)]
@@ -3672,3 +3805,36 @@ mod content_transform_tests {
assert!(s.execute("transform.set", &json!({"ids": [e], "content": true, "rotation": 10})).is_err());
}
}
#[cfg(test)]
mod fill_tint_tests {
use super::*;
#[test]
fn a_tint_alone_keeps_each_objects_own_swatch() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = s.execute("frame.create", &json!({"rect": [36, 36, 100, 100]})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("frame.create", &json!({"rect": [136, 36, 200, 100]})).unwrap()["id"].as_u64().unwrap();
s.execute("object.fill", &json!({"ids": [a], "swatch": "C=100 M=0 Y=0 K=0"})).unwrap();
s.execute("object.fill", &json!({"ids": [b], "swatch": "C=0 M=100 Y=0 K=0"})).unwrap();
s.execute("object.fill", &json!({"ids": [a, b], "tint": 0.4})).unwrap();
let fill_of = |s: &Session, id: u64| s.doc().unwrap().doc.item(ItemId(id)).unwrap().fill.clone();
assert_eq!((fill_of(&s, a).swatch.as_str(), fill_of(&s, a).tint), ("C=100 M=0 Y=0 K=0", 0.4));
assert_eq!((fill_of(&s, b).swatch.as_str(), fill_of(&s, b).tint), ("C=0 M=100 Y=0 K=0", 0.4));
// A swatch without a tint is at 100 %, as before.
s.execute("object.fill", &json!({"ids": [a], "swatch": "[Black]"})).unwrap();
assert_eq!(fill_of(&s, a).tint, 1.0);
// Neither, or a tint out of range: refused.
assert!(s.execute("object.fill", &json!({"ids": [a]})).is_err());
assert!(s.execute("object.fill", &json!({"ids": [a], "tint": 2})).is_err());
assert!(s.execute("object.fill", &json!({"ids": [a], "tint": "x"})).is_err());
// A stroke tint alone: stroked objects take it, an object without a stroke keeps none.
s.execute("object.stroke", &json!({"ids": [a], "swatch": "C=100 M=0 Y=0 K=0", "weight": 2})).unwrap();
s.execute("object.stroke", &json!({"ids": [b], "swatch": "[None]"})).unwrap();
s.execute("object.stroke", &json!({"ids": [a, b], "tint": 0.5})).unwrap();
let stroke_of = |s: &Session, id: u64| s.doc().unwrap().doc.item(ItemId(id)).unwrap().stroke.clone();
assert_eq!((stroke_of(&s, a).swatch.as_str(), stroke_of(&s, a).tint, stroke_of(&s, a).weight), ("C=100 M=0 Y=0 K=0", 0.5, 2.0));
assert!(stroke_of(&s, b).is_none());
}
}
+50
View File
@@ -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()));
+172 -9
View File
@@ -35,7 +35,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Create Outlines",
["Type"],
Some("Cmd+Shift+O"),
"{ids?} — text frames become compound paths of their glyphs (one per text colour, grouped when several) → {ids}",
"{ids?} — text frames become compound paths of their glyphs (one per line and text colour, grouped when several, so Ungroup separates the lines) → {ids}",
has_selection,
create_outlines
),
@@ -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")) };
@@ -245,9 +247,10 @@ 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();
for l in &ft.lines {
// One compound path per line and text look (fill, stroke), grouped: as in InDesign,
// Ungroup then leaves each line on its own.
let mut runs: Vec<((usize, String, f32, String, f32, f64, bool), BezPath)> = Vec::new();
for (li, l) in ft.lines.iter().enumerate() {
for g in l.glyphs.iter().filter(|g| g.visible) {
let st = &cs.styles[g.style as usize];
let outline = db.outline(&g.face, g.gid);
@@ -256,7 +259,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 = (li, 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 +277,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): ((usize, 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 +317,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 +423,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 +490,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();
@@ -454,6 +592,31 @@ mod tests {
assert!(d.stories.is_empty() || !d.stories.values().any(|st| st.frames.contains(&ItemId(id))));
}
#[test]
fn outlines_of_several_lines_ungroup_line_by_line() {
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": "GOOD\nMORNING"})).unwrap();
let id = r["id"].as_u64().unwrap();
s.execute("selection.set", &json!({"ids": [id]})).unwrap();
let g = s.execute("type.createOutlines", &json!({})).unwrap()["ids"][0].as_u64().unwrap();
let d = s.doc().unwrap().doc.clone();
let group = d.item(ItemId(g)).unwrap();
assert!(group.is_group(), "a group of the lines");
let lines: Vec<_> = group.children().iter().map(|c| c.bounds()).collect();
assert_eq!(lines.len(), 2, "one compound path per line");
assert!(lines[0].y1 <= lines[1].y0 + 1.0, "GOOD above MORNING: {lines:?}");
// Ungroup: each line on its own, to space them and make one compound path again.
s.execute("object.ungroup", &json!({})).unwrap();
let sel = s.doc().unwrap().selection.items.clone();
assert_eq!(sel.len(), 2);
let d = s.doc().unwrap().doc.clone();
assert!(sel.iter().all(|i| d.item(*i).is_some_and(|it| it.shape == Shape::Path && it.path.subpaths.len() >= 4)));
s.execute("object.makeCompoundPath", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().selection.items.len(), 1);
s.doc().unwrap().doc.check().unwrap();
}
#[test]
fn scissors_split_open_and_closed_paths() {
let mut s = Session::new();
+171 -18
View File
@@ -2,7 +2,7 @@
//! selected frame, or a new frame on the page; with autoflow, pages and threaded frames are added
//! until the whole story fits (InDesign's Shift-click with a loaded text cursor).
use designcraft_doc::{CharacterStyle, Content, Document, ParaFormat, ParagraphStyle, SpreadRef, Story, StoryId, TextSel};
use designcraft_doc::{CharacterStyle, Content, Document, ParaFormat, ParagraphStyle, SpreadRef, Story, StoryId, TextFileLink, TextSel};
use designcraft_geom::Rect;
use serde_json::{Value, json};
@@ -22,8 +22,33 @@ enum Conflict {
AutoRename,
}
impl Conflict {
fn parse(c: &str) -> Result<Self> {
match c {
"useExisting" | "" => Ok(Conflict::UseExisting),
"redefine" => Ok(Conflict::Redefine),
"autoRename" => Ok(Conflict::AutoRename),
c => Err(bad("file.place", format!("unknown styleConflicts `{c}` (useExisting, redefine, autoRename)"))),
}
}
fn name(self) -> &'static str {
match self {
Conflict::UseExisting => "useExisting",
Conflict::Redefine => "redefine",
Conflict::AutoRename => "autoRename",
}
}
}
/// Map imported style names (`styleMap`) and resolve name clashes in the import before it lands.
fn map_styles(d: &Document, imp: &mut designcraft_textimport::Imported, map: &std::collections::HashMap<String, String>, conflict: Conflict) {
/// Returns `map` with the names Auto Rename chose.
fn map_styles(
d: &Document,
imp: &mut designcraft_textimport::Imported,
map: &std::collections::HashMap<String, String>,
conflict: Conflict,
) -> std::collections::HashMap<String, String> {
let mut rename: std::collections::HashMap<String, String> = map.clone();
if conflict == Conflict::AutoRename {
let st = &d.styles;
@@ -39,7 +64,7 @@ fn map_styles(d: &Document, imp: &mut designcraft_textimport::Imported, map: &st
}
}
if rename.is_empty() {
return;
return rename;
}
// Mapped styles aren't imported; renamed ones come in under their new name.
imp.para_styles.retain(|s| !map.contains_key(&s.name));
@@ -63,6 +88,7 @@ fn map_styles(d: &Document, imp: &mut designcraft_textimport::Imported, map: &st
f.style = m.clone();
}
});
rename
}
fn add_styles(d: &mut Document, imp: &designcraft_textimport::Imported, conflict: Conflict) {
@@ -105,6 +131,70 @@ fn strip(story: &mut Story) {
story.format_chars(0..n, |f| *f = Default::default());
}
/// The story's whole text replaced by the import's (its frames stay).
fn fill(story: &mut Story, from: &Story) {
let n = story.len();
story.delete(0..n);
story.insert_story(0, from);
if let (Some(first), Some(p0)) = (from.paras.first(), story.paras.first_mut()) {
*p0 = ParaFormat { table: p0.table, ..first.clone() };
}
}
/// The link a story placed from `path` keeps (Create Links When Placing Text Files).
/// `before`: the file's size and time before `bytes` were read (its time is kept only when the
/// size matches what was read and it can be trusted, see [`super::links::settled`]).
fn text_link(
path: &str,
bytes: &[u8],
before: Option<designcraft_doc::Fingerprint>,
map: std::collections::HashMap<String, String>,
conflict: Conflict,
remove: bool,
story: &Story,
) -> TextFileLink {
TextFileLink {
path: path.to_string(),
size: bytes.len() as u64,
hash: super::links::fnv_hex(bytes),
mtime: before.filter(|f| f.size == bytes.len() as u64 && super::links::settled(f)).map_or(0, |f| f.mtime),
style_map: map.into_iter().collect(),
style_conflicts: conflict.name().into(),
remove_styles: remove,
story_hash: super::links::story_hash(story),
}
}
/// Update Link of a linked text file: `bytes` (read from `path`) imported again into the story
/// with the import options it was placed with, keeping its frames. Returns the import's warnings.
pub(crate) fn reimport(
d: &mut Document,
sid: StoryId,
path: &str,
bytes: &[u8],
before: Option<designcraft_doc::Fingerprint>,
) -> Result<Vec<String>> {
let link =
d.story(sid).and_then(|s| s.file_link.clone()).ok_or_else(|| bad("links.update", format!("story {} isn't linked to a file", sid.0)))?;
let name = std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string());
let mut imp = designcraft_textimport::import(&name, bytes).map_err(|e| bad("links.update", format!("{name}: {e}")))?;
if link.remove_styles {
strip(&mut imp.story);
imp.para_styles.clear();
imp.char_styles.clear();
}
let conflict = Conflict::parse(&link.style_conflicts)?;
// A mapping to a style the document no longer has is dropped: that style is imported again.
let map: std::collections::HashMap<String, String> =
link.style_map.into_iter().filter(|(_, to)| d.styles.para(to).is_some() || d.styles.char_style(to).is_some()).collect();
let map = map_styles(d, &mut imp, &map, conflict);
add_styles(d, &imp, conflict);
let story = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?;
fill(story, &imp.story);
story.file_link = Some(text_link(path, bytes, before, map, conflict, link.remove_styles, story));
Ok(imp.warnings)
}
/// Is the story overset in its frames, and how much text do its full frames hold (bytes, frames)?
/// A frame a column, frame or page break ends early is left out: it says nothing about how much
/// a frame holds.
@@ -160,7 +250,14 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s
}
}
pub(super) fn place_text(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) -> Result<Value> {
pub(super) fn place_text(
s: &mut Session,
p: &Value,
name: &str,
bytes: &[u8],
path: Option<String>,
before: Option<designcraft_doc::Fingerprint>,
) -> Result<Value> {
let mut imp = designcraft_textimport::import(name, bytes).map_err(|e| bad("file.place", e.to_string()))?;
let remove = p.get("removeStyles").and_then(Value::as_bool).unwrap_or(false);
if remove {
@@ -168,18 +265,20 @@ pub(super) fn place_text(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) -
imp.para_styles.clear();
imp.char_styles.clear();
}
let conflict = match p.get("styleConflicts").and_then(Value::as_str).unwrap_or("useExisting") {
"useExisting" => Conflict::UseExisting,
"redefine" => Conflict::Redefine,
"autoRename" => Conflict::AutoRename,
c => return Err(bad("file.place", format!("unknown styleConflicts `{c}` (useExisting, redefine, autoRename)"))),
};
let conflict = Conflict::parse(p.get("styleConflicts").and_then(Value::as_str).unwrap_or("useExisting"))?;
let map: std::collections::HashMap<String, String> = p
.get("styleMap")
.and_then(Value::as_object)
.map(|o| o.iter().filter_map(|(k, v)| Some((k.clone(), v.as_str()?.to_string()))).collect())
.unwrap_or_default();
map_styles(&s.doc()?.doc, &mut imp, &map, conflict);
let map = map_styles(&s.doc()?.doc, &mut imp, &map, conflict);
// Create Links When Placing Text Files (only a file on disk can be linked).
let link = path.filter(|_| p.get("link").and_then(Value::as_bool).unwrap_or(s.prefs.link_text_files));
// A file too large for the Links panel to check isn't linked.
let (link, too_large) = match link {
Some(_) if !super::links::checkable_size(bytes.len()) => (None, true),
l => (l, false),
};
let autoflow_on = p.get("autoflow").and_then(Value::as_bool).unwrap_or(false);
let st = s.doc()?;
let caret = st.selection.text.filter(|t| t.cell.is_none());
@@ -188,7 +287,10 @@ pub(super) fn place_text(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) -
});
let page = p.get("page").and_then(Value::as_u64).map(|v| (v as usize).saturating_sub(1));
let rect = super::rect_param(p, "rect");
let warnings = imp.warnings.clone();
let mut warnings = imp.warnings.clone();
if too_large {
warnings.push("not linked: the file is too large to keep linked".into());
}
s.edit(|d, sel| {
add_styles(d, &imp, conflict);
let sid = if let Some(t) = caret {
@@ -198,6 +300,9 @@ pub(super) fn place_text(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) -
story.delete(r.clone());
let end = story.insert_story(r.start, &imp.story);
sel.text = Some(TextSel { anchor: end, focus: end, ..t });
if link.is_some() {
warnings.push("not linked: a linked text file fills a whole story, not an insertion point".into());
}
t.story
} else {
let fid = match frame {
@@ -227,12 +332,8 @@ pub(super) fn place_text(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) -
None => return Err(bad("file.place", "no such frame")),
};
let story = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?;
let n = story.len();
story.delete(0..n);
story.insert_story(0, &imp.story);
if let Some(first) = imp.story.paras.first() {
story.paras[0] = ParaFormat { table: story.paras[0].table, ..first.clone() };
}
fill(story, &imp.story);
story.file_link = link.as_deref().map(|path| text_link(path, bytes, before, map.clone(), conflict, remove, story));
*sel = designcraft_doc::Selection::items(vec![fid]);
sid
};
@@ -487,4 +588,56 @@ mod pdf_page_tests {
s.execute("file.place", &json!({"base64": b64, "name": "a.docx", "styleConflicts": "redefine", "rect": [72, 400, 300, 500]})).unwrap();
assert_eq!(s.doc().unwrap().doc.styles.para("Body").unwrap().chars.size, Some(10.0));
}
#[test]
fn word_normal_comes_in_as_its_own_style() {
// Regression (#722): Normal's formatting (over Word's document defaults) was dropped.
use std::io::Write;
const W: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#;
let styles = format!(
r#"<w:styles {W}><w:docDefaults><w:rPrDefault><w:rPr><w:sz w:val="22"/></w:rPr></w:rPrDefault></w:docDefaults>
<w:style w:type="paragraph" w:styleId="Normal"><w:name w:val="Normal"/><w:rPr><w:rFonts w:ascii="Georgia"/><w:sz w:val="26"/></w:rPr></w:style></w:styles>"#
);
let doc = format!(r#"<w:document {W}><w:body><w:p><w:r><w:t>Plain</w:t></w:r></w:p></w:body></w:document>"#);
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (n, c) in [("word/document.xml", doc), ("word/styles.xml", styles)] {
w.start_file(n, zip::write::SimpleFileOptions::default()).unwrap();
w.write_all(c.as_bytes()).unwrap();
}
let b64 = crate::cmd::base64_encode(&w.finish().unwrap().into_inner());
let basic = designcraft_doc::story::BASIC_PARAGRAPH;
let placed = |s: &Session, story: &serde_json::Value| {
let d = &s.doc().unwrap().doc;
let st = d.story(designcraft_doc::StoryId(story["story"].as_u64().unwrap())).unwrap();
let (_, para_chars) = d.styles.resolve_para(&st.paras[0]);
let c = d.styles.resolve_char(&para_chars, st.format_after(0));
(st.paras[0].style.clone(), c.size, c.font_family)
};
let georgia = |style: &str| (style.to_string(), 13.0, "Georgia".to_string());
// Default options: the document gains Normal and the text looks as it did in Word.
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let info = s.execute("place.styles", &json!({"base64": b64, "name": "a.docx"})).unwrap();
assert_eq!(info["paragraph"], json!(["Normal"]));
assert_eq!(info["conflicts"], json!([]));
let r = s.execute("file.place", &json!({"base64": b64, "name": "a.docx"})).unwrap();
assert_eq!(placed(&s, &r), georgia("Normal"));
assert!(s.doc().unwrap().doc.styles.para(basic).unwrap().chars.is_empty(), "[Basic Paragraph] untouched");
// Normal mapped to [Basic Paragraph]: no new style, and the document's definition is used
// (mapped styles aren't imported, whatever the conflict option).
s.execute("file.new", &json!({})).unwrap();
let r =
s.execute("file.place", &json!({"base64": b64, "name": "a.docx", "styleMap": {"Normal": basic}, "styleConflicts": "redefine"})).unwrap();
assert!(s.doc().unwrap().doc.styles.para("Normal").is_none());
assert!(s.doc().unwrap().doc.styles.para(basic).unwrap().chars.is_empty());
let (style, size, _) = placed(&s, &r);
assert_eq!((style.as_str(), size), (basic, 12.0));
// A document with its own Normal: the usual conflict rules.
s.execute("style.paragraph.create", &json!({"name": "Normal", "chars": {"size": 9}})).unwrap();
let r = s.execute("file.place", &json!({"base64": b64, "name": "a.docx", "rect": [72, 400, 300, 500]})).unwrap();
assert_eq!(placed(&s, &r).1, 9.0, "use existing");
let r =
s.execute("file.place", &json!({"base64": b64, "name": "a.docx", "styleConflicts": "autoRename", "rect": [72, 520, 300, 600]})).unwrap();
assert_eq!(placed(&s, &r), georgia("Normal_wrd_1"));
}
}
+27 -1
View File
@@ -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 -1
View File
@@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Preferences",
[],
None,
"{showHiddenCharacters?, typographersQuotes?, polygonSides?, starInset?, scaleStrokes?, dimensionsIncludeStroke?, transformationsAreTotals?, absolutePageNumbers?, highlightHj?, highlightKeeps?, highlightCustomTracking?, highlightSubstitutedFonts?, richBlackOutput?, favoriteFonts?: [family], showFontNamesInEnglish?} → all application preferences",
"{showHiddenCharacters?, typographersQuotes?, polygonSides?, starInset?, scaleStrokes?, dimensionsIncludeStroke?, transformationsAreTotals?, absolutePageNumbers?, highlightHj?, highlightKeeps?, highlightCustomTracking?, highlightSubstitutedFonts?, richBlackOutput?, favoriteFonts?: [family], showFontNamesInEnglish?, linkTextFiles? (Create Links When Placing Text Files)} → all application preferences",
super::always,
|s, p| {
let cur = serde_json::to_value(&s.prefs).map_err(|e| bad("prefs.set", e.to_string()))?;
+34 -6
View File
@@ -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);
}
}
+419 -70
View File
@@ -86,7 +86,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Paragraph Style…",
[],
None,
"{name, basedOn?, nextStyle?, para?: {…}, chars?: {…}, fromSelection?: bool}",
"{name, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, firstLineIndent, spaceBefore, spaceAfter, …}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}, fromSelection?: bool}",
has_doc,
create_para
),
@@ -95,7 +95,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Paragraph Style Options…",
[],
None,
"{name, rename?, basedOn?, nextStyle?, para?: {… ruleAbove?/ruleBelow?: only the named rule fields change}, chars?: {…}}",
"{name, rename?, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, spaceBefore, …; ruleAbove?/ruleBelow?: only the named rule fields change}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}}",
has_doc,
edit_para
),
@@ -105,7 +105,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Character Style…",
[],
None,
"{name, basedOn?: style | \"[None]\", chars?: {…}, fromSelection?: bool (default false)} — if true, capture the first selected character's style/local overrides (caret: typing format), then overlay chars (null unsets); only explicit fill names are validated; paragraph formatting stays inherited; no text target: chars only → {name} (made unique)",
"{name, basedOn?: style | \"[None]\", chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}, fromSelection?: bool (default false)} — if true, capture the first selected character's style/local overrides (caret: typing format), then overlay chars (null unsets); only explicit fill names are validated; paragraph formatting stays inherited; no text target: chars only → {name} (made unique)",
has_doc,
create_char
),
@@ -114,10 +114,11 @@ pub fn specs() -> Vec<CommandSpec> {
"Character Style Options…",
[],
None,
"{name, rename?, basedOn?: style | \"[None]\" | null, chars?: {attr: value | null (no longer set)}} — renaming updates every use",
"{name, rename?, basedOn?: style | \"[None]\" | null, chars?: {character attribute (as type.char names them: fontFamily, fontStyle, size, …): value | null (no longer set)}} — renaming updates every use",
has_doc,
edit_char
),
cmd!("style.character.delete", "Delete Character Style", [], None, "{name, replaceWith?}", has_doc, delete_char),
cmd!(query "style.list", "List Styles", [], None, "{} → paragraph and character style names", has_doc, |s, _| {
let st = s.doc()?;
Ok(json!({
@@ -135,7 +136,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 +145,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), target?: fill|stroke, ids?} → {swatch, kind, stops} — edits the fill's (or the stroke's) gradient (an unnamed gradient unless it equals a gradient swatch)",
super::has_selection,
apply_gradient
),
@@ -180,7 +181,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
),
@@ -380,12 +381,18 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
fn attrs<T: Default>(v: Option<&Value>, set: impl Fn(&mut T, &str, &Value) -> std::result::Result<(), String>) -> Result<T> {
/// The `group` (`chars`, `para`) object of command `cmd`'s params as attributes; a bad attribute
/// is that command's parameter error, naming the group.
fn attrs<T: Default>(cmd: &str, group: &str, p: &Value, set: impl Fn(&mut T, &str, &Value) -> std::result::Result<(), String>) -> Result<T> {
let mut a = T::default();
if let Some(o) = v.and_then(Value::as_object) {
for (k, v) in o {
set(&mut a, k, v).map_err(|e| bad("style", e))?;
match p.get(group) {
None | Some(Value::Null) => {}
Some(Value::Object(o)) => {
for (k, v) in o {
set(&mut a, k, v).map_err(|e| bad(cmd, format!("{group}: {e}")))?;
}
}
Some(_) => return Err(bad(cmd, format!("{group} must be an object of attributes"))),
}
Ok(a)
}
@@ -436,8 +443,10 @@ fn apply_char(s: &mut Session, p: &Value) -> Result<Value> {
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))?;
let mut para: ParaAttrs = attrs("style.paragraph.create", "para", p, |a: &mut ParaAttrs, k, v| a.set_json(k, v))?;
let mut chars: CharAttrs = attrs("style.paragraph.create", "chars", p, |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 +462,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, _| {
@@ -479,7 +487,7 @@ fn CharProps_to_attrs(p: &designcraft_doc::CharProps) -> CharAttrs {
fn edit_para(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("style.paragraph.edit", "missing name"))?.to_string();
let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
let chars: CharAttrs = attrs("style.paragraph.edit", "chars", p, |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.edit")?;
let rename = str_param(p, "rename").map(str::to_string);
let based = p.get("basedOn").cloned();
@@ -491,7 +499,7 @@ fn edit_para(s: &mut Session, p: &Value) -> Result<Value> {
}
// A rule object changes only the rule fields it names, over the style's resolved rule.
let (current, _) = d.styles.resolve_para_style(&name);
let para: ParaAttrs = attrs(p.get("para"), |a: &mut ParaAttrs, k, v| a.set_json_over(k, v, &current))?;
let para: ParaAttrs = attrs("style.paragraph.edit", "para", p, |a: &mut ParaAttrs, k, v| a.set_json_over(k, v, &current))?;
let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?;
st.para.merge(&para);
st.chars.merge(&chars);
@@ -510,16 +518,37 @@ fn edit_para(s: &mut Session, p: &Value) -> Result<Value> {
/// Rename a paragraph or character style everywhere it's used.
fn rename_style(d: &mut designcraft_doc::Document, para: bool, from: &str, to: &str) {
let st = d.styles_mut();
if para {
for s in &mut st.paragraph {
for s in &mut d.styles_mut().paragraph {
if s.name == from {
s.name = to.to_string();
}
for r in [&mut s.based_on, &mut s.next_style] {
if r.as_deref() == Some(from) {
*r = Some(to.to_string());
}
}
} else {
for s in &mut d.styles_mut().character {
if s.name == from {
s.name = to.to_string();
}
}
}
redirect_style_refs(d, para, from, to);
}
/// Point every reference to style `from` at `to`, except the style's own registry entry (its
/// `name` field) — shared by renaming (which also updates the entry's name) and deletion (which
/// instead removes the entry, after its former uses point at the replacement).
fn redirect_style_refs(d: &mut designcraft_doc::Document, para: bool, from: &str, to: &str) {
let st = d.styles_mut();
// A style is never based on itself: when `to` was based on `from`, it takes `from`'s parent.
let parent_of = |based_on: Option<&String>| based_on.filter(|b| b.as_str() != to).cloned();
if para {
let from_parent = parent_of(st.paragraph.iter().find(|x| x.name == from).and_then(|x| x.based_on.as_ref()));
for s in &mut st.paragraph {
if s.based_on.as_deref() == Some(from) {
s.based_on = if s.name == to { from_parent.clone() } else { Some(to.to_string()) };
}
if s.next_style.as_deref() == Some(from) {
s.next_style = Some(to.to_string());
}
}
for o in &mut st.object {
@@ -528,12 +557,10 @@ fn rename_style(d: &mut designcraft_doc::Document, para: bool, from: &str, to: &
}
}
} else {
let from_parent = parent_of(st.character.iter().find(|x| x.name == from).and_then(|x| x.based_on.as_ref()));
for s in &mut st.character {
if s.name == from {
s.name = to.to_string();
}
if s.based_on.as_deref() == Some(from) {
s.based_on = Some(to.to_string());
s.based_on = if s.name == to { from_parent.clone() } else { Some(to.to_string()) };
}
}
for s in &mut st.paragraph {
@@ -563,7 +590,8 @@ fn rename_style(d: &mut designcraft_doc::Document, para: bool, from: &str, to: &
}
}
/// Point the nested, nested line and GREP styles of paragraph attributes at a renamed character style.
/// Point the nested, nested line and GREP styles, and the drop cap style, of paragraph
/// attributes at a renamed (or deleted, pointed at its replacement) character style.
fn rename_char_refs(p: &mut ParaAttrs, from: &str, to: &str) {
let names = p.nested_styles.iter_mut().flatten().map(|n| &mut n.style);
let names = names.chain(p.nested_line_styles.iter_mut().flatten().map(|n| &mut n.style));
@@ -572,6 +600,9 @@ fn rename_char_refs(p: &mut ParaAttrs, from: &str, to: &str) {
*n = to.to_string();
}
}
if p.drop_cap_style.as_deref() == Some(from) {
p.drop_cap_style = Some(to.to_string());
}
}
fn group_styles(s: &mut Session, p: &Value) -> Result<Value> {
@@ -609,29 +640,30 @@ fn group_styles(s: &mut Session, p: &Value) -> Result<Value> {
}
fn delete_para(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").unwrap_or("").to_string();
let name = str_param(p, "name").filter(|n| !n.is_empty()).ok_or_else(|| bad("style.paragraph.delete", "missing name"))?.to_string();
let repl = str_param(p, "replaceWith").unwrap_or(designcraft_doc::BASIC_PARAGRAPH).to_string();
if name.starts_with('[') {
return Err(bad("style.paragraph.delete", "built-in styles can't be deleted"));
}
if repl == name {
return Err(bad("style.paragraph.delete", "`replaceWith` can't be the deleted style"));
}
s.edit(|d, _| {
d.styles_mut().paragraph.retain(|x| x.name != name);
for sid in d.stories.keys().copied().collect::<Vec<_>>() {
if let Some(story) = d.story_mut(sid) {
for f in &mut story.paras {
if f.style == name {
f.style = repl.clone();
}
}
}
if d.styles.para(&name).is_none() {
return Err(bad("style.paragraph.delete", format!("no paragraph style `{name}`")));
}
if d.styles.para(&repl).is_none() {
return Err(bad("style.paragraph.delete", format!("no paragraph style `{repl}`")));
}
redirect_style_refs(d, true, &name, &repl);
d.styles_mut().paragraph.retain(|x| x.name != name);
ok()
})
}
fn create_char(s: &mut Session, p: &Value) -> Result<Value> {
let base = str_param(p, "name").unwrap_or("Character Style 1").to_string();
let mut chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
let mut chars: CharAttrs = attrs("style.character.create", "chars", p, |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
// Validate caller-supplied fills; captured formatting may retain a deleted swatch name.
super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.character.create")?;
if p.get("fromSelection").and_then(Value::as_bool).unwrap_or(false)
@@ -646,7 +678,7 @@ fn create_char(s: &mut Session, p: &Value) -> Result<Value> {
// Replay the explicit fields so null clears a captured value rather than looking absent.
if let Some(overrides) = p.get("chars").and_then(Value::as_object) {
for (key, value) in overrides {
captured.set_json(key, value).map_err(|e| bad("style", e))?;
captured.set_json(key, value).map_err(|e| bad("style.character.create", format!("chars: {e}")))?;
}
}
chars = captured;
@@ -670,7 +702,7 @@ fn edit_char(s: &mut Session, p: &Value) -> Result<Value> {
if name == designcraft_doc::NO_CHAR_STYLE {
return Err(bad("style.character.edit", "[None] can't be edited"));
}
let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
let chars: CharAttrs = attrs("style.character.edit", "chars", p, |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.character.edit")?;
// `null` unsets an attribute (the style no longer sets it).
let unset: Vec<String> = p
@@ -716,6 +748,28 @@ fn edit_char(s: &mut Session, p: &Value) -> Result<Value> {
})
}
fn delete_char(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").filter(|n| !n.is_empty()).ok_or_else(|| bad("style.character.delete", "missing name"))?.to_string();
let repl = str_param(p, "replaceWith").unwrap_or(designcraft_doc::NO_CHAR_STYLE).to_string();
if name.starts_with('[') {
return Err(bad("style.character.delete", "built-in styles can't be deleted"));
}
if repl == name {
return Err(bad("style.character.delete", "`replaceWith` can't be the deleted style"));
}
s.edit(|d, _| {
if d.styles.char_style(&name).is_none() {
return Err(bad("style.character.delete", format!("no character style `{name}`")));
}
if d.styles.char_style(&repl).is_none() {
return Err(bad("style.character.delete", format!("no character style `{repl}`")));
}
redirect_style_refs(d, false, &name, &repl);
d.styles_mut().character.retain(|x| x.name != name);
ok()
})
}
fn create_swatch(s: &mut Session, p: &Value) -> Result<Value> {
let c = p.get("color").ok_or_else(|| bad("swatch.create", "missing color"))?;
let color = parse_color(c).ok_or_else(|| bad("swatch.create", "bad color"))?;
@@ -747,18 +801,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 +892,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 +915,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 +933,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)})),
@@ -921,13 +979,18 @@ fn apply_gradient(s: &mut Session, p: &Value) -> Result<Value> {
};
let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false);
let angle = p.get("angle").and_then(Value::as_f64);
let stroke = match str_param(p, "target") {
None | Some("fill") => false,
Some("stroke") => true,
Some(t) => return Err(bad("object.gradient", format!("unknown target {t}"))),
};
let (from, to) = (super::point_param(p, "from"), super::point_param(p, "to"));
let ids = super::targets(s, p)?;
s.edit(|d, _| {
let mut out = Value::Null;
for id in &ids {
let Some(it) = d.item(*id) else { continue };
let cur_name = it.fill.swatch.clone();
let cur_name = if stroke { it.stroke.swatch.clone() } else { it.fill.swatch.clone() };
let cur = designcraft_color::swatch::resolve_gradient(&d.swatches, &cur_name).cloned();
let mut g = cur.clone().unwrap_or_default();
if let Some(k) = kind {
@@ -959,16 +1022,22 @@ fn apply_gradient(s: &mut Session, p: &Value) -> Result<Value> {
};
let inv = d.find(*id).map(|l| d.parent_xf(&l)).zip(d.item(*id).map(|i| i.xf)).map(|(pxf, xf)| (pxf * xf).inverse());
let Some(it) = d.item_mut(*id) else { continue };
it.fill.swatch = name.clone();
it.fill.tint = 1.0;
// The fill's or the stroke's paint: swatch, tint, angle, vector.
let (swatch, tint, g_angle, g_vector) = if stroke {
(&mut it.stroke.swatch, &mut it.stroke.tint, &mut it.stroke.gradient_angle, &mut it.stroke.gradient_vector)
} else {
(&mut it.fill.swatch, &mut it.fill.tint, &mut it.fill.gradient_angle, &mut it.fill.gradient_vector)
};
*swatch = name.clone();
*tint = 1.0;
if let Some(a) = angle {
it.fill.gradient_angle = Some(a);
it.fill.gradient_vector = None;
*g_angle = Some(a);
*g_vector = None;
}
if let (Some(a), Some(b), Some(inv)) = (from, to, inv) {
let (a, b) = (inv * a, inv * b);
if (b - a).hypot() > 1e-6 {
it.fill.gradient_vector = Some([a.x, a.y, b.x, b.y]);
*g_vector = Some([a.x, a.y, b.x, b.y]);
}
}
if out.is_null() {
@@ -1257,6 +1326,48 @@ fn rename_in(it: &mut designcraft_doc::Item, from: &str, to: &str) {
}
}
/// #777: the params descriptions name the attributes, and a wrong one is the command's error.
#[cfg(test)]
mod attribute_error_tests {
use super::*;
use crate::cmd::find_command;
#[test]
fn a_wrong_attribute_names_the_command_and_the_right_attribute() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let err = |s: &mut Session, id: &str, p: Value| s.execute(id, &p).unwrap_err().to_string();
assert_eq!(
err(&mut s, "style.paragraph.create", json!({"name": "H", "chars": {"font": "Source Sans 3"}})),
"invalid parameters for `style.paragraph.create`: chars: unknown attribute `font`; did you mean `fontFamily`?"
);
assert_eq!(
err(&mut s, "style.paragraph.create", json!({"name": "H", "para": {"spaceBefor": 6}})),
"invalid parameters for `style.paragraph.create`: para: unknown attribute `spaceBefor`; did you mean `spaceBefore`?"
);
assert!(
err(&mut s, "style.character.create", json!({"name": "C", "chars": {"font": "X"}}))
.starts_with("invalid parameters for `style.character.create`: chars:")
);
assert!(err(&mut s, "style.paragraph.create", json!({"name": "H", "chars": "bold"})).contains("chars must be an object of attributes"));
s.execute("style.paragraph.create", &json!({"name": "H", "chars": {"fontFamily": "Source Sans 3", "size": 14}})).unwrap();
assert!(
err(&mut s, "style.paragraph.edit", json!({"name": "H", "chars": {"font": "X"}}))
.starts_with("invalid parameters for `style.paragraph.edit`: chars:")
);
s.execute("style.character.create", &json!({"name": "C", "chars": {"fontStyle": "Bold"}})).unwrap();
assert!(
err(&mut s, "style.character.edit", json!({"name": "C", "chars": {"font": "X"}}))
.starts_with("invalid parameters for `style.character.edit`: chars:")
);
for id in ["style.paragraph.create", "style.paragraph.edit", "style.character.create", "style.character.edit"] {
let doc = find_command(id).unwrap().params;
assert!(doc.contains("as type.char names them: fontFamily"), "{id}: {doc}");
}
assert!(find_command("style.paragraph.create").unwrap().params.contains("as type.para names them: align"));
}
}
#[cfg(test)]
mod color_tests {
use super::*;
@@ -1324,17 +1435,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", &params).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]
@@ -1415,14 +1547,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();
@@ -1526,6 +1687,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();
@@ -1843,6 +2037,161 @@ 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)]
mod gradient_stroke_tests {
use serde_json::json;
/// Grey along a 20 pt line from (20, 60) to (180, 60): near its start, middle and end.
fn along(img: &designcraft_render::Rendered) -> (u8, u8, u8) {
(img.pixel(26, 60)[0], img.pixel(100, 60)[0], img.pixel(174, 60)[0])
}
#[test]
fn a_gradient_on_a_stroke_runs_across_the_path_on_screen_and_in_pdf() {
let mut s = crate::Session::new();
s.execute("file.new", &json!({"width": 200, "height": 120, "facingPages": false})).unwrap();
let id = s.execute("line.create", &json!({"a": [20, 60], "b": [180, 60]})).unwrap()["id"].as_u64().unwrap();
s.execute("object.stroke", &json!({"ids": [id], "weight": 20})).unwrap();
let r = s
.execute(
"object.gradient",
&json!({"ids": [id], "target": "stroke", "stops": [{"location": 0, "color": "#000000"}, {"location": 100, "color": "#ffffff"}]}),
)
.unwrap();
let d = s.doc().unwrap().doc.clone();
let it = d.item(designcraft_doc::ItemId(id)).unwrap();
assert_eq!(it.stroke.swatch, r["swatch"].as_str().unwrap());
assert_eq!(it.fill.swatch, designcraft_color::swatch::NONE, "the fill is left alone");
let mut rr = designcraft_render::Renderer::new();
let (a, m, b) = along(&rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap());
assert!(a < 40 && (90..170).contains(&m) && b > 215, "screen: {a} {m} {b}");
// The PDF, placed back and drawn: the same.
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": 120, "facingPages": false})).unwrap();
p.execute("file.place", &json!({"base64": crate::cmd::file::base64_encode(&pdf), "name": "stroke.pdf", "x": 0, "y": 0, "width": 200}))
.unwrap();
let pd = p.doc().unwrap().doc.clone();
let (a, m, b) = along(&rr.render_page(&pd, &p.cache, 0, 1.0, false, &Default::default()).unwrap());
assert!(a < 40 && (90..170).contains(&m) && b > 215, "pdf: {a} {m} {b}");
// The Gradient Swatch tool's drag on the stroke: reversed by dragging right to left.
s.execute("object.gradient", &json!({"ids": [id], "target": "stroke", "from": [180, 60], "to": [20, 60]})).unwrap();
let d = s.doc().unwrap().doc.clone();
let (a, _, b) = along(&rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap());
assert!(a > 215 && b < 40, "dragged: {a} {b}");
assert!(s.execute("object.gradient", &json!({"ids": [id], "target": "gap"})).is_err());
}
/// A 20 pt horizontal line with a gradient on its stroke, drawn on screen and from its PDF.
fn line_with_stroke_gradient(g: serde_json::Value) -> (designcraft_render::Rendered, designcraft_render::Rendered) {
let mut s = crate::Session::new();
s.execute("file.new", &json!({"width": 200, "height": 120, "facingPages": false})).unwrap();
let id = s.execute("line.create", &json!({"a": [20, 60], "b": [180, 60]})).unwrap()["id"].as_u64().unwrap();
s.execute("object.stroke", &json!({"ids": [id], "weight": 20})).unwrap();
let mut g = g;
g["ids"] = json!([id]);
g["target"] = json!("stroke");
s.execute("object.gradient", &g).unwrap();
let d = s.doc().unwrap().doc.clone();
let mut rr = designcraft_render::Renderer::new();
let screen = rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap();
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": 120, "facingPages": false})).unwrap();
p.execute("file.place", &json!({"base64": crate::cmd::file::base64_encode(&pdf), "name": "line.pdf", "x": 0, "y": 0, "width": 200})).unwrap();
let pd = p.doc().unwrap().doc.clone();
let placed = rr.render_page(&pd, &p.cache, 0, 1.0, false, &Default::default()).unwrap();
(screen, placed)
}
#[test]
fn a_radial_gradient_on_a_straight_lines_stroke_shows_all_its_stops() {
// The line's box has no height: the gradient is a circle across the line's length.
let (screen, pdf) = line_with_stroke_gradient(
json!({"kind": "radial", "stops": [{"location": 0, "color": "#000000"}, {"location": 100, "color": "#ffffff"}]}),
);
for (what, img) in [("screen", &screen), ("pdf", &pdf)] {
let (a, m, b) = along(img);
assert!(m < 40 && a > 180 && b > 180, "{what}: {a} {m} {b}");
}
}
#[test]
fn a_linear_gradient_across_a_straight_line_paints_its_stroke_alike_on_screen_and_in_pdf() {
let (screen, pdf) =
line_with_stroke_gradient(json!({"angle": 90, "stops": [{"location": 0, "color": "#ff0000"}, {"location": 100, "color": "#0000ff"}]}));
let (s, p) = (screen.pixel(100, 60), pdf.pixel(100, 60));
// The line's box has no height: the gradient runs across the line's length, so the stroke
// shows its middle.
assert!(s[1] < 60 && s[0] > 80 && s[2] > 80, "screen: the stroke is painted, {s:?}");
assert!((0..3).all(|i| s[i].abs_diff(p[i]) < 40), "screen {s:?}, pdf {p:?}");
}
}
#[cfg(test)]
+505 -18
View File
@@ -55,6 +55,18 @@ pub fn specs() -> Vec<CommandSpec> {
ok()
}),
cmd!("text.insert", "Type", [], None, "{text, raw?: bool (no typographer's quotes)} — replaces the selected text", has_text, insert),
// Neither journalled nor undoable: what is composed is a preview until it's typed.
CommandSpec {
id: "text.compose",
label: "Compose Text",
menu: &[],
shortcut: None,
params: "{text} — input-method text being composed (a dead key's accent), shown at the caret in the text's format until text.insert types the result; \"\" drops it",
enabled: has_doc,
run: compose_text,
journal: false,
undoable: false,
},
cmd!("text.delete", "Delete Text", [], None, "{forward?: bool, word?: bool}", has_text, delete),
cmd!(noundo "text.move", "Move Caret", [], None, "{dir: left|right|up|down|lineStart|lineEnd|storyStart|storyEnd, extend?, word?}", has_text, move_caret),
cmd!(noundo "text.exitToFrame", "Select Frame", [], None, "{}", has_doc, |s, _| {
@@ -148,7 +160,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Paragraph Formatting",
[],
None,
"{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, dropCapChars?, dropCapStyle?, dropCapAlignLeft?, dropCapScaleDescenders?, hyphenate?, composer?, tabs?, ruleAbove?/ruleBelow?: {on?, weight?, color?, tint?, columnWidth?, offset?, leftIndent?, rightIndent?} (only the named rule fields change), …}}",
"{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, dropCapChars?, dropCapStyle?, dropCapAlignLeft?, dropCapScaleDescenders?, hyphenate?: bool (default true), composer?, tabs?, ruleAbove?/ruleBelow?: {on?, weight?, color?, tint?, columnWidth?, offset?, leftIndent?, rightIndent?} (only the named rule fields change), …}}",
has_text_or_frames,
|s, p| format_paras(s, p.get("attrs").unwrap_or(p))
),
@@ -292,6 +304,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
),
]
}
@@ -365,7 +387,8 @@ fn select_at(s: &mut Session, p: &Value, unit: &str) -> Result<Value> {
s.typing_format = None;
let st = s.doc_mut()?;
st.selection = Selection::text(TextSel { story: sid, anchor: a, focus: e, frame: Some(frame), cell });
st.revision += 1;
// Explicit reselection invalidates the Find scope even when the range is unchanged.
st.revision = st.revision.wrapping_add(1);
Ok(json!({"story": sid.0, "anchor": a, "focus": e}))
}
@@ -419,8 +442,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"}))?;
}
@@ -492,6 +520,30 @@ fn release(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// `text.compose`: show input-method text at the caret, typed in the format the next
/// character would get, without making it part of the story. Replaces what was composed before.
fn compose_text(s: &mut Session, p: &Value) -> Result<Value> {
let text = super::text_param(p, "text");
s.end_composition();
if text.is_empty() {
return ok();
}
has_text(s).map_err(|e| bad("text.compose", &e))?;
// The preview types the text; the format chosen at the caret is still for the real typing.
let typing = s.typing_format.clone();
s.begin_interaction("Type")?;
let r = s.preview("text.insert", json!({"text": text, "raw": true}));
s.typing_format = typing;
if let Err(e) = r {
s.cancel_interaction();
return Err(e);
}
let uid = s.doc()?.uid;
s.composition = Some((uid, text.len()));
let pos = s.doc()?.selection.text.map_or(0, |t| t.focus);
Ok(json!({"range": [pos.saturating_sub(text.len()), pos]}))
}
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
let text = super::text_param(p, "text");
// Tab in a table cell moves to the next cell (Shift-Tab: `table.prevCell`).
@@ -1030,6 +1082,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() {
@@ -1040,11 +1100,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![] });
@@ -1066,12 +1128,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;
}
@@ -1378,6 +1449,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(&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. \
@@ -1397,20 +1587,30 @@ fn fill_placeholder(s: &mut Session, p: &Value) -> Result<Value> {
story_of(s, p).ok_or_else(|| bad("type.fillWithPlaceholder", "select a text frame"))?
}
};
// Fill until overset: repeat the filler sentences.
// Fill until overset with paragraphs of two to five sentences, as InDesign's placeholder text
// comes (so a paragraph can be styled on its own).
const SENTENCES_PER_PARAGRAPH: [usize; 7] = [2, 2, 3, 3, 5, 2, 4];
let mut text = String::new();
let sentences: Vec<&str> = PLACEHOLDER.split_inclusive(". ").collect();
let sentences: Vec<&str> = PLACEHOLDER.split_inclusive(". ").map(str::trim_end).collect();
let doc = s.doc()?.doc.clone();
let mut d2 = (*doc).clone();
let (mut paragraph, mut in_paragraph) = (0, 0);
for i in 0..400 {
text.push_str(sentences[i % sentences.len()]);
in_paragraph += 1;
if in_paragraph >= SENTENCES_PER_PARAGRAPH[paragraph % SENTENCES_PER_PARAGRAPH.len()] {
text.push('\n');
(paragraph, in_paragraph) = (paragraph + 1, 0);
} else {
text.push(' ');
}
if i % 3 == 2 {
d2.set_story_text(sid, &text)?;
let cs = compose::compose_story(&d2, sid, &compose::ComposeOptions::default());
if cs.is_overset() {
// Trim to the last sentence that fits.
let cut = cs.overset_at.unwrap_or(text.len());
let keep = text[..cut.min(text.len())].rfind(". ").map(|k| k + 1).unwrap_or(cut);
let cut = cs.overset_at.unwrap_or(text.len()).min(text.len());
let keep = text.get(..cut).and_then(|t| t.rfind('.')).map(|k| k + 1).unwrap_or(cut);
text.truncate(keep);
break;
}
@@ -1435,19 +1635,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)
}
@@ -1638,6 +1840,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)]
@@ -2558,3 +2786,262 @@ 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}");
}
}
#[cfg(test)]
mod gradient_text_tests {
use serde_json::json;
/// Mean red and blue of the ink (pixels whose green is low: red → blue has none) in columns
/// `x0..x1`.
fn ink(img: &designcraft_render::Rendered, x0: u32, x1: u32) -> (f64, f64) {
let (mut r, mut b, mut n) = (0.0, 0.0, 0.0);
for x in x0..x1 {
for y in 0..img.height {
let p = img.pixel(x, y);
if p[1] < 100 {
(r, b, n) = (r + p[0] as f64, b + p[2] as f64, n + 1.0);
}
}
}
assert!(n > 50.0, "no ink in {x0}..{x1}");
(r / n, b / n)
}
/// Red ink at the frame's left, blue at its right.
fn check(img: &designcraft_render::Rendered, what: &str) {
let (lr, lb) = ink(img, 0, 60);
let (rr, rb) = ink(img, 200, 300);
assert!(lr > lb + 80.0, "{what}: the left ink is red ({lr:.0}, {lb:.0})");
assert!(rb > rr + 80.0, "{what}: the right ink is blue ({rr:.0}, {rb:.0})");
}
#[test]
fn a_gradient_on_text_spans_its_frame_on_screen_and_in_pdf() {
let mut s = crate::Session::new();
s.execute("file.new", &json!({"width": 300, "height": 120, "facingPages": false, "margins": 0})).unwrap();
// A gradient swatch (an off-page object's unnamed one), red → blue.
let shape = s.execute("frame.create", &json!({"rect": [-60, 0, -50, 10], "content": "unassigned"})).unwrap()["id"].clone();
s.execute("object.gradient", &json!({"ids": [shape], "stops": [{"location": 0, "color": "#ff0000"}, {"location": 100, "color": "#0000ff"}]}))
.unwrap();
let id = designcraft_doc::ItemId(shape.as_u64().unwrap());
let swatch = s.doc().unwrap().doc.item(id).unwrap().fill.swatch.clone();
let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 120], "content": "text", "text": "IIIIIIIIIIIIIIIIIIIIIIIIIIIIII"})).unwrap();
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 30})).unwrap();
s.execute("type.char", &json!({"size": 60, "tracking": 0, "fill": swatch})).unwrap();
let d = s.doc().unwrap().doc.clone();
let mut rr = designcraft_render::Renderer::new();
check(&rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap(), "screen");
// The PDF, placed back and drawn: the same gradient.
let pdf = designcraft_pdf::export_pdf(&d, &s.cache, &Default::default()).unwrap();
let mut p = crate::Session::new();
p.execute("file.new", &json!({"width": 300, "height": 120, "facingPages": false})).unwrap();
p.execute("file.place", &json!({"base64": super::super::file::base64_encode(&pdf), "name": "text.pdf", "x": 0, "y": 0, "width": 300}))
.unwrap();
let d = p.doc().unwrap().doc.clone();
check(&rr.render_page(&d, &p.cache, 0, 1.0, false, &Default::default()).unwrap(), "pdf");
}
/// The columns the red → blue ink spans (black table edges left out).
fn ink_span(img: &designcraft_render::Rendered) -> (u32, u32) {
let cols: Vec<u32> = (0..img.width)
.filter(|&x| {
(0..img.height).any(|y| {
let p = img.pixel(x, y);
p[1] < 100 && p[0].max(p[2]) > 100
})
})
.collect();
(*cols.first().unwrap(), *cols.last().unwrap())
}
/// Mean red and blue of the red → blue ink in columns `x0..x1`.
fn coloured_ink(img: &designcraft_render::Rendered, x0: u32, x1: u32) -> (f64, f64) {
let (mut r, mut b, mut n) = (0.0, 0.0, 0.0);
for x in x0..x1 {
for y in 0..img.height {
let p = img.pixel(x, y);
if p[1] < 100 && p[0].max(p[2]) > 100 {
(r, b, n) = (r + p[0] as f64, b + p[2] as f64, n + 1.0);
}
}
}
assert!(n > 20.0, "no ink in {x0}..{x1}");
(r / n, b / n)
}
#[test]
fn a_gradient_on_table_cell_text_spans_the_cells_text_in_pdf_as_on_screen() {
let mut s = crate::Session::new();
s.execute("file.new", &json!({"width": 300, "height": 120, "facingPages": false, "margins": 0})).unwrap();
// A gradient swatch (an off-page object's unnamed one), red → blue.
let shape = s.execute("frame.create", &json!({"rect": [-60, 0, -50, 10], "content": "unassigned"})).unwrap()["id"].clone();
s.execute("object.gradient", &json!({"ids": [shape], "stops": [{"location": 0, "color": "#ff0000"}, {"location": 100, "color": "#0000ff"}]}))
.unwrap();
let g = s.doc().unwrap().doc.item(designcraft_doc::ItemId(shape.as_u64().unwrap())).unwrap().fill.swatch.clone();
let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 120], "content": "text", "text": ""})).unwrap();
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap();
s.execute("table.insert", &json!({"rows": 1, "cols": 3})).unwrap();
// A third of the frame's width: the cell's text, red → blue.
let mut d: designcraft_doc::Document = (*s.doc().unwrap().doc).clone();
let sid = d.stories.values().find(|st| !st.tables.is_empty()).unwrap().id;
let t = std::sync::Arc::make_mut(d.story_mut(sid).unwrap().tables.values_mut().next().unwrap());
let text = &mut t.cell_mut(0, 0).unwrap().text;
text.insert(0, "HHHH");
text.format_chars(0..4, |f| {
f.over.fill = Some(g.clone());
f.over.size = Some(24.0);
});
let mut rr = designcraft_render::Renderer::new();
let cache = designcraft_compose::Cache::new();
let screen = rr.render_page(&d, &cache, 0, 1.0, false, &Default::default()).unwrap();
let pdf = designcraft_pdf::export_pdf(&d, &cache, &Default::default()).unwrap();
let mut p = crate::Session::new();
p.execute("file.new", &json!({"width": 300, "height": 120, "facingPages": false})).unwrap();
p.execute("file.place", &json!({"base64": super::super::file::base64_encode(&pdf), "name": "cell.pdf", "x": 0, "y": 0, "width": 300}))
.unwrap();
let placed = rr.render_page(&p.doc().unwrap().doc.clone(), &p.cache, 0, 1.0, false, &Default::default()).unwrap();
let (x0, x1) = ink_span(&screen);
assert!(x1 < 120, "the text stays in the first column: {x0}..{x1}");
let q = (x1 - x0) / 4;
for (what, img) in [("screen", &screen), ("pdf", &placed)] {
let (lr, lb) = coloured_ink(img, x0, x0 + q);
let (rr, rb) = coloured_ink(img, x1 + 1 - q, x1 + 1);
assert!(lr > lb + 50.0, "{what}: the left ink is red ({lr:.0}, {lb:.0})");
assert!(rb > rr + 50.0, "{what}: the right ink is blue ({rr:.0}, {rb:.0})");
}
}
}
#[cfg(test)]
mod compose_tests {
use serde_json::json;
use crate::Session;
/// A story "Ta" with the caret after "T" and 30 pt chosen for the next typing.
fn session() -> (Session, u64) {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": "Ta"})).unwrap();
let sid = r["story"].as_u64().unwrap();
s.set_tool("type");
s.execute("text.select", &json!({"story": sid, "anchor": 1})).unwrap();
s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap();
(s, sid)
}
fn text(s: &Session, sid: u64) -> String {
s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text.clone()
}
/// ⌥E shows the accent in the story, in the format it will be typed in, then I types "í":
/// one undo step, and the accent was never part of the document.
#[test]
fn composed_text_shows_in_the_typing_format_until_it_is_typed() {
let (mut s, sid) = session();
let r = s.execute("text.compose", &json!({"text": "´"})).unwrap();
assert_eq!(r["range"], json!([1, 3]));
assert_eq!(text(&s, sid), "T´a");
let size_at = |s: &Session, b| s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().char_format_at(b).over.size;
assert_eq!(size_at(&s, 2), Some(30.0), "the accent is shown in the format chosen at the caret");
assert_eq!(s.composition().map(|c| c.2), Some(1..3));
s.execute("text.compose", &json!({"text": "¨"})).unwrap();
assert_eq!(text(&s, sid), "T¨a", "a new composition replaces the last one");
s.execute("text.compose", &json!({"text": ""})).unwrap();
assert_eq!(text(&s, sid), "Ta");
assert!(s.composition().is_none());
s.execute("text.insert", &json!({"text": "í"})).unwrap();
assert_eq!(text(&s, sid), "Tía");
assert_eq!(size_at(&s, 2), Some(30.0), "the typing format survived the composition");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(text(&s, sid), "Ta");
}
/// Anything else that edits, a tool change or a drag drops the uncommitted accent.
#[test]
fn other_edits_drop_the_composed_text() {
for act in 0..3 {
let (mut s, sid) = session();
s.execute("text.compose", &json!({"text": "´"})).unwrap();
match act {
0 => {
s.execute("text.move", &json!({"dir": "right"})).unwrap();
}
1 => s.set_tool("selection"),
_ => s.begin_interaction("Move").unwrap(),
}
assert_eq!(text(&s, sid), "Ta", "case {act}");
assert!(s.composition().is_none());
}
}
}
#[cfg(test)]
mod placeholder_tests {
use serde_json::json;
use crate::Session;
/// Fill with Placeholder Text gives paragraphs of a few sentences, as InDesign's does (it was one
/// paragraph, and the filler ran sentences together: "interit.Ovid").
#[test]
fn placeholder_text_comes_in_paragraphs() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [36, 36, 336, 736], "content": "text", "text": ""})).unwrap();
s.execute("type.fillWithPlaceholder", &json!({"frame": r["id"]})).unwrap();
let st = s.execute("story.get", &json!({"story": r["story"]})).unwrap();
let text = st["text"].as_str().unwrap();
let paragraphs: Vec<&str> = text.split('\n').collect();
assert!(paragraphs.len() >= 4, "{} paragraphs", paragraphs.len());
for p in &paragraphs {
assert!(p.ends_with('.') && !p.starts_with(' '), "a paragraph of whole sentences: {p:?}");
}
assert!(!text.contains(".O") && !text.contains(".L"), "sentences are spaced: {text}");
assert!(st["overset"].is_null(), "nothing overset: {}", st["overset"]);
assert!(text.len() > 1500, "the frame is filled: {}", text.len());
}
}
+199 -21
View File
@@ -1,9 +1,9 @@
//! Layout → Table of Contents: list the paragraphs of chosen styles with their page names, as a
//! story with "TOC Title" / "TOC Level n" styles (right tab with dot leader). Update Table of
//! Contents regenerates the same story after edits.
//! story with "TOC Title" / "TOC Level n" styles (right tab with dot leader) or the entry styles
//! chosen per style. Update Table of Contents regenerates the same story after edits.
use designcraft_compose::{ComposeOptions, caret, compose_story};
use designcraft_doc::{Align, Document, ParaAttrs, ParaFormat, ParagraphStyle, SpreadRef, StoryId, TabAlign, TabStop, Toc, TocEntry};
use designcraft_doc::{Align, Document, ParaAttrs, ParaFormat, ParagraphStyle, SpreadRef, StoryId, TabAlign, TabStop, Toc, TocEntry, TocPageNumber};
use designcraft_geom::Rect;
use serde_json::{Value, json};
@@ -17,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Table of Contents…",
["Layout"],
None,
"{entries: [{style, level?: 1}] | styles: [names], title?: \"Contents\", pageNumbers?: true, page?: 1-based page for a new frame (its margins), rect?: [x0,y0,x1,y1] (spread coords)} → {story, entries}",
"{entries: [{style, level?: 1 (1-9), entryStyle?: paragraph style of its entries (default \"TOC Level n\"), pageNumber?: \"after\"|\"before\"|\"none\", separator?: text between entry and number (default a tab)}] | styles: [names] (levels 1, 2, …), title?: \"Contents\", titleStyle?: paragraph style of the title (default \"TOC Title\"), pageNumbers?: true, replace?: true (regenerate the existing table of contents in place; false makes a new frame), page?: 1-based page for a new frame (its margins), rect?: [x0,y0,x1,y1] (spread coords)} → {story, entries}",
has_doc,
generate
),
@@ -44,20 +44,66 @@ fn has_toc(s: &Session) -> std::result::Result<(), String> {
}
}
const MAX_ENTRIES: usize = 64;
const MAX_NAME: usize = 256;
const MAX_TITLE: usize = 1024;
const MAX_SEPARATOR: usize = 64;
fn entries_param(p: &Value) -> Result<Vec<TocEntry>> {
if let Some(v) = p.get("entries") {
return serde_json::from_value(v.clone()).map_err(|e| bad("toc.generate", e.to_string()));
let mut entries: Vec<TocEntry> = if let Some(v) = p.get("entries") {
if v.as_array().is_some_and(|a| a.len() > MAX_ENTRIES) {
return Err(bad("toc.generate", format!("at most {MAX_ENTRIES} entries")));
}
serde_json::from_value(v.clone()).map_err(|e| bad("toc.generate", e.to_string()))?
} else if let Some(a) = p.get("styles").and_then(Value::as_array) {
if a.len() > MAX_ENTRIES {
return Err(bad("toc.generate", format!("at most {MAX_ENTRIES} styles")));
}
a.iter().enumerate().filter_map(|(i, v)| v.as_str().map(|s| TocEntry::new(s, (i + 1).min(9) as u8))).collect()
} else {
return Err(bad("toc.generate", "give `entries` or `styles`"));
};
for e in &mut entries {
if !(1..=9).contains(&e.level) {
return Err(bad("toc.generate", format!("level {} of `{}` is not 1–9", e.level, e.style)));
}
if e.style.chars().count() > MAX_NAME || e.entry_style.as_ref().is_some_and(|n| n.chars().count() > MAX_NAME) {
return Err(bad("toc.generate", format!("style names are at most {MAX_NAME} characters")));
}
if let Some(sep) = &mut e.separator {
if sep.chars().count() > MAX_SEPARATOR {
return Err(bad("toc.generate", format!("the separator is at most {MAX_SEPARATOR} characters")));
}
*sep = one_line(sep);
}
// The defaults are stored as `None`, so a definition reads the same however it was made.
if e.entry_style.as_deref() == Some("") {
e.entry_style = None;
}
if e.separator.as_deref() == Some("\t") {
e.separator = None;
}
}
if let Some(a) = p.get("styles").and_then(Value::as_array) {
return Ok(a.iter().enumerate().filter_map(|(i, v)| v.as_str().map(|s| TocEntry { style: s.into(), level: (i + 1).min(9) as u8 })).collect());
Ok(entries)
}
/// Paragraph breaks in a title or separator would shift the paragraph styles after them.
fn one_line(s: &str) -> String {
s.chars().map(|c| if matches!(c, '\n' | '\r' | '\u{2029}') { ' ' } else { c }).collect()
}
fn para_style_exists(d: &Document, name: &str) -> Result<()> {
match d.styles.para(name) {
Some(_) => Ok(()),
None => Err(bad("toc.generate", format!("no paragraph style `{name}`"))),
}
Err(bad("toc.generate", "give `entries` or `styles`"))
}
pub struct Found {
pub level: u8,
pub text: String,
pub page: String,
entry: usize,
key: (usize, i64, i64),
}
@@ -70,7 +116,7 @@ pub fn collect(d: &Document, entries: &[TocEntry], skip: Option<StoryId>) -> Vec
}
let cs = compose_story(d, *sid, &ComposeOptions::default());
for (pi, r) in st.para_ranges().into_iter().enumerate() {
let Some(e) = entries.iter().find(|e| e.style == st.paras[pi].style) else { continue };
let Some((ei, e)) = entries.iter().enumerate().find(|(_, e)| e.style == st.paras[pi].style) else { continue };
let text: String = st.text[r.clone()]
.chars()
.filter(|c| !('\u{E000}'..='\u{E1FF}').contains(c))
@@ -95,6 +141,7 @@ pub fn collect(d: &Document, entries: &[TocEntry], skip: Option<StoryId>) -> Vec
out.push(Found {
level: e.level,
text,
entry: ei,
page: d.page_name(abs),
key: (abs, ((b.y0 + baseline) * 10.0) as i64, ((b.x0 + x) * 10.0) as i64),
});
@@ -146,22 +193,35 @@ fn fill(d: &mut Document, toc: &Toc, width: Option<f64>) -> Result<usize> {
d.story(toc.story).and_then(|st| st.frames.first()).and_then(|f| d.item(*f)).map(|i| i.text_area().width()).unwrap_or(300.0)
});
ensure_styles(d, levels, width);
for name in toc.entries.iter().filter_map(|e| e.entry_style.as_deref()).chain(toc.title_style.as_deref()) {
para_style_exists(d, name)?;
}
let mut text = String::new();
let mut styles = Vec::new();
if !toc.title.is_empty() {
text.push_str(&toc.title);
styles.push("TOC Title".to_string());
styles.push(toc.title_style.clone().unwrap_or_else(|| "TOC Title".into()));
}
for f in &found {
let Some(e) = toc.entries.get(f.entry) else { continue };
if !text.is_empty() {
text.push('\n');
}
text.push_str(&f.text);
if toc.page_numbers {
text.push('\t');
text.push_str(&f.page);
let sep = e.separator.as_deref().unwrap_or("\t");
match e.page_number {
TocPageNumber::After if toc.page_numbers => {
text.push_str(&f.text);
text.push_str(sep);
text.push_str(&f.page);
}
TocPageNumber::Before if toc.page_numbers => {
text.push_str(&f.page);
text.push_str(sep);
text.push_str(&f.text);
}
_ => text.push_str(&f.text),
}
styles.push(format!("TOC Level {}", f.level.max(1)));
styles.push(e.entry_style.clone().unwrap_or_else(|| format!("TOC Level {}", f.level.max(1))));
}
let st = d.story_mut(toc.story).ok_or(designcraft_doc::DocError::NoStory(toc.story))?;
let len = st.len();
@@ -179,13 +239,29 @@ fn generate(s: &mut Session, p: &Value) -> Result<Value> {
if entries.is_empty() {
return Err(bad("toc.generate", "no entries"));
}
let title = str_param(p, "title").unwrap_or("Contents").to_string();
let title = str_param(p, "title").unwrap_or("Contents");
if title.chars().count() > MAX_TITLE {
return Err(bad("toc.generate", format!("the title is at most {MAX_TITLE} characters")));
}
let title = one_line(title);
let title_style = str_param(p, "titleStyle").filter(|n| !n.is_empty()).map(str::to_string);
if title_style.as_ref().is_some_and(|n| n.chars().count() > MAX_NAME) {
return Err(bad("toc.generate", format!("style names are at most {MAX_NAME} characters")));
}
let page_numbers = p.get("pageNumbers").and_then(Value::as_bool).unwrap_or(true);
let replace = p.get("replace").and_then(Value::as_bool).unwrap_or(true);
let rect = super::rect_param(p, "rect");
let page = p.get("page").and_then(Value::as_u64).unwrap_or(1).max(1) as usize - 1;
if rect.is_some_and(|r| ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) || r.width() <= 0.0 || r.height() <= 0.0) {
return Err(bad("toc.generate", "`rect` must be finite with a positive size"));
}
let page = p.get("page").and_then(Value::as_u64).map_or(Some(0), |n| usize::try_from(n.max(1) - 1).ok());
let page = page.ok_or_else(|| bad("toc.generate", "no such page"))?;
s.edit(|d, sel| {
// Reuse the existing TOC story when there is one; otherwise make a frame.
let existing = d.toc.as_ref().map(|t| t.story).filter(|sid| d.story(*sid).is_some());
for e in &entries {
para_style_exists(d, &e.style)?;
}
// Replace: regenerate the existing TOC story in place; otherwise make a frame.
let existing = d.toc.as_ref().map(|t| t.story).filter(|sid| replace && d.story(*sid).is_some());
let (story, width) = match existing {
Some(sid) => (sid, None),
None => {
@@ -198,7 +274,7 @@ fn generate(s: &mut Session, p: &Value) -> Result<Value> {
(sid, w)
}
};
let toc = Toc { story, title: title.clone(), entries: entries.clone(), page_numbers };
let toc = Toc { story, title: title.clone(), entries: entries.clone(), page_numbers, title_style: title_style.clone() };
let n = fill(d, &toc, width)?;
d.toc = Some(toc);
Ok(json!({"story": story.0, "entries": n}))
@@ -237,4 +313,106 @@ mod tests {
assert!(text.contains("Thoughts on the Grid"), "{text}");
s.doc().unwrap().doc.check().unwrap();
}
fn sample_with_toc_page() -> (Session, usize) {
let mut s = Session::new();
s.execute("file.newSample", &json!({})).unwrap();
let pages = s.doc().unwrap().doc.page_count();
s.execute("layout.pages.insert", &json!({"count": 1})).unwrap();
(s, pages + 1)
}
fn toc_lines(s: &Session) -> Vec<(String, String)> {
let d = &s.doc().unwrap().doc;
let st = d.story(d.toc.as_ref().unwrap().story).unwrap();
st.para_ranges().into_iter().zip(&st.paras).map(|(r, f)| (st.text[r].trim_end_matches('\n').to_string(), f.style.clone())).collect()
}
#[test]
fn toc_entry_style_page_number_position_and_separator_survive_update() {
let (mut s, page) = sample_with_toc_page();
let basic = designcraft_doc::story::BASIC_PARAGRAPH;
s.execute(
"toc.generate",
&json!({"page": page, "title": "In This Issue", "titleStyle": basic, "entries": [
{"style": "Cover Title", "level": 1, "pageNumber": "before", "separator": " — "},
{"style": "Headline", "level": 2, "entryStyle": basic, "pageNumber": "none"},
]}),
)
.unwrap();
let check = |s: &Session, headline: &str| {
let lines = toc_lines(s);
assert_eq!(lines[0], ("In This Issue".to_string(), basic.to_string()));
let cover = lines.iter().find(|(t, _)| t.contains("The Quiet")).unwrap();
assert!(cover.0.starts_with("1 — The Quiet"), "{cover:?}");
assert_eq!(cover.1, "TOC Level 1");
let head = lines.iter().find(|(t, _)| t.contains("on the Grid")).unwrap();
assert!(head.0.starts_with(headline) && !head.0.contains('\t'), "{head:?}");
assert_eq!(head.1, basic);
};
check(&s, "Notes on the Grid");
let d = &s.doc().unwrap().doc;
let hid = d.stories.iter().find(|(_, st)| st.text.starts_with("Notes on the Grid")).map(|(id, _)| *id).unwrap();
s.execute("story.replaceRange", &json!({"story": hid.0, "start": 0, "end": 5, "text": "Thoughts"})).unwrap();
s.execute("toc.update", &json!({})).unwrap();
check(&s, "Thoughts on the Grid");
}
#[test]
fn replace_regenerates_the_toc_in_place_and_off_makes_a_new_frame() {
let (mut s, page) = sample_with_toc_page();
let stories = |s: &Session| s.doc().unwrap().doc.stories.len();
let first = s.execute("toc.generate", &json!({"styles": ["Headline"], "page": page})).unwrap()["story"].clone();
let n = stories(&s);
let undo = s.doc().unwrap().history.undo.len();
let r = s.execute("toc.generate", &json!({"styles": ["Headline"], "title": "Inside", "page": page, "replace": true})).unwrap();
assert_eq!(r["story"], first);
assert_eq!(stories(&s), n, "one table of contents");
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
assert_eq!(toc_lines(&s)[0].0, "Inside");
let r = s.execute("toc.generate", &json!({"styles": ["Headline"], "page": page, "replace": false})).unwrap();
assert_ne!(r["story"], first);
assert_eq!(stories(&s), n + 1);
assert_eq!(s.doc().unwrap().doc.toc.as_ref().unwrap().story.0, r["story"].as_u64().unwrap());
}
#[test]
fn hostile_toc_params_are_errors_that_leave_the_document_alone() {
let (mut s, page) = sample_with_toc_page();
let long = "x".repeat(5000);
let many: Vec<Value> = (0..65).map(|_| json!({"style": "Headline"})).collect();
for p in [
json!({"entries": [{"style": "Headline", "level": 0}]}),
json!({"entries": [{"style": "Headline", "level": 10}]}),
json!({"entries": [{"style": "Headline", "level": 300}]}),
json!({"entries": [{"style": "Headline", "level": -1}]}),
json!({"entries": [{"style": "Headline", "pageNumber": "sideways"}]}),
json!({"entries": [{"style": "No Such Style"}]}),
json!({"entries": [{"style": "Headline", "entryStyle": "No Such Style"}]}),
json!({"entries": [{"style": "Headline", "separator": long}]}),
json!({"entries": [{"style": long}]}),
json!({"entries": many}),
json!({"entries": "Headline"}),
json!({"entries": []}),
json!({"styles": ["Headline"], "titleStyle": "No Such Style"}),
json!({"styles": ["Headline"], "title": long}),
json!({"styles": ["Headline"], "rect": [100, 100, 50, 50]}),
json!({"styles": ["Headline"], "page": u64::MAX}),
json!([1, 2]),
] {
let rev = s.doc().unwrap().revision;
assert!(s.execute("toc.generate", &merge(&p, page)).is_err(), "{p}");
assert_eq!(s.doc().unwrap().revision, rev, "{p}");
assert!(s.doc().unwrap().doc.toc.is_none());
}
}
/// `p` with the TOC page added when it is an object and has none.
fn merge(p: &Value, page: usize) -> Value {
let mut p = p.clone();
if let Some(o) = p.as_object_mut() {
o.entry("page").or_insert(json!(page));
}
p
}
}
+32
View File
@@ -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]
+2 -2
View File
@@ -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;
+28
View File
@@ -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();
+439
View File
@@ -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}");
}
}
}
+56 -7
View File
@@ -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");
}
}
+114
View File
@@ -0,0 +1,114 @@
//! 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.
pub 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)
}
/// [`read`] that stops past `cap` bytes: at most `cap + 1` bytes are read, so a device or a file
/// that grows while it is read can't exhaust memory. The caller compares the length with `cap`.
pub fn read_capped(path: impl AsRef<Path>, cap: u64) -> std::io::Result<Vec<u8>> {
use std::io::Read;
let path = path.as_ref();
crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
let mut b = Vec::new();
std::fs::File::open(path)?.take(cap.saturating_add(1)).read_to_end(&mut b)?;
Ok(b)
}
/// 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)
}
+55 -2
View File
@@ -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;
@@ -110,6 +112,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);
@@ -201,6 +213,9 @@ pub struct Prefs {
pub show_note_anchors: bool,
/// Preferences › File Handling: minutes between document recovery saves.
pub recovery_minutes: f64,
/// Preferences › File Handling › Create Links When Placing Text Files: placed text files are
/// linked (Links panel) unless `file.place` says otherwise.
pub link_text_files: bool,
}
/// A starter autocorrect list (common English typing slips).
@@ -249,6 +264,7 @@ impl Default for Prefs {
show_added_text: true,
show_note_anchors: true,
recovery_minutes: 0.5,
link_text_files: false,
}
}
}
@@ -260,6 +276,7 @@ pub struct Session {
pub cache: Arc<Cache>,
pub(crate) tool: Box<dyn designcraft_tools::Tool>,
pub journal: Vec<(String, Value)>,
pub(crate) find_context: Option<cmd::find::FindContext>,
pub clipboard: Option<Arc<Document>>,
/// Crash-recovery folder (the app sets it; saving or closing a document removes its entry).
pub recovery_dir: Option<std::path::PathBuf>,
@@ -267,8 +284,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
@@ -278,12 +295,18 @@ pub struct Session {
pub(crate) text_drag: bool,
/// Character formatting chosen at a bare caret, for the text typed there next.
pub(crate) typing_format: Option<cmd::text::TypingFormat>,
/// Text an input method is composing (a dead key's accent), shown in the story as an
/// interaction preview until it commits: (document uid, byte length before the caret).
pub(crate) composition: Option<(u64, usize)>,
/// The open Object Library (File › New / Open Library).
pub library: Option<cmd::library::Library>,
/// The open book (File › Open Book).
pub book: Option<cmd::book::Book>,
/// Content Collector conveyor: collected objects as snippets (name, bytes).
pub conveyor: Vec<(String, Vec<u8>)>,
/// Linked text files checked by the Links panel: path → the size and modification time the
/// file had and the hash of its contents then, so a file touched but not changed is read once.
pub(crate) text_link_checks: std::collections::HashMap<String, (designcraft_doc::Fingerprint, String)>,
}
impl Default for Session {
@@ -302,14 +325,17 @@ impl Session {
cache: Arc::new(Cache::new()),
tool: designcraft_tools::create("selection"),
journal: vec![],
find_context: None,
clipboard: None,
recovery_dir: None,
text_clipboard: None,
text_link_checks: Default::default(),
ui_requests: vec![],
loaded: None,
transforms: Default::default(),
text_drag: false,
typing_format: None,
composition: None,
library: None,
conveyor: Vec::new(),
book: None,
@@ -361,13 +387,26 @@ impl Session {
pub fn doc_mut(&mut self) -> Result<&mut DocState> {
self.active_mut().ok_or(EngineError::NoDocument)
}
/// Release the transient Find/Change domain when its caller starts or closes a search.
pub fn reset_find_scope(&mut self) {
self.find_context = None;
}
fn discard_stale_find_scope(&mut self) {
if self.find_context.as_ref().is_some_and(|c| self.active().is_none_or(|st| !c.is_current(st))) {
self.reset_find_scope();
}
}
pub fn set_active(&mut self, i: usize) {
if i < self.docs.len() && self.active != Some(i) {
self.reset_find_scope();
self.stop_data_preview();
self.active = Some(i);
}
}
pub fn add_document(&mut self, d: DocState) -> usize {
self.reset_find_scope();
self.stop_data_preview();
self.docs.push(d);
self.active = Some(self.docs.len() - 1);
@@ -391,6 +430,7 @@ impl Session {
recovery::discard_document(self, i, recovery_dir.as_deref());
}
if i < self.docs.len() {
self.reset_find_scope();
self.docs.remove(i);
self.active = if self.docs.is_empty() { None } else { Some(i.min(self.docs.len() - 1)) };
}
@@ -405,6 +445,10 @@ impl Session {
fn execute_unguarded(&mut self, id: &str, params: &Value) -> Result<Value> {
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
// Text still being composed isn't part of the story: any other edit drops it first.
if id != "text.compose" && (spec.undoable || spec.journal) {
self.end_composition();
}
if let Err(e) = (spec.enabled)(self) {
// A command that takes `ids` acts on the objects the call names, so it needs no
// selection: with a text caret or nothing selected, `{"ids": [5]}` still runs.
@@ -421,6 +465,8 @@ impl Session {
if id != "data.preview" && (spec.undoable || spec.journal) {
self.stop_data_preview();
}
// Preview restoration can replace the document before a Find handler resolves bounds.
self.discard_stale_find_scope();
let before = self.active().map(|d| (d.uid, d.doc.clone()));
let r = (spec.run)(self, params)?;
// Undo, redo or deleting layers can take the active layer away: new objects would land on
@@ -458,6 +504,7 @@ impl Session {
if spec.journal {
self.journal.push((id.to_string(), params.clone()));
}
self.discard_stale_find_scope();
Ok(r)
}
@@ -541,10 +588,12 @@ impl Session {
let mut sel = st.selection.clone();
let r = f(&mut doc, &mut sel)?;
crate::cmd::sync_placeholders(&mut doc);
crate::cmd::flex::sync(&mut doc);
doc.sync_endnote_story();
st.doc = Arc::new(doc);
st.selection = sel;
st.revision += 1;
self.reset_find_scope();
Ok(r)
}
}
@@ -562,6 +611,10 @@ 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_roots;
#[cfg(test)]
mod tests_table;
+44 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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]
+441
View File
@@ -605,6 +605,364 @@ fn direct_selection_drag_and_arrow_keys_move_placed_content() {
assert_eq!(g2 - g0, designcraft_geom::Vec2::new(1.0, 10.0));
}
#[test]
fn paragraph_style_edit_persists_attrs_and_rename() {
let mut s = session();
let name =
s.execute("style.paragraph.create", &json!({"name": "Body Copy", "para": {"align": "left"}})).unwrap()["name"].as_str().unwrap().to_string();
s.execute("style.paragraph.edit", &json!({"name": name, "para": {"align": "center"}})).unwrap();
{
let st = s.doc().unwrap();
let style = st.doc.styles.para(&name).expect("style still exists");
assert_eq!(style.para.align, Some(designcraft_doc::Align::Center), "edited attribute persisted");
}
// Renaming updates the style definition itself, and every place it's referenced.
s.execute("style.paragraph.edit", &json!({"name": name, "rename": "Body Text"})).unwrap();
let st = s.doc().unwrap();
assert!(st.doc.styles.para("Body Text").is_some(), "renamed style exists under the new name");
assert!(st.doc.styles.para(&name).is_none(), "old name is gone");
}
#[test]
fn paragraph_style_edit_rejects_based_on_cycle() {
let mut s = session();
let a = s.execute("style.paragraph.create", &json!({"name": "A"})).unwrap()["name"].as_str().unwrap().to_string();
let b = s.execute("style.paragraph.create", &json!({"name": "B", "basedOn": a})).unwrap()["name"].as_str().unwrap().to_string();
let r = s.execute("style.paragraph.edit", &json!({"name": a, "basedOn": b}));
assert!(r.is_err(), "A based on B based on A must be rejected");
}
#[test]
fn paragraph_style_delete_reassigns_affected_text() {
let mut s = session();
let name = s.execute("style.paragraph.create", &json!({"name": "Caption"})).unwrap()["name"].as_str().unwrap().to_string();
let sid = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap()["story"].as_u64().unwrap();
s.execute("text.insert", &json!({"text": "Hello, world"})).unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap();
s.execute("style.paragraph.apply", &json!({"name": name})).unwrap();
{
let st = s.doc().unwrap();
let story = st.doc.stories.get(&designcraft_doc::StoryId(sid)).expect("story exists");
assert_eq!(story.paras[0].style, name, "style applied before delete");
}
s.execute("style.paragraph.delete", &json!({"name": name, "replaceWith": designcraft_doc::BASIC_PARAGRAPH})).unwrap();
let st = s.doc().unwrap();
assert!(st.doc.styles.para(&name).is_none(), "deleted style is gone");
let story = st.doc.stories.get(&designcraft_doc::StoryId(sid)).expect("story exists");
assert_eq!(story.paras[0].style, designcraft_doc::BASIC_PARAGRAPH, "affected paragraph reassigned to the replacement style");
}
#[test]
fn paragraph_style_delete_rejects_builtin_styles() {
let mut s = session();
let r = s.execute("style.paragraph.delete", &json!({"name": designcraft_doc::BASIC_PARAGRAPH}));
assert!(r.is_err(), "built-in styles can't be deleted");
}
#[test]
fn character_style_edit_persists_attrs_and_rename() {
let mut s = session();
let name = s.execute("style.character.create", &json!({"name": "Key Term", "chars": {"fontStyle": "Bold"}})).unwrap()["name"]
.as_str()
.unwrap()
.to_string();
s.execute("style.character.edit", &json!({"name": name, "chars": {"tracking": 20.0}})).unwrap();
{
let st = s.doc().unwrap();
let style = st.doc.styles.char_style(&name).expect("style still exists");
assert_eq!(style.chars.tracking, Some(20.0), "edited attribute persisted");
}
// Renaming updates the style definition itself, and every place it's referenced.
s.execute("style.character.edit", &json!({"name": name, "rename": "Emphasis"})).unwrap();
let st = s.doc().unwrap();
assert!(st.doc.styles.char_style("Emphasis").is_some(), "renamed style exists under the new name");
assert!(st.doc.styles.char_style(&name).is_none(), "old name is gone");
}
#[test]
fn character_style_edit_rejects_based_on_cycle() {
let mut s = session();
let a = s.execute("style.character.create", &json!({"name": "A"})).unwrap()["name"].as_str().unwrap().to_string();
let b = s.execute("style.character.create", &json!({"name": "B", "basedOn": a})).unwrap()["name"].as_str().unwrap().to_string();
let r = s.execute("style.character.edit", &json!({"name": a, "basedOn": b}));
assert!(r.is_err(), "A based on B based on A must be rejected");
}
#[test]
fn character_style_delete_reassigns_affected_text() {
let mut s = session();
let name = s.execute("style.character.create", &json!({"name": "Key Term"})).unwrap()["name"].as_str().unwrap().to_string();
let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap();
let sid = r["story"].as_u64().unwrap();
s.execute("text.insert", &json!({"text": "Hello, world"})).unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 5})).unwrap();
s.execute("style.character.apply", &json!({"name": name})).unwrap();
{
let st = s.doc().unwrap();
let story = st.doc.stories.get(&designcraft_doc::StoryId(sid)).expect("story exists");
assert_eq!(story.chars[0].format.style, name, "style applied before delete");
}
s.execute("style.character.delete", &json!({"name": name, "replaceWith": designcraft_doc::NO_CHAR_STYLE})).unwrap();
let st = s.doc().unwrap();
assert!(st.doc.styles.char_style(&name).is_none(), "deleted style is gone");
let story = st.doc.stories.get(&designcraft_doc::StoryId(sid)).expect("story exists");
assert_eq!(story.chars[0].format.style, designcraft_doc::NO_CHAR_STYLE, "affected run reassigned to the replacement style");
}
#[test]
fn character_style_delete_rejects_builtin_styles() {
let mut s = session();
let r = s.execute("style.character.delete", &json!({"name": designcraft_doc::NO_CHAR_STYLE}));
assert!(r.is_err(), "built-in styles can't be deleted");
}
#[test]
fn character_style_delete_rejects_missing_unknown_and_self_replacement() {
let mut s = session();
let name = s.execute("style.character.create", &json!({"name": "Key Term"})).unwrap()["name"].as_str().unwrap().to_string();
assert!(s.execute("style.character.delete", &json!({"name": ""})).is_err(), "empty name is rejected");
assert!(s.execute("style.character.delete", &json!({"name": "No Such Style"})).is_err(), "unknown name is rejected");
assert!(s.execute("style.character.delete", &json!({"name": name, "replaceWith": "No Such Style"})).is_err(), "unknown replaceWith is rejected");
assert!(s.execute("style.character.delete", &json!({"name": name, "replaceWith": name})).is_err(), "replaceWith can't be the deleted style");
}
#[test]
fn paragraph_style_delete_rejects_missing_unknown_and_self_replacement() {
let mut s = session();
let name = s.execute("style.paragraph.create", &json!({"name": "Caption"})).unwrap()["name"].as_str().unwrap().to_string();
assert!(s.execute("style.paragraph.delete", &json!({"name": ""})).is_err(), "empty name is rejected");
assert!(s.execute("style.paragraph.delete", &json!({"name": "No Such Style"})).is_err(), "unknown name is rejected");
assert!(s.execute("style.paragraph.delete", &json!({"name": name, "replaceWith": "No Such Style"})).is_err(), "unknown replaceWith is rejected");
assert!(s.execute("style.paragraph.delete", &json!({"name": name, "replaceWith": name})).is_err(), "replaceWith can't be the deleted style");
}
#[test]
fn deleting_a_style_never_leaves_its_replacement_based_on_itself() {
let mut s = session();
let para = |s: &Session, n: &str| s.doc().unwrap().doc.styles.para(n).unwrap().based_on.clone();
s.execute("style.paragraph.create", &json!({"name": "Head"})).unwrap();
s.execute("style.paragraph.create", &json!({"name": "Sub", "basedOn": "Head"})).unwrap();
s.execute("style.paragraph.create", &json!({"name": "Minor", "basedOn": "Sub"})).unwrap();
// Minor is based on Sub; deleting Sub in favour of Minor gives Minor Sub's parent.
s.execute("style.paragraph.delete", &json!({"name": "Sub", "replaceWith": "Minor"})).unwrap();
assert_eq!(para(&s, "Minor").as_deref(), Some("Head"));
s.execute("style.character.create", &json!({"name": "Strong"})).unwrap();
s.execute("style.character.create", &json!({"name": "Stronger", "basedOn": "Strong"})).unwrap();
s.execute("style.character.delete", &json!({"name": "Strong", "replaceWith": "Stronger"})).unwrap();
let based = s.doc().unwrap().doc.styles.character.iter().find(|c| c.name == "Stronger").unwrap().based_on.clone();
assert_ne!(based.as_deref(), Some("Stronger"));
}
#[test]
fn character_style_delete_reassigns_every_kind_of_reference() {
let mut s = session();
let name = s.execute("style.character.create", &json!({"name": "Key Term"})).unwrap()["name"].as_str().unwrap().to_string();
let repl = s.execute("style.character.create", &json!({"name": "Emphasis"})).unwrap()["name"].as_str().unwrap().to_string();
// Another character style's basedOn.
let based = s.execute("style.character.create", &json!({"name": "Strong", "basedOn": name})).unwrap()["name"].as_str().unwrap().to_string();
// A paragraph style's nested, nested line, GREP and drop cap styles, and the footnote reference style.
s.execute(
"style.paragraph.create",
&json!({"name": "Body", "para": {
"nestedStyles": [{"style": name, "through": true, "count": 1, "until": {"kind": "words"}}],
"grepStyles": [{"style": name, "pattern": "x"}],
"nestedLineStyles": [{"style": name, "lines": 1}],
"dropCapStyle": name}}),
)
.unwrap();
s.execute("footnote.options", &json!({"refCharStyle": name})).unwrap();
s.execute("style.character.delete", &json!({"name": name, "replaceWith": repl})).unwrap();
let st = s.doc().unwrap();
assert!(st.doc.styles.char_style(&name).is_none(), "deleted style is gone");
assert_eq!(st.doc.styles.char_style(&based).unwrap().based_on.as_deref(), Some(repl.as_str()), "basedOn redirected");
let body = st.doc.styles.para("Body").unwrap();
assert_eq!(body.para.nested_styles.as_ref().unwrap()[0].style, repl, "nested style redirected");
assert_eq!(body.para.nested_line_styles.as_ref().unwrap()[0].style, repl, "nested line style redirected");
assert_eq!(body.para.grep_styles.as_ref().unwrap()[0].style, repl, "grep style redirected");
assert_eq!(body.para.drop_cap_style.as_deref(), Some(repl.as_str()), "drop cap style redirected");
assert_eq!(st.doc.footnote_options.ref_char_style, repl, "footnote reference style redirected");
}
#[test]
fn page_tool_selects_a_page_and_its_handles_preview_or_resize_it() {
use designcraft_tools::{CanvasLayout, Mods, PointerEvent, PointerKind};
let mut s = Session::new();
s.execute("file.new", &json!({"width": 612, "height": 792, "facingPages": false})).unwrap();
let id = s.execute("frame.create", &json!({"rect": [400, 100, 500, 200]})).unwrap()["id"].as_u64().unwrap();
let frame = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds();
let width = |s: &Session| s.doc().unwrap().doc.spreads[0].pages[0].width;
// The page on the canvas (a single page is centred on the spine).
let page = |s: &Session| {
let d = &s.doc().unwrap().doc;
CanvasLayout::new(d, false).page_rect(d, 0).unwrap()
};
s.set_tool("page");
let v = ViewInfo::at_zoom(1.0);
let ev = |kind, x: f64, y: f64, alt: bool| PointerEvent { mods: Mods { alt, ..Default::default() }, ..PointerEvent::new(kind, x, y) };
let drag = |s: &mut Session, from: (f64, f64), by: f64, alt: bool, check: &dyn Fn(&Session)| {
s.pointer(&ev(PointerKind::Down, from.0, from.1, alt), v).unwrap();
s.pointer(&ev(PointerKind::Drag, from.0 + by, from.1, alt), v).unwrap();
check(s);
s.pointer(&ev(PointerKind::Up, from.0 + by, from.1, alt), v).unwrap();
};
// A click selects the page.
let c = page(&s).center();
s.pointer(&ev(PointerKind::Down, c.x, c.y, false), v).unwrap();
s.pointer(&ev(PointerKind::Up, c.x, c.y, false), v).unwrap();
assert_eq!(s.doc().unwrap().selection.page, Some(0));
// A plain drag of the right handle previews the page's liquid rule and springs back.
s.execute("liquid.pageRule", &json!({"rule": "scale"})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
let r = page(&s);
drag(&mut s, (r.x1, r.center().y), 612.0, false, &|s| {
assert_eq!(width(s), 1224.0, "previewed");
// Scale keeps proportions: only wider, so the frame is centred on the wider page.
assert_eq!(frame(s).x0, 706.0, "the Scale rule in the preview: {:?}", frame(s));
});
assert_eq!((width(&s), frame(&s)), (612.0, designcraft_geom::Rect::new(400.0, 100.0, 500.0, 200.0)), "sprung back");
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "nothing to undo");
// Alt-drag resizes; with the rule off the frame stays where it is.
s.execute("liquid.pageRule", &json!({"rule": "off"})).unwrap();
let r = page(&s);
drag(&mut s, (r.x1, r.center().y), 100.0, true, &|_| {});
assert_eq!(width(&s), 712.0);
assert_eq!(frame(&s).x0, 400.0);
// The left handle grows the page to the left: the frame keeps its place by the right edge.
let r = page(&s);
drag(&mut s, (r.x0, r.center().y), -100.0, true, &|_| {});
let pg = s.doc().unwrap().doc.spreads[0].pages[0].clone();
assert_eq!(pg.width, 812.0);
assert_eq!(frame(&s).x0 - pg.x, 500.0, "100 pt further from the page's left edge");
// A click on the pasteboard deselects the page.
let r = page(&s);
s.pointer(&ev(PointerKind::Down, r.x0 - 200.0, r.center().y, false), v).unwrap();
assert_eq!(s.doc().unwrap().selection.page, None);
}
#[test]
fn page_size_keeps_objects_by_its_anchor() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400, "facingPages": false})).unwrap();
let id = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap();
let frame = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds();
// Bottom-right stays put: 100 wider and 50 taller moves the frame 100 right and 50 down.
s.execute("layout.pageSize", &json!({"pages": [1], "width": 500, "height": 450, "anchor": 8})).unwrap();
assert_eq!(frame(&s), designcraft_geom::Rect::new(200.0, 150.0, 300.0, 250.0));
// The centre: half of it.
s.execute("layout.pageSize", &json!({"pages": [1], "width": 700, "height": 450, "anchor": 4})).unwrap();
assert_eq!(frame(&s).x0, 300.0);
assert!(s.execute("layout.pageSize", &json!({"pages": [1], "width": 700, "anchor": 9})).is_err());
assert!(s.execute("layout.selectPage", &json!({"page": 2})).is_err());
assert_eq!(s.execute("layout.selectPage", &json!({"page": 1})).unwrap()["page"], 1);
}
#[test]
fn controlled_by_parent_takes_the_parent_pages_rule() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400, "facingPages": false})).unwrap();
let id = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap();
let frame = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds();
// The page follows its parent; the parent page re-centres.
s.execute("liquid.pageRule", &json!({"rule": "controlledByParent", "pages": [1]})).unwrap();
s.edit(|d, _| {
std::sync::Arc::make_mut(&mut d.parents[0]).pages[0].liquid = designcraft_doc::LiquidRule::ReCenter;
Ok(())
})
.unwrap();
s.execute("layout.pageSize", &json!({"pages": [1], "width": 600})).unwrap();
assert_eq!(frame(&s).x0, 200.0, "re-centred: {:?}", frame(&s));
// Without a parent (or a parent that's also controlled by its parent) nothing moves.
s.execute("layout.pages.applyParent", &json!({"pages": [0], "parent": null})).unwrap();
s.execute("layout.pageSize", &json!({"pages": [1], "width": 800})).unwrap();
assert_eq!(frame(&s).x0, 200.0);
// Show Parent Page Overlay is a page option.
s.execute("layout.pageOptions", &json!({"pages": [1], "showParentOverlay": true})).unwrap();
assert!(s.doc().unwrap().doc.spreads[0].pages[0].show_parent_overlay);
assert!(s.execute("layout.pageOptions", &json!({"pages": [2], "showParentOverlay": true})).is_err());
}
#[test]
fn page_tool_pins_and_locks_objects_on_object_based_pages() {
use designcraft_tools::{CanvasLayout, Mods, PointerEvent, PointerKind};
let mut s = Session::new();
s.execute("file.new", &json!({"width": 612, "height": 792, "facingPages": false})).unwrap();
let id = s.execute("frame.create", &json!({"rect": [256, 72, 356, 132]})).unwrap()["id"].as_u64().unwrap();
s.execute("liquid.pageRule", &json!({"rule": "objectBased"})).unwrap();
s.execute("edit.deselectAll", &json!({})).unwrap();
s.set_tool("page");
let v = ViewInfo::at_zoom(1.0);
let ev =
|kind, p: designcraft_geom::Point, alt: bool| PointerEvent { mods: Mods { alt, ..Default::default() }, ..PointerEvent::new(kind, p.x, p.y) };
let canvas = |s: &Session| {
let d = &s.doc().unwrap().doc;
let l = CanvasLayout::new(d, false);
(
l.page_rect(d, 0).unwrap(),
l.xf(designcraft_doc::SpreadRef::Doc(0)).transform_rect_bbox(d.item(designcraft_doc::ItemId(id)).unwrap().bounds()),
)
};
let click = |s: &mut Session, p: designcraft_geom::Point| {
s.pointer(&ev(PointerKind::Down, p, false), v).unwrap();
s.pointer(&ev(PointerKind::Up, p, false), v).unwrap();
};
let liquid = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().liquid;
// A click on the object selects it with its page; its pins and locks show.
let (_, o) = canvas(&s);
click(&mut s, o.center());
let sel = s.doc().unwrap().selection.clone();
assert_eq!((sel.page, sel.items.clone()), (Some(0), vec![designcraft_doc::ItemId(id)]));
assert!(
s.overlays(v)
.iter()
.any(|o| matches!(o, designcraft_tools::Overlay::LiquidPins { pins: [false, false, false, false], resize: [true, true], .. }))
);
// The right pin (14 px outside the right edge) and the width lock (10 px right of the centre).
click(&mut s, designcraft_geom::Point::new(o.x1 + 14.0, o.center().y));
click(&mut s, designcraft_geom::Point::new(o.center().x + 10.0, o.center().y));
let l = liquid(&s).unwrap();
assert!(l.pin_right && !l.resize_width && l.resize_height && !l.pin_left, "{l:?}");
// Alt-drag the page 100 pt wider: the pinned, width-locked frame keeps its size by the right edge.
let (p, _) = canvas(&s);
let right = designcraft_geom::Point::new(p.x1, p.center().y);
s.pointer(&ev(PointerKind::Down, right, true), v).unwrap();
s.pointer(&ev(PointerKind::Drag, right + designcraft_geom::Vec2::new(100.0, 0.0), true), v).unwrap();
s.pointer(&ev(PointerKind::Up, right + designcraft_geom::Vec2::new(100.0, 0.0), true), v).unwrap();
let d = &s.doc().unwrap().doc;
let (pg, b) = (&d.spreads[0].pages[0], d.item(designcraft_doc::ItemId(id)).unwrap().bounds());
assert_eq!(pg.width, 712.0);
assert!((b.width() - 100.0).abs() < 1e-6 && (pg.x + pg.width - b.x1 - 256.0).abs() < 1e-6, "{b:?}");
// Hovering a pin shows its tooltip.
let (_, o) = canvas(&s);
s.pointer(&ev(PointerKind::Move, designcraft_geom::Point::new(o.x1 + 14.0, o.center().y), false), v).unwrap();
assert!(s.overlays(v).iter().any(|o| matches!(o, designcraft_tools::Overlay::Tip { text, .. } if text.starts_with("Object is pinned"))));
}
#[test]
fn duplicating_a_page_keeps_its_own_size_rule_guides_and_objects() {
// Chris Cicchino's Page Tool video (2:06): the letterhead page is duplicated, then resized.
let mut s = Session::new();
s.execute("file.new", &json!({"width": 252, "height": 144, "facingPages": false})).unwrap();
s.execute("layout.pages.insert", &json!({"count": 1})).unwrap();
s.execute("layout.pageSize", &json!({"pages": [2], "width": 612, "height": 792})).unwrap();
s.execute("liquid.pageRule", &json!({"rule": "scale", "pages": [2]})).unwrap();
s.execute("guide.add", &json!({"spread": 1, "page": 0, "orientation": "vertical", "position": 300})).unwrap();
s.execute("frame.create", &json!({"spread": 1, "rect": [100, 100, 200, 200]})).unwrap();
let r = s.execute("layout.pages.duplicateSpread", &json!({"spread": 1})).unwrap();
assert_eq!(r["page"], 2);
let d = s.doc().unwrap().doc.clone();
let (si, pi) = d.page_loc(2).unwrap();
let pg = &d.spreads[si].pages[pi];
assert_eq!((pg.width, pg.height, pg.liquid), (612.0, 792.0, designcraft_doc::LiquidRule::Scale));
assert_eq!(pg.guides.len(), 1);
assert_eq!(d.spreads[si].items.len(), 1);
// At the end of the document (the New Page button): the business card, not the last page's size.
let r = s.execute("layout.pages.duplicateSpread", &json!({"spread": 0, "atEnd": true})).unwrap();
assert_eq!(r["page"], 3);
let d = s.doc().unwrap().doc.clone();
let (si, pi) = d.page_loc(3).unwrap();
assert_eq!((d.spreads[si].pages[pi].width, d.spreads[si].pages[pi].height), (252.0, 144.0));
}
/// Load the text from a frame's out port with the Selection tool and click on the pasteboard:
/// a new frame is threaded there and the overset text flows into it.
#[test]
@@ -704,3 +1062,86 @@ fn type_tool_frames_snap_to_the_margins() {
assert!((r.x0 - margin.x0).abs() < 1e-6 && (r.y0 - margin.y0).abs() < 1e-6, "press point {r:?} vs {margin:?}");
assert!((r.x1 - margin.x1).abs() < 1e-6, "right edge {} vs {}", r.x1, margin.x1);
}
#[test]
fn duplicating_a_page_to_an_index_is_one_undo_step() {
let mut s = Session::new();
s.execute("file.new", &json!({"facingPages": false})).unwrap();
s.execute("layout.pages.insert", &json!({"count": 2})).unwrap();
for (page, width) in [(1, 300), (2, 400), (3, 500)] {
s.execute("layout.pageSize", &json!({"pages": [page], "width": width, "height": 600})).unwrap();
}
let widths = |s: &Session| {
let d = &s.doc().unwrap().doc;
(0..d.page_count()).map(|i| d.page_loc(i).map(|(si, pi)| d.spreads[si].pages[pi].width)).collect::<Option<Vec<_>>>().unwrap()
};
// Backwards: the last page copied to the front.
let r = s.execute("layout.pages.duplicateSpread", &json!({"spread": 2, "to": 0})).unwrap();
assert_eq!(r["page"], 0);
assert_eq!(widths(&s), [500.0, 300.0, 400.0, 500.0]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(widths(&s), [300.0, 400.0, 500.0]);
// Forwards: the first page copied to the end.
s.execute("layout.pages.duplicateSpread", &json!({"spread": 0, "to": 3})).unwrap();
assert_eq!(widths(&s), [300.0, 400.0, 500.0, 300.0]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(widths(&s), [300.0, 400.0, 500.0]);
// Past the end: refused, nothing changes.
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");
}
+250
View File
@@ -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");
}
+32 -1
View File
@@ -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();
@@ -363,3 +364,33 @@ fn type_para_sets_a_drop_cap_that_survives_idml() {
assert_eq!(p.drop_cap_style.as_deref(), Some("Initial"));
assert_eq!(p.nested_styles, Some(vec![designcraft_doc::NestedStyle::drop_cap("Initial")]));
}
#[test]
fn table_of_contents_definition_round_trips_through_idml_and_updates() {
let mut s = Session::new();
s.execute("file.newSample", &json!({})).unwrap();
let page = s.doc().unwrap().doc.page_count() + 1;
s.execute("layout.pages.insert", &json!({"count": 1})).unwrap();
let basic = designcraft_doc::story::BASIC_PARAGRAPH;
s.execute(
"toc.generate",
&json!({"page": page, "title": "Inside", "titleStyle": basic, "entries": [
{"style": "Cover Title", "level": 1, "pageNumber": "before", "separator": " ^ "},
{"style": "Headline", "level": 2, "entryStyle": basic, "pageNumber": "none"},
]}),
)
.unwrap();
let toc_text = |s: &Session| {
let d = &s.doc().unwrap().doc;
d.story(d.toc.as_ref().unwrap().story).unwrap().text.clone()
};
let before = s.doc().unwrap().doc.toc.clone().unwrap();
let text = toc_text(&s);
let b64 = s.execute("file.exportIdml", &json!({})).unwrap()["base64"].as_str().unwrap().to_string();
s.execute("file.openIdml", &json!({"base64": b64, "name": "toc.idml"})).unwrap();
let after = s.doc().unwrap().doc.toc.clone().expect("the TOC definition is imported");
assert_eq!((&after.title, &after.title_style, &after.entries), (&before.title, &before.title_style, &before.entries));
assert_eq!(toc_text(&s), text);
s.execute("toc.update", &json!({})).unwrap();
assert_eq!(toc_text(&s), text);
}
+233
View File
@@ -0,0 +1,233 @@
//! 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();
for command in ["book.paginate", "book.syncStyles", "book.exportPdf"] {
let r = s.execute(command, &json!({"path": p(&base.join("outside/book.pdf"))}));
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);
}
+37
View File
@@ -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()
}
+32 -1
View File
@@ -2,6 +2,7 @@
use std::sync::Arc;
use designcraft_doc::{CellAddr, StoryId};
use designcraft_geom::Unit;
pub use designcraft_tools::SnapView;
use designcraft_tools::{Action, CanvasLayout, Cursor, Mods, Overlay, PointerEvent, ToolContext, ToolKey};
@@ -35,11 +36,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 }
}
}
@@ -58,7 +61,12 @@ impl Session {
if self.tool.id() == id {
return;
}
self.end_composition();
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 +101,7 @@ impl Session {
layer,
snap: view.snap,
unit: view.unit,
stroke_in_front: view.stroke_in_front,
};
Some(f(self.tool.as_mut(), &cx))
}
@@ -152,7 +161,29 @@ impl Session {
Ok(())
}
/// The text an input method is composing at the caret, as story bytes: it is in the
/// document as a preview until `text.compose` commits or drops it.
pub fn composition(&self) -> Option<(StoryId, Option<CellAddr>, std::ops::Range<usize>)> {
let (uid, len) = self.composition?;
let st = self.active().filter(|st| st.uid == uid)?;
let t = st.selection.text?;
Some((t.story, t.cell, t.focus.checked_sub(len)?..t.focus))
}
/// Drop the text being composed: the document goes back to how it was before it.
pub fn end_composition(&mut self) {
let Some((uid, _)) = self.composition.take() else { return };
if let Some(st) = self.docs.iter_mut().find(|st| st.uid == uid)
&& let Some(it) = st.interaction.take()
{
st.doc = it.doc;
st.selection = it.selection;
st.revision += 1;
}
}
pub fn begin_interaction(&mut self, label: &str) -> Result<()> {
self.end_composition();
let st = self.doc_mut()?;
st.interaction = Some(Interaction { label: label.into(), doc: st.doc.clone(), selection: st.selection.clone(), preview: None });
Ok(())
@@ -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)));
}
+1333 -123
View File
File diff suppressed because it is too large Load Diff
+14 -1
View File
@@ -55,7 +55,20 @@ fn package(doc: &Document) -> (String, String) {
let opf = read("OEBPS/content.opf");
assert!(xhtml.contains("<link rel=\"stylesheet\" type=\"text/css\" href=\"style.css\"/>"));
assert!(opf.contains("href=\"style.css\" media-type=\"text/css\""));
(xhtml, css)
(without_heading_ids(&xhtml), css)
}
/// The markup without the `id`s headings get as table-of-contents targets (` id="s0-p0"`).
fn without_heading_ids(html: &str) -> String {
let mut out = String::with_capacity(html.len());
let mut rest = html;
while let Some(i) = rest.find(" id=\"s") {
out.push_str(&rest[..i]);
let after = &rest[i + 6..];
rest = after.find('"').map_or("", |j| &after[j + 1..]);
}
out.push_str(rest);
out
}
fn rule<'a>(css: &'a str, selector: &str) -> &'a str {
+52
View File
@@ -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
}
+167 -27
View File
@@ -93,6 +93,9 @@ pub struct FontFace {
pub family: String,
/// Typographic style name (e.g. "Semibold", "Italic").
pub style: String,
/// The WWS family and style names (name IDs 21 and 22) when they differ from the typographic
/// ones: InDesign names an optical-size face by them ("Kepler Std Display" / "Light").
pub wws: Option<(String, String)>,
/// usWeightClass-style weight (400 = regular).
pub weight: f32,
pub italic: bool,
@@ -125,6 +128,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 +314,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)
@@ -331,6 +343,13 @@ impl FontFace {
let fs_type = self.skrifa().and_then(|f| f.os2().ok()).map_or(0, |t| t.fs_type());
fs_type & 0x0002 != 0 && fs_type & 0x000C == 0
}
/// Does the font's licence allow embedding bitmaps only? OS/2 `fsType` bit 9: the outlines
/// may not be passed on, so the font file can't be packaged whole.
pub fn bitmap_only(&self) -> bool {
use skrifa::raw::TableProvider;
self.skrifa().and_then(|f| f.os2().ok()).is_some_and(|t| t.fs_type() & 0x0200 != 0)
}
}
#[cfg(not(target_arch = "wasm32"))]
@@ -338,10 +357,12 @@ impl FontFace {
struct CatalogEntry {
family: String,
style: String,
wws: Option<(String, String)>,
path: std::path::PathBuf,
/// 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.
@@ -350,8 +371,10 @@ struct CatalogEntry {
struct ScannedFace {
family: String,
style: String,
wws: Option<(String, String)>,
group: FontGroup,
native: Option<String>,
postscript: Option<String>,
}
/// Process-wide font database: the fonts every document shares (bundled, installed, added), and
@@ -563,6 +586,31 @@ fn has_face(faces: &[Arc<FontFace>], family: &str, style: &str) -> bool {
faces.iter().any(|f| f.family.eq_ignore_ascii_case(family) && f.style.eq_ignore_ascii_case(style))
}
/// A face's WWS family and style names (name IDs 21 and 22), if it has a WWS family and it differs
/// from `family`/`style`.
fn wws_names(f: &skrifa::FontRef<'_>, family: &str, style: &str) -> Option<(String, String)> {
let wf = name(f, &[StringId::WWS_FAMILY_NAME])?;
let ws = name(f, &[StringId::WWS_SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into());
(!wf.eq_ignore_ascii_case(family) || !ws.eq_ignore_ascii_case(style)).then_some((wf, ws))
}
/// The family and style a face is known by: its typographic names, or its WWS names (if it has
/// them) when `by_wws`.
fn known_as<'a>(family: &'a str, style: &'a str, wws: Option<&'a (String, String)>, by_wws: bool) -> Option<(&'a str, &'a str)> {
if by_wws { wws.map(|w| (w.0.as_str(), w.1.as_str())) } else { Some((family, style)) }
}
/// Is `family` the WWS family name of `f`?
fn has_wws_family(f: &FontFace, family: &str) -> bool {
f.wws.as_ref().is_some_and(|w| w.0.eq_ignore_ascii_case(family))
}
/// Is `family` the typographic or the WWS family name of `c`?
#[cfg(not(target_arch = "wasm32"))]
fn in_catalog_family(c: &CatalogEntry, family: &str) -> bool {
c.family.eq_ignore_ascii_case(family) || c.wws.as_ref().is_some_and(|w| w.0.eq_ignore_ascii_case(family))
}
fn face_names(f: &skrifa::FontRef<'_>) -> Option<(String, String)> {
let family = name(f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME])?;
let style = name(f, &[StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into());
@@ -620,7 +668,7 @@ fn file_face_names(path: &std::path::Path) -> Vec<ScannedFace> {
let font = crate::group::sfnt(found.clone())?;
let f = skrifa::FontRef::new(&font).ok()?;
let (family, style) = face_names(&f)?;
let (group, native) = match crate::group::declared_group(&f) {
let (group, native) = match crate::group::declared_group(&f, &family) {
Some(g) => (g, crate::group::classify(&f, &family).1),
None => {
found.extend(table(b"cmap", MAX_CMAP));
@@ -628,7 +676,9 @@ 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]);
let wws = wws_names(&f, &family, &style);
Some(ScannedFace { family, style, wws, group, native, postscript })
})
.collect()
}
@@ -669,15 +719,23 @@ pub fn system_font_dirs() -> Vec<std::path::PathBuf> {
dirs.push(std::path::PathBuf::from(l).join("Microsoft\\Windows\\Fonts"));
}
} else {
dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(Into::into));
if let Some(h) = &home {
dirs.push(h.join(".fonts"));
dirs.push(h.join(".local/share/fonts"));
}
let xdg_data_home = std::env::var_os("XDG_DATA_HOME").map(std::path::PathBuf::from);
dirs.extend(unix_font_dirs(home.as_deref(), xdg_data_home.as_deref()));
}
dirs
}
fn unix_font_dirs(home: Option<&std::path::Path>, xdg_data_home: Option<&std::path::Path>) -> Vec<std::path::PathBuf> {
let mut dirs: Vec<std::path::PathBuf> = ["/usr/share/fonts", "/usr/local/share/fonts"].map(Into::into).into();
if let Some(home) = home {
dirs.push(home.join(".fonts"));
}
let data_home =
xdg_data_home.filter(|path| path.is_absolute()).map(std::path::Path::to_path_buf).or_else(|| home.map(|path| path.join(".local/share")));
dirs.extend(data_home.map(|path| path.join("fonts")));
dirs
}
/// One face found in a font file: index, family, style and (variable fonts) the named instance's
/// axis settings.
type Found = (u32, String, String, Vec<([u8; 4], f32)>);
@@ -740,6 +798,7 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s
let m = f.metrics(Size::unscaled(), &location);
let upem = m.units_per_em.max(1) as f64;
let a = f.attributes();
let wws = wws_names(&f, &family, &style);
let hb = harfrust::FontRef::from_index(data, index).ok()?;
let shaper = harfrust::ShaperData::new(&hb);
let instance = (!coords.is_empty())
@@ -754,6 +813,7 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
family,
style,
wws,
weight,
italic,
upem,
@@ -779,6 +839,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 +906,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 +1077,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 +1130,15 @@ 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,
wws: f.wws,
path: p.clone(),
group: f.group,
native: f.native,
postscript: f.postscript,
});
}
}
}
@@ -1069,7 +1153,7 @@ impl FontDb {
fn load_cataloged(&self, family: &str) -> bool {
let paths: Vec<std::path::PathBuf> = {
let cat = self.read_catalog();
let mut p: Vec<_> = cat.iter().filter(|c| c.family.eq_ignore_ascii_case(family)).map(|c| c.path.clone()).collect();
let mut p: Vec<_> = cat.iter().filter(|c| in_catalog_family(c, family)).map(|c| c.path.clone()).collect();
p.sort();
p.dedup();
p
@@ -1092,18 +1176,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,13 +1302,18 @@ 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)
}
fn is_loaded(&self, family: &str) -> bool {
self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(family))
self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(family) || has_wws_family(f, family))
}
/// A shared variable font's axes (see [`ScopedFonts::axes`]).
@@ -1409,17 +1513,30 @@ impl ScopedFonts<'_> {
}
fn exact_style_traits(&self, family: &str) -> Vec<(bool, f32, String)> {
let v = self.named_style_traits(family, false);
// No face of that typographic family: the family may be a WWS name (see `find`).
if v.is_empty() { self.named_style_traits(family, true) } else { v }
}
/// [`ScopedFonts::exact_style_traits`] by the faces' typographic names, or by their WWS names
/// when `by_wws`.
fn named_style_traits(&self, family: &str, by_wws: bool) -> Vec<(bool, f32, String)> {
let mut v: Vec<(bool, f32, String)> = self
.own()
.iter()
.chain(self.db.read_faces().iter())
.filter(|f| f.family.eq_ignore_ascii_case(family) && !f.style.contains('{'))
.map(|f| (f.italic, f.weight, f.style.clone()))
.filter(|f| !f.style.contains('{'))
.filter_map(|f| known_as(&f.family, &f.style, f.wws.as_ref(), by_wws).map(|n| (f, n)))
.filter(|(_, (fam, _))| fam.eq_ignore_ascii_case(family))
.map(|(f, (_, style))| (f.italic, f.weight, style.to_string()))
.collect();
#[cfg(not(target_arch = "wasm32"))]
for c in self.db.read_catalog().iter() {
if c.family.eq_ignore_ascii_case(family) && !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&c.style)) {
v.push((style_italic(&c.style), style_weight(&c.style), c.style.clone()));
if let Some((fam, style)) = known_as(&c.family, &c.style, c.wws.as_ref(), by_wws)
&& fam.eq_ignore_ascii_case(family)
&& !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(style))
{
v.push((style_italic(style), style_weight(style), style.to_string()));
}
}
v.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1)).then(a.2.cmp(&b.2)));
@@ -1494,12 +1611,27 @@ 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) {
if self.own().iter().any(|f| f.family.eq_ignore_ascii_case(family) || has_wws_family(f, family)) || self.db.is_loaded(family) {
return true;
}
#[cfg(not(target_arch = "wasm32"))]
if self.db.read_catalog().iter().any(|c| c.family.eq_ignore_ascii_case(family)) {
if self.db.read_catalog().iter().any(|c| in_catalog_family(c, family)) {
return true;
}
false
@@ -1566,12 +1698,20 @@ impl ScopedFonts<'_> {
let faces = self.db.read_faces();
// The document's fonts first: they stand in for other fonts of the same name.
let named = |f: &&Arc<FontFace>| f.family.eq_ignore_ascii_case(family);
let cands: Vec<&Arc<FontFace>> = self.own().iter().filter(named).chain(faces.iter().filter(named)).collect();
let mut cands: Vec<&Arc<FontFace>> = self.own().iter().filter(named).chain(faces.iter().filter(named)).collect();
// No face of that typographic family: the family may be a WWS name, whose faces carry
// their WWS style names.
let by_wws = cands.is_empty();
if by_wws {
let aliased = |f: &&Arc<FontFace>| has_wws_family(f, family);
cands = self.own().iter().filter(aliased).chain(faces.iter().filter(aliased)).collect();
}
if cands.is_empty() {
return None;
}
let style_of = |f: &FontFace| -> String { norm(f.wws.as_ref().filter(|_| by_wws).map_or(&f.style, |w| &w.1)) };
let ns = norm(style);
if let Some(f) = cands.iter().find(|f| norm(&f.style) == ns) {
if let Some(f) = cands.iter().find(|f| style_of(f) == ns) {
return Some((*f).clone());
}
let (tw, ti) = (style_weight(style), style_italic(style));
+63 -10
View File
@@ -141,10 +141,10 @@ fn name_group(f: &skrifa::FontRef<'_>) -> Option<FontGroup> {
/// Name IDs of the family name: typographic family, then family.
const FAMILY_IDS: [u16; 2] = [16, 1];
/// From the OS/2 code page ranges: `Some(Some(group))` for the CJK code pages (JIS, Chinese
/// Simplified, Korean Wansung, Chinese Traditional, Korean Johab), `Some(None)` when the font
/// lists code pages but no CJK one, `None` when it lists none.
fn code_page_group(f: &skrifa::FontRef<'_>) -> Option<Option<FontGroup>> {
/// The CJK groups of the OS/2 code page ranges the font lists (JIS, Chinese Simplified, Korean
/// Wansung, Chinese Traditional, Korean Johab), in menu order: empty when it lists code pages
/// but no CJK one, `None` when it lists none at all.
fn code_page_groups(f: &skrifa::FontRef<'_>) -> Option<Vec<FontGroup>> {
let os2 = f.os2().ok()?;
let (r1, r2) = (os2.ul_code_page_range_1()?, os2.ul_code_page_range_2().unwrap_or(0));
if r1 == 0 && r2 == 0 {
@@ -157,17 +157,37 @@ fn code_page_group(f: &skrifa::FontRef<'_>) -> Option<Option<FontGroup>> {
(20, FontGroup::ChineseTraditional),
(21, FontGroup::Korean),
];
Some(first(bits.into_iter().filter(|(b, _)| r1 & (1 << b) != 0).map(|(_, g)| g)))
let mut groups: Vec<FontGroup> = bits.into_iter().filter(|(b, _)| r1 & (1 << b) != 0).map(|(_, g)| g).collect();
groups.sort();
groups.dedup();
Some(groups)
}
/// The CJK group a family name's region tag names, as the pan-CJK families carry one per
/// language ("Noto Sans CJK SC", "Source Han Serif TC", "PingFang HK", "Hiragino Sans GB"): a
/// whole word of the name, in capitals.
fn family_tag_group(family: &str) -> Option<FontGroup> {
family.split(|c: char| c.is_whitespace() || c == '-' || c == '_').find_map(|word| match word {
"JP" => Some(FontGroup::Japanese),
"SC" | "CN" | "GB" => Some(FontGroup::ChineseSimplified),
"TC" | "HK" | "TW" | "MO" | "HC" => Some(FontGroup::ChineseTraditional),
"KR" => Some(FontGroup::Korean),
_ => None,
})
}
/// The group the font declares: its `meta` languages, then its `name` table's languages, then its
/// code pages (the strongest signal first). `None` when it declares nothing at all: no CJK
/// language and no code pages.
pub(crate) fn declared_group(f: &skrifa::FontRef<'_>) -> Option<FontGroup> {
/// code pages (the strongest signal first). A font that lists the code pages of several CJK
/// languages (one of a pan-CJK family, covering them all) is the one `family`'s region tag
/// names, else the first in menu order. `None` when it declares nothing at all: no CJK language
/// and no code pages.
pub(crate) fn declared_group(f: &skrifa::FontRef<'_>, family: &str) -> Option<FontGroup> {
if let Some(g) = meta_group(f).or_else(|| name_group(f)) {
return Some(g);
}
code_page_group(f).map(|g| g.unwrap_or(FontGroup::Western))
let pages = code_page_groups(f)?;
let tagged = if pages.len() > 1 { family_tag_group(family).filter(|g| pages.contains(g)) } else { None };
Some(tagged.or_else(|| pages.first().copied()).unwrap_or(FontGroup::Western))
}
/// The group by what the font covers: kana is Japanese, Hangul Korean, ideographs alone
@@ -190,7 +210,7 @@ pub(crate) fn covered_group(f: &skrifa::FontRef<'_>) -> FontGroup {
/// The font's group and, for a CJK group, its family name in that language (typographic family,
/// else family) when it differs from `family`.
pub(crate) fn classify(f: &skrifa::FontRef<'_>, family: &str) -> (FontGroup, Option<String>) {
let group = declared_group(f).unwrap_or_else(|| covered_group(f));
let group = declared_group(f, family).unwrap_or_else(|| covered_group(f));
(group, native_family(f, group).filter(|n| n != family))
}
@@ -253,6 +273,39 @@ mod tests {
assert_eq!(group_of(&latin(1 << 20 | 1 << 21)).0, FontGroup::ChineseTraditional);
}
/// A pan-CJK family's fonts list the code pages of every language they cover and name their
/// language only in the family name ("… CJK SC"): that tag decides, not the menu order.
#[test]
fn a_region_tag_in_the_family_name_decides_between_several_code_pages() {
let pan = 1 << 17 | 1 << 18 | 1 << 19 | 1 << 20 | 1 << 21;
let group = |family: &str, range1: u32| {
let font = with_code_pages(font_with(family, &['a', 'b']).unwrap(), range1, 0).unwrap();
classify(&skrifa::FontRef::new(&font).unwrap(), family).0
};
for (family, expected) in [
("DC Sans CJK SC R", FontGroup::ChineseSimplified),
("DC Sans CJK SC", FontGroup::ChineseSimplified),
("DC Sans GB", FontGroup::ChineseSimplified),
("DC Serif CJK TC", FontGroup::ChineseTraditional),
("DC Sans HK", FontGroup::ChineseTraditional),
("DC Sans CJK KR", FontGroup::Korean),
("DC Sans CJK JP", FontGroup::Japanese),
("DC-Sans-SC", FontGroup::ChineseSimplified),
// No tag, or not a whole word in capitals: the first in menu order, as before.
("DC Sans", FontGroup::Japanese),
("DC Script", FontGroup::Japanese),
("DC Sans Sc", FontGroup::Japanese),
] {
assert_eq!(group(family, pan), expected, "{family}");
}
// The tag names a language the font doesn't list: its code pages decide.
assert_eq!(group("DC Sans SC", 1 << 17 | 1 << 19), FontGroup::Japanese);
// One CJK code page is unambiguous, whatever the name says.
assert_eq!(group("DC Sans SC", 1 << 17), FontGroup::Japanese);
// No CJK code page: the tag alone doesn't make a font CJK.
assert_eq!(group("DC Sans SC", 1), FontGroup::Western);
}
#[test]
fn name_languages_give_the_group_and_the_native_name() {
let jp = named(&latin(1), &[(3, 1, 0x0411, 1, "テスト明朝"), (3, 1, 0x0411, 16, "テスト明朝 Pro")]);
+13 -1
View File
@@ -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('-') {
+24
View File
@@ -67,6 +67,30 @@ pub fn font_mapping(family: &str, glyphs: &[(char, char)]) -> Option<Vec<u8>> {
assemble(base, tables)
}
/// [`font_with`] for an optical-size face: typographic names `family` / `style` (name IDs 16 and
/// 17) and WWS names `wws_family` / `wws_style` (IDs 21 and 22), the way InDesign names it
/// ("Kepler Std" / "Light Display" and "Kepler Std Display" / "Light"). `None` if the bundled font
/// can't be read.
pub fn font_with_wws(family: &str, style: &str, wws_family: &str, wws_style: &str, chars: &[char]) -> Option<Vec<u8>> {
let base = font_with(family, chars)?;
let entries = [(1u16, family), (2, style), (16, family), (17, style), (21, wws_family), (22, wws_style)];
let count = u16::try_from(entries.len()).ok()?;
let mut name = Vec::new();
for v in [0u16, count, 6 + count * 12] {
name.extend_from_slice(&v.to_be_bytes());
}
let mut at = 0usize;
for (id, s) in entries {
let len = s.encode_utf16().count() * 2;
for v in [3u16, 1, 0x409, id, u16::try_from(len).ok()?, u16::try_from(at).ok()?] {
name.extend_from_slice(&v.to_be_bytes());
}
at += len;
}
entries.iter().for_each(|(_, s)| name.extend(s.encode_utf16().flat_map(u16::to_be_bytes)));
with_tables(&base, vec![(*b"name", name)])
}
/// Every table of the font file `font`: (tag, bytes).
fn tables_of(font: &[u8]) -> Option<Vec<([u8; 4], Vec<u8>)>> {
let be16 = |b: &[u8], at: usize| b.get(at..at + 2).and_then(|s| s.try_into().ok()).map(u16::from_be_bytes);

Some files were not shown because too many files have changed in this diff Show More