mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-13 22:02:31 +00:00
Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts: # docs/ui-parity.md
This commit is contained in:
commit
ab96d42696
65 files changed
+2783
-343
No files matched your search
Generated
+65
-27
@@ -1306,7 +1306,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"craft-update",
|
||||
@@ -1330,7 +1330,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-cli"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"designcraft-color",
|
||||
"designcraft-compose",
|
||||
@@ -1341,12 +1341,13 @@ dependencies = [
|
||||
"designcraft-mcp",
|
||||
"designcraft-pdf",
|
||||
"designcraft-render",
|
||||
"image",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-color"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-cms",
|
||||
"kurbo",
|
||||
@@ -1358,7 +1359,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-compose"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"designcraft-color",
|
||||
"designcraft-doc",
|
||||
@@ -1378,7 +1379,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-doc"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"designcraft-color",
|
||||
@@ -1391,7 +1392,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-engine"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"caseless",
|
||||
"craft-atomic",
|
||||
@@ -1431,7 +1432,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-epub"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"designcraft-color",
|
||||
@@ -1441,7 +1442,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-fonts"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"core-text",
|
||||
"harfrust 0.13.3",
|
||||
@@ -1454,7 +1455,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-format"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"designcraft-doc",
|
||||
"designcraft-images",
|
||||
@@ -1466,7 +1467,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-geom"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"flo_curves",
|
||||
"kurbo",
|
||||
@@ -1479,7 +1480,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-idml"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"designcraft-color",
|
||||
"designcraft-compose",
|
||||
@@ -1494,7 +1495,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-images"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-cms",
|
||||
"designcraft-color",
|
||||
@@ -1512,7 +1513,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-mcp"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"designcraft-engine",
|
||||
"designcraft-geom",
|
||||
@@ -1525,7 +1526,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-pdf"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-cms",
|
||||
@@ -1551,7 +1552,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-pptx"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"designcraft-color",
|
||||
@@ -1569,7 +1570,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-render"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-pdf",
|
||||
"designcraft-color",
|
||||
@@ -1588,7 +1589,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-textimport"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"designcraft-doc",
|
||||
"quick-xml 0.41.0",
|
||||
@@ -1597,7 +1598,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-tools"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"designcraft-color",
|
||||
"designcraft-compose",
|
||||
@@ -1609,7 +1610,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-ui-egui"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-dialogs",
|
||||
"craft-keymap",
|
||||
@@ -1641,7 +1642,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-web"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"designcraft-engine",
|
||||
"designcraft-format",
|
||||
@@ -3138,7 +3139,7 @@ checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c"
|
||||
[[package]]
|
||||
name = "indd"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/indd-utils?branch=main#7f3060e5bce78d21ea70e0d5eacb96edce15dfbe"
|
||||
source = "git+https://github.com/storytold/indd-utils?rev=7f3060e5bce78d21ea70e0d5eacb96edce15dfbe#7f3060e5bce78d21ea70e0d5eacb96edce15dfbe"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
@@ -3337,7 +3338,7 @@ dependencies = [
|
||||
"skrifa 0.42.1",
|
||||
"smallvec",
|
||||
"subsetter",
|
||||
"tiny-skia-path",
|
||||
"tiny-skia-path 0.12.0",
|
||||
"xmp-writer",
|
||||
"yoke",
|
||||
"zune-jpeg",
|
||||
@@ -3355,7 +3356,7 @@ dependencies = [
|
||||
"resvg",
|
||||
"skrifa 0.42.1",
|
||||
"smallvec",
|
||||
"tiny-skia",
|
||||
"tiny-skia 0.12.0",
|
||||
"usvg",
|
||||
]
|
||||
|
||||
@@ -4932,7 +4933,7 @@ dependencies = [
|
||||
"pico-args",
|
||||
"rgb",
|
||||
"svgtypes",
|
||||
"tiny-skia",
|
||||
"tiny-skia 0.12.0",
|
||||
"usvg",
|
||||
"zune-jpeg",
|
||||
]
|
||||
@@ -5210,6 +5211,17 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "sctk-adwaita"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
|
||||
dependencies = [
|
||||
"log",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"tiny-skia 0.11.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -6032,6 +6044,20 @@ dependencies = [
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
"log",
|
||||
"tiny-skia-path 0.11.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia"
|
||||
version = "0.12.0"
|
||||
@@ -6044,7 +6070,18 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"log",
|
||||
"png",
|
||||
"tiny-skia-path",
|
||||
"tiny-skia-path 0.12.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia-path"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"bytemuck",
|
||||
"strict-num",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6468,7 +6505,7 @@ dependencies = [
|
||||
"siphasher",
|
||||
"strict-num",
|
||||
"svgtypes",
|
||||
"tiny-skia-path",
|
||||
"tiny-skia-path 0.12.0",
|
||||
"ttf-parser",
|
||||
"unicode-bidi",
|
||||
"unicode-script",
|
||||
@@ -7361,6 +7398,7 @@ dependencies = [
|
||||
"raw-window-handle",
|
||||
"redox_syscall 0.4.1",
|
||||
"rustix 0.38.44",
|
||||
"sctk-adwaita",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"smol_str",
|
||||
"tracing",
|
||||
@@ -7605,7 +7643,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+6
-3
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -76,9 +76,12 @@ flo_curves = "0.8"
|
||||
qrcodegen = "1.8"
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
# INDD/INDT → IDML conversion for File › Open: storytold/indd-utils, tracking its main branch
|
||||
# INDD/INDT → IDML conversion for File › Open: storytold/indd-utils, pinned by rev as every other
|
||||
# git dependency here is. It parses untrusted INDD files, and `branch = "main"` meant any
|
||||
# `cargo update` -- Dependabot runs them -- pulled whatever was on that branch into the parser
|
||||
# without review (#1599).
|
||||
# (MIT OR Apache-2.0, no dependencies, builds for wasm).
|
||||
indd = { git = "https://github.com/storytold/indd-utils", branch = "main" }
|
||||
indd = { git = "https://github.com/storytold/indd-utils", rev = "7f3060e5bce78d21ea70e0d5eacb96edce15dfbe" }
|
||||
|
||||
# Which paths stored in documents may be followed (craft-libs).
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
|
||||
@@ -173,6 +173,37 @@ Assets already bundled through Hayro's default features retain their licences:
|
||||
Adobe and contributor names do not endorse or promote this product.
|
||||
Per-file hashes and sources are recorded in ASSETS.md.
|
||||
|
||||
Linux Wayland window decorations
|
||||
-------------------------------
|
||||
sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita,
|
||||
commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux
|
||||
for vector window controls. Its title-font features are disabled: no Cantarell
|
||||
font is embedded by this dependency and no font file is added to this repo.
|
||||
|
||||
sctk-adwaita's MIT licence:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Bartłomiej Maryńczak
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
Bundled egui font icons
|
||||
|
||||
The pinned egui dependency includes egui-icons.ttf, a small subset of John Slegers'
|
||||
|
||||
+6
-2
@@ -4,7 +4,7 @@
|
||||
- 2026-10-23: placed-artwork layer options survive IDML and native round trips per placement, independently of shared assets. Unsupported visibility overrides are explicit import/preflight/PDF warnings; format 4 prevents older native readers from silently discarding them. Rendering parity is still open; see [graphic-layer-options.md](docs/graphic-layer-options.md).
|
||||
- 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.
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (placed graphic layer-option preservation and explicit rendering warnings; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-01 · **Change:** minor (placed graphic layer-option preservation and explicit rendering warnings; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026
|
||||
- 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.
|
||||
@@ -383,6 +383,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
|
||||
## Progress log
|
||||
|
||||
- **2026-11-02** · The Pen shows the segment a dragged smooth point shapes while it is dragged, and its rubber band curves out of a smooth point's direction line (#1438, first item). No readiness percentage change.
|
||||
- **2026-11-02** · A Custom text variable defined with a number (a typed `2026` reaches the command as one) shows its digits instead of nothing (#2093, first item). No readiness percentage change.
|
||||
- **2026-11-02** · Windows: the custom title bar window stays borderless, so the content is no longer drawn offset by the caption and border (black bars at the top and left, clicks landing off target). The workaround goes when winit carries the fix (storytold/winit#17). No readiness percentage change.
|
||||
- **2026-10-30** · The Color Picker takes an eyedropper: dragged from the picker onto the page, it takes the colour the canvas shows (proofed under Proof Colors). No readiness percentage change.
|
||||
- **2026-10-30** · The Color Picker is laid out as the reference app's: a field and slider for the component chosen, HSB, Lab, RGB and CMYK values, the old colour, and Add CMYK Swatch; Enter and Esc close it once. No readiness percentage change.
|
||||
@@ -390,8 +391,10 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
|
||||
|
||||
Newest first.
|
||||
|
||||
- **2026-11-03** · `type.char` says when the font it applies isn't available (#2189): it returns `{"warnings": ["Missing font: Courier Prime (using Source Sans 3)"]}`, or `Missing font style: …` for a style the family doesn't have, in preflight's words. The font is still applied, as before, so Find Font and preflight list it. A script that asked for a font the machine doesn't have exported in the substitute with no word about it. No readiness percentage change.
|
||||
- **2026-11-01** · Parent page resizing (#1620): Properties, the Page Tool Control panel and on-page handles resize the selected parent page independently of document pages. Stable parent IDs, repeated resizing, anchors and liquid rules are preserved on the current command modules. Inline parent margin editing targets only the selected parent page and preserves opposite-page margins and document overrides; document commands retain their first-page default before the canvas is initialized. No readiness percentage change.
|
||||
- **2026-10-31** · `font.list` reports the embedding licence of the face that sets each font's text: its OS/2 `fsType` and what the bits mean (`restrictedLicence`, `bitmapOnly`, `noSubsetting`, `editable`, `previewPrint`; null when the font is missing or has no OS/2 table). A script or host that makes PDFs can check every face, bundled and installed ones included, without reading font files. Export behaviour is unchanged. No readiness percentage change.
|
||||
- **2026-10-31** · A `.designcraft` file opened from bytes (`file.openBytes` with its file name, as a browser or host passes it) is titled without the extension, as an IDML file opened that way is. Saving it in the browser downloaded `Brochure.designcraft.designcraft`, and the File Name variable showed the doubled name. Browser Open and Drop pass the full file name, so only the engine removes the extension and a repeated suffix is kept in the title. No readiness percentage change.
|
||||
- **2026-10-31** · A `.designcraft` file opened from bytes (`file.openBytes` with its file name, as a browser or host passes it) is titled without the extension, as an IDML file opened that way is. Saving it in the browser downloaded `Brochure.designcraft.designcraft`, and the File Name variable showed the doubled name. Browser Open and Drop pass the full file name, so only the engine removes the extension and a repeated suffix is kept in the title. Native and IDML content takes priority over a misleading `.indd` or `.indt` extension. No readiness percentage change.
|
||||
- **2026-10-30** · Import warnings about a story have a Show button: it closes the warnings (the status line keeps them) and selects the story's first frame, fitted in the window. `file.open` returns `warningItems`, the frame each IDML or InDesign warning is about (or null), matching the converter's INDD story numbers (`story 30892`, written to IDML as `u78ac`) and IDML `Self` names to the imported stories. No readiness percentage change.
|
||||
- **2026-10-30** · Parent pages: a running-header variable shows its name in angle brackets on the parent itself (it was blank), as InDesign shows it; on the document pages, and in text with no page, it shows its text as before. No readiness percentage change.
|
||||
- **2026-10-30** · Without facing pages, a page of a multi-page spread shows the parent page in its place, as in InDesign: the second page of a two-page spread shows the parent's second page. Every single-sided page showed the parent's first page, so an InDesign book set up without facing pages but laid out in two-page spreads repeated the left parent page's items (a black heading bar) on its right pages and lost the right parent page's. No readiness percentage change.
|
||||
@@ -420,6 +423,7 @@ Newest first.
|
||||
- **2026-10-24** · Renaming a paragraph or character style, or moving it into a style group, keeps its Export Tagging under the new name, and undo puts it back under the old one. Deleting a tagged style drops its tagging and leaves the replacement style's own tagging as it was. No readiness percentage change.
|
||||
- **2026-10-24** · Quick Apply and the application bar's search leave out the interface commands that failed or reset a setting when chosen there: those that need a workspace, a command and shortcut, a menu item, a panel, a window edge, an app page, a recent file or a swatch they can't give them, a Contextual Task Bar place, and Interface Color Theme, UI Scaling, Reset Shortcuts, the flattener preset, Screen Mode and Zoom To, which without a value switched to the dark theme, set 100%, cleared every custom shortcut or the preset, or went to Normal mode or 100% zoom. Each stays in the menu, panel or dialog that gives it the value. No readiness percentage change.
|
||||
- **2026-10-24** · EPUB Export Options › Split Document, a dialog before File › Export EPUB… saves (`document.epubSplit`, stored with the document): off, a single paragraph style, or based on paragraph style export tags, with Paragraph Style Options › Export Tagging's Split Document flag marking those styles. A reflowable EPUB is one content file unless Split Document is on; the split before each top-level h1 from #791 is gone. A split before a list item or preformatted line no longer leaves an unclosed list or block in a content file. IDML import and export keep the setting and the flag, and Load Styles the flag. No readiness percentage change.
|
||||
- **2026-10-24** · Linux file managers show a `.designcraft` file's first page instead of a blank icon: the packages install a freedesktop thumbnailer (`share/thumbnailers/ai.storyteller.designcraft.thumbnailer`) that runs `designcraft-cli thumbnail FILE OUT.png SIZE`, which writes the `preview.png` saving already puts in the file, fitted to the size asked for and never enlarged. A file without a preview fails, so the file manager keeps the file type's icon (#1782). No readiness percentage change.
|
||||
- **2026-10-18** · Repeated placed-PDF layer variants are prepared once per asset and hidden-name set, with recovery warnings retained. All-visible named layers keep their original source bytes, including PDFs whose cross-reference tables the reader repairs, in plain, PDF/X-4, booklet and book-merge output. Layer changes that require writing still reject an unusable cross-reference table with a file-named damage error; the first placement error is retained (#906). No readiness percentage change.
|
||||
- **2026-10-18** · Placed PDF optional-content defaults and per-frame overrides survive plain and PDF/X-4 vector export, including repeated placements, booklet output and book merging (#906). Source usage-application arrays are accepted; groups without a catalog configuration stay visible. Missing or unreadable default configurations and unsupported base states use all-ON defaults with a file-named export warning, retaining explicit frame overrides. Readable groups omitted from the catalog list are added with a distinct file-named warning, preserving their default ON/OFF states. Concurrent recovery warnings are combined. Malformed ON data is recoverable when a known ON base and empty OFF list prove it cannot hide artwork; missing display names are generated without changing known visibility when no override needs name matching. Contradictory states, unresolved references and ambiguous name overrides still fail export. Print-usage adjustments and IDML layer overrides remain follow-ups. No readiness percentage change.
|
||||
|
||||
|
||||
@@ -22,4 +22,5 @@ designcraft-compose = { workspace = true }
|
||||
designcraft-format = { workspace = true }
|
||||
designcraft-pdf = { workspace = true }
|
||||
designcraft-mcp = { workspace = true }
|
||||
image = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -16,6 +16,8 @@
|
||||
//! designcraft-cli bench FILE [--runs N] # the same measurements on one document
|
||||
//! designcraft-cli validate-pdf FILE.pdf [--standard x4|x1a] # the built-in PDF/X-4 or PDF/X-1a checks (default: the
|
||||
//! # file's own GTS_PDFXVersion)
|
||||
//! designcraft-cli thumbnail FILE.designcraft OUT.png [SIZE] # the file's first-page preview, fitted to SIZE
|
||||
//! # (the freedesktop thumbnailer for Linux file managers)
|
||||
//! designcraft-cli links # Discord, website, app page and GitHub links
|
||||
//! designcraft-cli --version # print the version
|
||||
//! ```
|
||||
@@ -40,7 +42,7 @@ 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] [--automation-read-root DIR]... [--automation-write-root DIR]...\n the 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. Each may be given more than once; a kind\n 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 validate-pdf FILE.pdf # built-in PDF/X-4 checks (including visible unembedded fonts)\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 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. Each may be given more than once; a kind\n 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 validate-pdf FILE.pdf # built-in PDF/X-4 checks (including visible unembedded fonts)\n designcraft-cli thumbnail FILE.designcraft OUT.png [SIZE] # the saved first-page preview, at most SIZE px\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]
|
||||
@@ -124,6 +126,7 @@ fn main() -> ExitCode {
|
||||
Some("perf") => report(perf::perf(&args[1..])),
|
||||
Some("bench") => report(perf::bench(&args[1..])),
|
||||
Some("validate-pdf") => report(validate_pdf(args.get(1..).unwrap_or_default())),
|
||||
Some("thumbnail") => report(thumbnail(&args[1..])),
|
||||
Some("links") => {
|
||||
use designcraft_engine::links::{APP_PAGE, DISCORD, GITHUB, ISSUES, WEBSITE};
|
||||
outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}\nIssues {ISSUES}");
|
||||
@@ -187,6 +190,61 @@ fn validate_pdf(args: &[String]) -> Result<(), String> {
|
||||
if valid { Ok(()) } else { Err(format!("{path} failed {name} validation")) }
|
||||
}
|
||||
|
||||
/// `thumbnail FILE OUT [SIZE]`: the first-page preview saved in a `.designcraft` file, as a PNG
|
||||
/// whose longer side is at most SIZE pixels (never enlarged). It is the freedesktop thumbnailer
|
||||
/// (`packaging/linux/ai.storyteller.designcraft.thumbnailer`), so it only reads the preview: a file
|
||||
/// without one fails, and the file manager keeps the file type's icon.
|
||||
fn thumbnail(args: &[String]) -> Result<(), String> {
|
||||
const USAGE: &str = "usage: designcraft-cli thumbnail FILE.designcraft OUT.png [SIZE]";
|
||||
let [input, out, rest @ ..] = args else { return Err(USAGE.into()) };
|
||||
let size = match rest {
|
||||
[] => None,
|
||||
[s] => Some(s.parse::<u32>().ok().filter(|s| *s > 0).ok_or_else(|| format!("bad SIZE `{s}`: expected a positive number of pixels"))?),
|
||||
_ => return Err(USAGE.into()),
|
||||
};
|
||||
if names_the_same_file(input.as_ref(), out.as_ref()) {
|
||||
return Err(format!("{out} is the document {input}: the thumbnail would replace it"));
|
||||
}
|
||||
let file = std::fs::File::open(input).map_err(|e| format!("{input}: {e}"))?;
|
||||
let preview = designcraft_format::read_preview(std::io::BufReader::new(file))
|
||||
.ok_or_else(|| format!("{input} has no preview (a DesignCraft file saved before they had one, or not a DesignCraft file)"))?;
|
||||
let png = fit_png(preview, size).map_err(|e| format!("{input}: the preview can't be read: {e}"))?;
|
||||
designcraft_engine::atomic::write_atomic(std::path::Path::new(out), &png).map_err(|e| format!("{out}: {e}"))
|
||||
}
|
||||
|
||||
/// Whether `out` is the file `input` names: the same path, a symlink to it or, on Unix, a hard
|
||||
/// link (the same device and inode). An `out` that doesn't exist yet can't be the input.
|
||||
fn names_the_same_file(input: &std::path::Path, out: &std::path::Path) -> bool {
|
||||
let (Ok(a), Ok(b)) = (std::fs::canonicalize(input), std::fs::canonicalize(out)) else { return false };
|
||||
if a == b {
|
||||
return true;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
if let (Ok(a), Ok(b)) = (std::fs::metadata(&a), std::fs::metadata(&b)) {
|
||||
return (a.dev(), a.ino()) == (b.dev(), b.ino());
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// `png` with its longer side brought down to `size`. A picture already that small is returned
|
||||
/// as it is; it is decoded anyway, so a broken one is an error rather than an unreadable thumbnail.
|
||||
fn fit_png(png: Vec<u8>, size: Option<u32>) -> Result<Vec<u8>, String> {
|
||||
// The preview comes from the file: it may claim any size.
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(4096);
|
||||
limits.max_image_height = Some(4096);
|
||||
let mut reader = image::ImageReader::with_format(std::io::Cursor::new(&png), image::ImageFormat::Png);
|
||||
reader.limits(limits);
|
||||
let img = reader.decode().map_err(|e| e.to_string())?;
|
||||
let Some(size) = size.filter(|s| img.width().max(img.height()) > *s) else { return Ok(png) };
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
img.resize(size, size, image::imageops::FilterType::Triangle).write_to(&mut out, image::ImageFormat::Png).map_err(|e| e.to_string())?;
|
||||
Ok(out.into_inner())
|
||||
}
|
||||
|
||||
/// `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> {
|
||||
|
||||
@@ -556,3 +556,110 @@ fn usage_lists_the_automation_root_flags() {
|
||||
let usage = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(usage.contains("--automation-read-root DIR") && usage.contains("--automation-write-root DIR"), "{usage}");
|
||||
}
|
||||
|
||||
/// Width and height from a PNG's IHDR chunk.
|
||||
fn png_size(png: &[u8]) -> (u32, u32) {
|
||||
assert!(png.starts_with(b"\x89PNG\r\n\x1a\n"), "not a PNG");
|
||||
let be = |at: usize| u32::from_be_bytes(png[at..at + 4].try_into().unwrap());
|
||||
(be(16), be(20))
|
||||
}
|
||||
|
||||
/// Linux file managers (Dolphin, Nautilus, Thunar) draw a `.designcraft` file's icon through the
|
||||
/// freedesktop thumbnailer, which runs `designcraft-cli thumbnail %i %o %s` (#1782). The picture is
|
||||
/// the first-page preview the file already holds, fitted to the size asked for and never enlarged.
|
||||
#[test]
|
||||
fn thumbnail_writes_the_saved_preview_fitted_to_the_size() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-cli-thumb-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let doc = dir.join("a.designcraft");
|
||||
make_document(&doc);
|
||||
let preview = designcraft_format::read_preview(std::fs::File::open(&doc).unwrap()).unwrap();
|
||||
let (pw, ph) = png_size(&preview);
|
||||
assert!(pw.max(ph) > 128, "the saved preview is {pw}×{ph}");
|
||||
|
||||
let small = dir.join("small.png");
|
||||
let out = cli().arg("thumbnail").arg(&doc).arg(&small).arg("128").output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let (w, h) = png_size(&std::fs::read(&small).unwrap());
|
||||
assert_eq!(w.max(h), 128, "{w}×{h} from a {pw}×{ph} preview");
|
||||
// The page's proportions stay.
|
||||
let ratio = |a: u32, b: u32| f64::from(a) / f64::from(b);
|
||||
assert!((ratio(w, h) - ratio(pw, ph)).abs() < 0.02, "{w}×{h} from a {pw}×{ph} preview");
|
||||
|
||||
let large = dir.join("large.png");
|
||||
let out = cli().arg("thumbnail").arg(&doc).arg(&large).arg("1024").output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
assert_eq!(std::fs::read(&large).unwrap(), preview, "a preview smaller than the size is written as saved");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A file saved without a preview (by an older version, or not by DesignCraft) has no picture: the
|
||||
/// thumbnailer fails and writes nothing, so the file manager keeps the file type's icon.
|
||||
#[test]
|
||||
fn thumbnail_of_a_file_without_a_preview_fails_and_writes_nothing() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-cli-thumb-none-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let with = dir.join("with.designcraft");
|
||||
make_document(&with);
|
||||
let doc = designcraft_format::load(&std::fs::read(&with).unwrap()).unwrap();
|
||||
let without = dir.join("without.designcraft");
|
||||
std::fs::write(&without, designcraft_format::save(&doc).unwrap()).unwrap();
|
||||
let text = dir.join("notes.designcraft");
|
||||
std::fs::write(&text, "not a document").unwrap();
|
||||
for input in [&without, &text] {
|
||||
let png = dir.join("out.png");
|
||||
let out = cli().arg("thumbnail").arg(input).arg(&png).arg("256").output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(!out.status.success(), "{}", input.display());
|
||||
assert!(err.contains("no preview"), "{}: {err}", input.display());
|
||||
assert!(!png.exists(), "{}", input.display());
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `thumbnail a.designcraft a.designcraft` would replace the document with a PNG. An output that
|
||||
/// names the input, by the same path, through a symlink or (on Unix) as a hard link, is refused
|
||||
/// before anything is read or written, and the document keeps its bytes.
|
||||
#[test]
|
||||
fn thumbnail_refuses_an_output_that_names_the_input() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-cli-thumb-self-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let doc = dir.join("a.designcraft");
|
||||
make_document(&doc);
|
||||
let saved = std::fs::read(&doc).unwrap();
|
||||
let mut outs = vec![doc.clone(), dir.join(".").join("a.designcraft")];
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let link = dir.join("link.png");
|
||||
std::os::unix::fs::symlink(&doc, &link).unwrap();
|
||||
outs.push(link);
|
||||
let hard = dir.join("hard.png");
|
||||
std::fs::hard_link(&doc, &hard).unwrap();
|
||||
outs.push(hard);
|
||||
}
|
||||
for out_path in &outs {
|
||||
let out = cli().arg("thumbnail").arg(&doc).arg(out_path).arg("128").output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert_eq!(out.status.code(), Some(1), "{}: {err}", out_path.display());
|
||||
assert!(err.contains("is the document"), "{}: {err}", out_path.display());
|
||||
assert!(std::fs::read(&doc).unwrap() == saved, "{} changed the document", out_path.display());
|
||||
assert!(std::fs::read(out_path).unwrap() == saved, "{} was written", out_path.display());
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Another existing file at the output path is still replaced by the thumbnail.
|
||||
#[test]
|
||||
fn thumbnail_replaces_another_existing_file() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-cli-thumb-other-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let doc = dir.join("a.designcraft");
|
||||
make_document(&doc);
|
||||
let png = dir.join("old.png");
|
||||
std::fs::write(&png, b"an older thumbnail").unwrap();
|
||||
let out = cli().arg("thumbnail").arg(&doc).arg(&png).arg("128").output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let (w, h) = png_size(&std::fs::read(&png).unwrap());
|
||||
assert_eq!(w.max(h), 128, "{w}×{h}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -56,3 +56,8 @@ craft-window = { workspace = true }
|
||||
# PdfCraft and WordCraft) registers the handler behind a safe API (Objective-C interop needs unsafe,
|
||||
# which the workspace forbids). Pinned exactly: review the diff before bumping.
|
||||
fmv-macos-events = "=0.1.0"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Native GNOME/Wayland controls without a bundled title font.
|
||||
winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita-notitle"] }
|
||||
|
||||
@@ -678,7 +678,12 @@ fn restart_without<'a>(graphics_failed: bool, frames: u64, picked: Option<&'a st
|
||||
/// ended it.
|
||||
pub fn finish(outcome: Result<eframe::Result, String>, startup: &Startup) -> Result<(), String> {
|
||||
let (why, graphics_failed) = match outcome {
|
||||
Ok(Ok(())) => return Ok(()),
|
||||
// The window closed normally, perhaps before its first frame or before the app was
|
||||
// created (so `on_exit` never ran): no driver fault took this start down (#2116).
|
||||
Ok(Ok(())) => {
|
||||
startup.presented();
|
||||
return Ok(());
|
||||
}
|
||||
Ok(Err(e @ eframe::Error::Wgpu(_))) => (e.to_string(), true),
|
||||
Ok(Err(e)) => (e.to_string(), false),
|
||||
Err(panic) => (panic, GRAPHICS_PANIC.load(Ordering::Relaxed)),
|
||||
|
||||
@@ -14,8 +14,9 @@
|
||||
//! fallback off.
|
||||
//!
|
||||
//! The fallback: before the window is created, `gpu.json` in the settings directory records the
|
||||
//! backend being tried; once a frame has been presented, or the graphics failed with an error the
|
||||
//! app caught (see `gpu::finish`), that entry is cleared. An entry still there at the next start
|
||||
//! backend being tried; once a frame has been presented, the window closed normally (even before
|
||||
//! its first frame), or the graphics failed with an error the app caught (see `gpu::finish`), that
|
||||
//! entry is cleared. An entry still there at the next start
|
||||
//! means the previous start never got that far — the driver crashed, or the app was killed
|
||||
//! meanwhile — so that backend joins `failed`, the next candidate is tried and the status bar says
|
||||
//! so. Backends a restart left out (`--gpu-skip=`, see `gpu`) aren't chosen for that start but
|
||||
@@ -538,6 +539,23 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(path.parent().expect("dir"));
|
||||
}
|
||||
|
||||
/// A window closed before its first frame (#2116): the app was created slowly or not at all,
|
||||
/// so neither a frame nor `on_exit` cleared the record, but eframe ended cleanly. No driver
|
||||
/// fault took that start down, so the next start keeps the backend.
|
||||
#[test]
|
||||
fn a_start_that_ends_cleanly_before_its_first_frame_does_not_fail_its_backend() {
|
||||
let path = temp_path("clean-end");
|
||||
let fallback = Fallback::begin_unforced(Some(path.clone()), V, &[]).expect("candidates");
|
||||
let first = fallback.backend;
|
||||
assert_eq!(read(&path).trying, Some(first));
|
||||
let startup = super::super::Startup::new(Some(fallback), Vec::new());
|
||||
assert_eq!(super::super::finish(Ok(Ok(())), &startup), Ok(()));
|
||||
assert_eq!(read(&path).trying, None, "a clean end leaves no backend being tried");
|
||||
let c = choose(candidates(), &read(&path), V, &[]).expect("candidates");
|
||||
assert_eq!((c.backend, c.record.failed, c.switched_from), (first, Vec::new(), None));
|
||||
let _ = std::fs::remove_dir_all(path.parent().expect("dir"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_path_nothing_is_written_and_a_bad_path_is_not_fatal() {
|
||||
let mut s = Fallback::begin_unforced(None, V, &[]).expect("candidates");
|
||||
|
||||
@@ -83,3 +83,7 @@ sync_github_name = true
|
||||
|
||||
[people.Tom-R-Main]
|
||||
sync_github_name = true
|
||||
|
||||
[people.JonRC]
|
||||
real_name = "Jonathan Cardoso"
|
||||
display_name = "Jonathan Cardoso"
|
||||
@@ -1817,6 +1817,8 @@ fn place_sub_objects(
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets one paragraph's lines, column after column and frame after frame. It stops when the
|
||||
/// paragraph ends, when the frames run out, or when an earlier paragraph has to be set again.
|
||||
fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLines<'_>) -> LinePlacement {
|
||||
let PlacementShared {
|
||||
doc,
|
||||
@@ -1849,7 +1851,7 @@ fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLi
|
||||
let ParagraphLines {
|
||||
paragraph: pi,
|
||||
range: prange,
|
||||
props: pp,
|
||||
props: para_props,
|
||||
glyphs,
|
||||
hyph_after,
|
||||
drop_cap,
|
||||
@@ -1878,7 +1880,10 @@ fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLi
|
||||
let mut hyphen_at: Option<usize> = None;
|
||||
let has_tabs = glyphs.iter().any(|g| g.ch == '\t');
|
||||
let mut first_line_rect: Option<(usize, f64, f64, f64)> = None; // frame, baseline, ascent, x-span
|
||||
let rtl = pp.direction == designcraft_doc::TextDirection::RightToLeft;
|
||||
let rtl = para_props.direction == designcraft_doc::TextDirection::RightToLeft;
|
||||
// The paragraph's settings with the start-side indent an Indent to Here character set for
|
||||
// the lines after it (InDesign's ^i): those lines start where the character sits.
|
||||
let mut indented: Option<designcraft_doc::ParaProps> = None;
|
||||
// Paragraph gyoudori: the (frame, column) of the block and the grid line after it.
|
||||
let mut para_block: Option<((usize, usize), f64)> = None;
|
||||
// Segments of a line already placed (beside an object with text on both sides) that the
|
||||
@@ -1912,6 +1917,10 @@ fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLi
|
||||
no_hyph,
|
||||
});
|
||||
}
|
||||
let pp = indented.as_ref().unwrap_or(para_props);
|
||||
// The Indent to Here character sits past the drop cap, so the lines that start at it
|
||||
// aren't indented by the drop cap again.
|
||||
let beside_cap = drop_cap.filter(|_| indented.is_none());
|
||||
let f = &frames[cur.fi];
|
||||
let base = cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)];
|
||||
let col = cur.clip(base);
|
||||
@@ -1937,7 +1946,7 @@ fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLi
|
||||
props: pp,
|
||||
slots: &slots,
|
||||
column: col,
|
||||
drop_cap,
|
||||
drop_cap: beside_cap,
|
||||
line_no,
|
||||
col_first_line,
|
||||
rtl,
|
||||
@@ -1945,6 +1954,8 @@ fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLi
|
||||
spacing,
|
||||
});
|
||||
let mut moved = false;
|
||||
// Set by a line with an Indent to Here character, for the lines broken again after it.
|
||||
let mut reindented = None;
|
||||
for (k, b) in breaks.iter().enumerate() {
|
||||
let (s, e) = (g0 + b.start, g0 + b.end);
|
||||
let line_glyphs = &glyphs[s..e.max(s)];
|
||||
@@ -2060,7 +2071,7 @@ fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLi
|
||||
let col_w = notes.area(&cols[cur.fi], cur.col).map_or(0.0, |r| r.width());
|
||||
let note_column = NoteColumn { frame_index: cur.fi, column_index: cur.col, width: col_w, frame: f };
|
||||
// A drop cap sits at the start-side indent; the first-line indent doesn't move it.
|
||||
let beside = drop_cap.filter(|dc| line_no < dc.lines && col_first_line == 0).map(|dc| dc.indent());
|
||||
let beside = beside_cap.filter(|dc| line_no < dc.lines && col_first_line == 0).map(|dc| dc.indent());
|
||||
let first_line = if line_no == 0 { pp.first_line_indent } else { 0.0 };
|
||||
let (ind_l, ind_r) = match beside {
|
||||
Some(w) if rtl => (pp.left_indent, pp.right_indent + w),
|
||||
@@ -2112,6 +2123,7 @@ fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLi
|
||||
}
|
||||
ruby::annotate(db, styles_tab, &story.text, &mut placed, doc.settings.glyph_fallback, f.vertical);
|
||||
kenten::annotate(db, styles_tab, &mut placed, doc.settings.glyph_fallback);
|
||||
let here_props = indent_here(&placed, pp, x0, x1, rtl);
|
||||
let range_end = if last { prange.end } else { glyphs.get(g0 + b.next).map(|g| g.byte).unwrap_or(prange.end) };
|
||||
let range_start = glyphs.get(s).map(|g| g.byte).unwrap_or(prange.start).min(range_end);
|
||||
let range_start = if line_no == 0 { prange.start } else { range_start };
|
||||
@@ -2170,6 +2182,10 @@ fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLi
|
||||
hyphen_at =
|
||||
e.checked_sub(1).filter(|&i| b.hyphen && hyph_after.get(i) == Some(&true)).and_then(|i| glyphs.get(i)).map(|g| g.byte + g.len);
|
||||
line_no += 1;
|
||||
// An Indent to Here character in this line moves the start of the lines after it:
|
||||
// break them again with that indent (it holds into the next columns too).
|
||||
let reindent = here_props.is_some();
|
||||
reindented = here_props.or(reindented);
|
||||
// A column / frame / page break inside the paragraph: the rest of it continues in the
|
||||
// new column/frame/page, re-broken there. (A break that ends the paragraph moves the
|
||||
// next paragraph instead.)
|
||||
@@ -2183,12 +2199,13 @@ fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLi
|
||||
}
|
||||
// An own-story anchored object anchored in this line wraps the lines after it,
|
||||
// which were broken without it: break them again.
|
||||
if k + 1 < breaks.len() && line_has_wrapping_exclusion(f, range_start..range_end, col, baseline) {
|
||||
if k + 1 < breaks.len() && (reindent || line_has_wrapping_exclusion(f, range_start..range_end, col, baseline)) {
|
||||
g0 += b.next;
|
||||
moved = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
indented = reindented.or(indented);
|
||||
if !moved {
|
||||
break;
|
||||
}
|
||||
@@ -4949,6 +4966,34 @@ fn distribute(line: &[Glyph], spaces: &[usize], extra: f64, sp: &Spacing, add: &
|
||||
}
|
||||
}
|
||||
|
||||
/// The paragraph settings for the lines after the `placed` line when it holds an Indent to Here
|
||||
/// character (the last one, if several): the start-side indent reaches the character, measured
|
||||
/// from the start edge of the line's segment `x0..x1`. Like the paragraph's own indents it then
|
||||
/// applies from each later line's segment, so beside a text wrap it starts at the wrap's edge.
|
||||
/// `None` when there is none or the indent is unchanged.
|
||||
fn indent_here(placed: &[PlacedGlyph], pp: &designcraft_doc::ParaProps, x0: f64, x1: f64, rtl: bool) -> Option<designcraft_doc::ParaProps> {
|
||||
// The placed glyphs are in the line's order (bidi only moves them): the drop cap's, then a
|
||||
// list label's, which is set beside the drop cap, then the text's. A character in the drop
|
||||
// cap or the label counts too, and one in the label comes after one in the drop cap.
|
||||
let x = placed.iter().rev().find(|g| g.rendered_char == story::INDENT_HERE)?.x;
|
||||
let indent = if rtl { x1 - x } else { x - x0 };
|
||||
if !indent.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let indent = indent.max(0.0);
|
||||
let current = if rtl { pp.right_indent } else { pp.left_indent };
|
||||
if (indent - current).abs() < 0.01 {
|
||||
return None;
|
||||
}
|
||||
let mut props = pp.clone();
|
||||
if rtl {
|
||||
props.right_indent = indent;
|
||||
} else {
|
||||
props.left_indent = indent;
|
||||
}
|
||||
Some(props)
|
||||
}
|
||||
|
||||
fn place(g: &Glyph, x: f64) -> PlacedGlyph {
|
||||
let visible = !(g.ch == '\t'
|
||||
|| g.ch == '\n'
|
||||
|
||||
@@ -358,10 +358,10 @@ fn stroke_outline(path: &BezPath, w: f64, kind: &StrokeType, cap: Cap, join: Joi
|
||||
let p: Vec<f64> = pattern.iter().copied().filter(|v| v.is_finite() && *v >= 0.0).collect();
|
||||
Some(if p.iter().sum::<f64>() > 1e-3 { p } else { vec![3.0 * w, 2.0 * w] })
|
||||
}
|
||||
StrokeType::Dotted => {
|
||||
st = st.with_caps(kurbo::Cap::Round);
|
||||
Some(vec![0.0, 2.0 * w])
|
||||
}
|
||||
StrokeType::Dotted => match dots(path, w) {
|
||||
Some(d) => return d,
|
||||
None => None,
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
if let Some(d) = dashes {
|
||||
@@ -372,3 +372,142 @@ fn stroke_outline(path: &BezPath, w: f64, kind: &StrokeType, cap: Cap, join: Joi
|
||||
}
|
||||
kurbo::stroke(path.iter(), &st, &kurbo::StrokeOpts::default(), TOL)
|
||||
}
|
||||
|
||||
/// Round dots `w` wide along `path`, about `2w` apart and evenly spaced by arc length: around a
|
||||
/// closed subpath the spacing divides its length exactly, an open one has a dot at each end.
|
||||
/// `None` (stroke solid) when there would be more than [`MAX_DASHES`] dots.
|
||||
fn dots(path: &BezPath, w: f64) -> Option<BezPath> {
|
||||
use kurbo::{ParamCurve, ParamCurveArclen, PathEl, PathSeg};
|
||||
// Subpaths as (segments, closed).
|
||||
let mut subs: Vec<(Vec<PathSeg>, bool)> = Vec::new();
|
||||
let (mut start, mut cur) = (kurbo::Point::ZERO, kurbo::Point::ZERO);
|
||||
for el in path.elements() {
|
||||
let seg = match *el {
|
||||
PathEl::MoveTo(p) => {
|
||||
subs.push((Vec::new(), false));
|
||||
(start, cur) = (p, p);
|
||||
continue;
|
||||
}
|
||||
PathEl::LineTo(p) => PathSeg::Line(kurbo::Line::new(cur, p)),
|
||||
PathEl::QuadTo(a, p) => PathSeg::Quad(kurbo::QuadBez::new(cur, a, p)),
|
||||
PathEl::CurveTo(a, b, p) => PathSeg::Cubic(kurbo::CubicBez::new(cur, a, b, p)),
|
||||
PathEl::ClosePath => {
|
||||
if let Some(s) = subs.last_mut() {
|
||||
s.1 = true;
|
||||
if cur.distance(start) > 1e-9 {
|
||||
s.0.push(PathSeg::Line(kurbo::Line::new(cur, start)));
|
||||
}
|
||||
}
|
||||
cur = start;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
cur = seg.end();
|
||||
if let Some(s) = subs.last_mut() {
|
||||
s.0.push(seg);
|
||||
}
|
||||
}
|
||||
const ACC: f64 = 1e-4;
|
||||
let mut out = BezPath::new();
|
||||
let mut count = 0.0;
|
||||
for (segs, closed) in subs {
|
||||
let lens: Vec<f64> = segs.iter().map(|s| s.arclen(ACC)).collect();
|
||||
let total: f64 = lens.iter().sum();
|
||||
if !(total.is_finite() && total > 1e-9) {
|
||||
continue;
|
||||
}
|
||||
let n = (total / (2.0 * w)).round().max(1.0);
|
||||
count += n + 1.0;
|
||||
if count > MAX_DASHES {
|
||||
return None;
|
||||
}
|
||||
let step = total / n;
|
||||
let dots = if closed { n as usize } else { n as usize + 1 };
|
||||
let (mut k, mut before) = (0, 0.0);
|
||||
for i in 0..dots {
|
||||
let s = i as f64 * step;
|
||||
while k + 1 < segs.len() && before + lens.get(k).copied().unwrap_or(0.0) < s {
|
||||
before += lens.get(k).copied().unwrap_or(0.0);
|
||||
k += 1;
|
||||
}
|
||||
let (Some(seg), Some(&l)) = (segs.get(k), lens.get(k)) else { continue };
|
||||
let t = if l > 1e-12 { seg.inv_arclen((s - before).clamp(0.0, l), ACC) } else { 0.0 };
|
||||
out.extend(kurbo::Circle::new(seg.eval(t), w / 2.0).path_elements(TOL));
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod dotted_tests {
|
||||
use super::*;
|
||||
|
||||
/// The centres of the dots (one subpath each) of a dotted outline.
|
||||
fn dot_centres(p: &BezPath) -> Vec<kurbo::Point> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur = BezPath::new();
|
||||
for el in p.elements() {
|
||||
if matches!(el, kurbo::PathEl::MoveTo(_)) && !cur.elements().is_empty() {
|
||||
out.push(cur.bounding_box().center());
|
||||
cur = BezPath::new();
|
||||
}
|
||||
cur.push(*el);
|
||||
}
|
||||
if !cur.elements().is_empty() {
|
||||
out.push(cur.bounding_box().center());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Distance along the outline of `r`, clockwise from its top left corner, of point `q` on it.
|
||||
fn along(r: Rect, q: kurbo::Point) -> f64 {
|
||||
let (w, h) = (r.width(), r.height());
|
||||
if (q.y - r.y0).abs() < 1e-6 {
|
||||
q.x - r.x0
|
||||
} else if (q.x - r.x1).abs() < 1e-6 {
|
||||
w + q.y - r.y0
|
||||
} else if (q.y - r.y1).abs() < 1e-6 {
|
||||
w + h + r.x1 - q.x
|
||||
} else {
|
||||
2.0 * w + h + r.y1 - q.y
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_even(gaps: &[f64], expected: f64) {
|
||||
for g in gaps {
|
||||
assert!((g - expected).abs() < 0.05, "uneven dot gaps {gaps:?}, expected {expected}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dotted_box_border_dots_are_evenly_spaced() {
|
||||
let r = Rect::new(0.0, 0.0, 100.3, 37.7);
|
||||
let path = box_outline(r, &[Corner::default(); 4], [true; 4]);
|
||||
let len = 2.0 * (r.width() + r.height());
|
||||
let out = stroke_outline(&path, 1.0, &StrokeType::Dotted, Cap::Butt, Join::Miter, len);
|
||||
let mut s: Vec<f64> = dot_centres(&out).into_iter().map(|q| along(r, q)).collect();
|
||||
s.sort_by(f64::total_cmp);
|
||||
let n = (len / 2.0).round() as usize;
|
||||
assert_eq!(s.len(), n, "dot count");
|
||||
let mut gaps: Vec<f64> = s.windows(2).map(|p| p[1] - p[0]).collect();
|
||||
gaps.push(s[0] + len - s[n - 1]);
|
||||
assert_even(&gaps, len / n as f64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dotted_edge_dots_are_evenly_spaced_and_reach_both_ends() {
|
||||
for (w, l) in [(1.0, 50.3), (2.5, 37.7), (0.7, 100.3)] {
|
||||
let mut p = BezPath::new();
|
||||
p.move_to((0.0, 0.0));
|
||||
p.line_to((l, 0.0));
|
||||
let out = stroke_outline(&p, w, &StrokeType::Dotted, Cap::Butt, Join::Miter, l);
|
||||
let mut xs: Vec<f64> = dot_centres(&out).into_iter().map(|q| q.x).collect();
|
||||
xs.sort_by(f64::total_cmp);
|
||||
let n = (l / (2.0 * w)).round() as usize;
|
||||
assert_eq!(xs.len(), n + 1, "dot count for w {w}, length {l}");
|
||||
assert!(xs[0].abs() < 0.05 && (xs[n] - l).abs() < 0.05, "ends {xs:?}");
|
||||
let gaps: Vec<f64> = xs.windows(2).map(|p| p[1] - p[0]).collect();
|
||||
assert_even(&gaps, l / n as f64);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1906,6 +1906,141 @@ fn tab_leaders_fill_the_gap() {
|
||||
assert!(last_dot < num_x, "leaders stop before the page number");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indent_to_here_starts_the_paragraphs_later_lines_at_the_character() {
|
||||
// "1.", Indent to Here, then text long enough to wrap: every line after the first starts
|
||||
// where the character sits, in this frame and the next one (#2080).
|
||||
let text = format!("1.\u{e005}{}", [LOREM; 3].join(" "));
|
||||
let para = ParaAttrs { left_indent: Some(6.0), ..Default::default() };
|
||||
let (mut d, sid, a) = doc_with(&text, Rect::new(72.0, 36.0, 272.0, 96.0), para.clone());
|
||||
let lid = d.default_layer();
|
||||
let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(72.0, 200.0, 272.0, 800.0), lid, "", ParaFormat::default()).unwrap();
|
||||
d.thread(a, b).unwrap();
|
||||
let cs = compose_story(&d, sid, &Default::default());
|
||||
assert!(!cs.is_overset());
|
||||
assert!(cs.frames[0].lines.len() > 1 && !cs.frames[1].lines.is_empty(), "the text wraps into the second frame");
|
||||
let lines = all_lines(&cs);
|
||||
let here = lines[0].glyphs.iter().find(|g| g.byte == 2 && g.len > 0).unwrap().x;
|
||||
assert!(here > 72.0 + 6.0 + 1.0, "the character follows the number");
|
||||
for (i, line) in lines.iter().enumerate().skip(1) {
|
||||
let start = line.glyphs.iter().find(|g| g.visible).unwrap().x;
|
||||
assert!((start - here).abs() < 0.01, "line {i} starts at {start}, not at the indent {here}");
|
||||
assert!(line.end_x <= 272.0 + 0.01, "line {i} fits the narrower measure");
|
||||
}
|
||||
// Without the character the later lines keep the paragraph's own indent.
|
||||
let (plain, psid, _) = doc_with(&text.replace('\u{e005}', " "), Rect::new(72.0, 36.0, 272.0, 400.0), para);
|
||||
let pcs = compose_story(&plain, psid, &Default::default());
|
||||
let start = pcs.frames[0].lines[1].glyphs.iter().find(|g| g.visible).unwrap().x;
|
||||
assert!((start - 78.0).abs() < 0.01, "plain second line starts at {start}");
|
||||
}
|
||||
|
||||
/// Where each line after the first starts, and the Indent to Here character's x on the first.
|
||||
fn indent_to_here_starts(text: &str, para: ParaAttrs) -> (f64, Vec<f64>) {
|
||||
let (d, sid, _) = doc_with(text, Rect::new(72.0, 36.0, 272.0, 600.0), para);
|
||||
let cs = compose_story(&d, sid, &Default::default());
|
||||
let lines = all_lines(&cs);
|
||||
assert!(lines.len() > 4, "the text wraps");
|
||||
let byte = text.find('\u{e005}').unwrap();
|
||||
let here = lines[0].glyphs.iter().find(|g| g.byte == byte && g.len > 0).unwrap().x;
|
||||
let starts = lines.iter().skip(1).map(|l| l.glyphs.iter().find(|g| g.visible).unwrap().x).collect();
|
||||
(here, starts)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indent_to_here_after_a_tab_hangs_the_text_after_the_number() {
|
||||
// The usual hanging number: "1.", a tab to the next stop, then Indent to Here.
|
||||
let tabs = vec![designcraft_doc::TabStop { position: 24.0, align: TabAlign::Left, leader: String::new(), align_on: String::new() }];
|
||||
let (here, starts) = indent_to_here_starts(&format!("1.\t\u{e005}{LOREM}"), ParaAttrs { tabs: Some(tabs), ..Default::default() });
|
||||
assert!((here - (72.0 + 24.0)).abs() < 0.01, "the character sits at the tab stop: {here}");
|
||||
for (i, start) in starts.iter().enumerate() {
|
||||
assert!((start - here).abs() < 0.01, "line {} starts at {start}, not at {here}", i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indent_to_here_beside_a_drop_cap_is_not_indented_by_the_drop_cap_again() {
|
||||
// A three-line drop cap, then Indent to Here on the first line: the lines beside the drop
|
||||
// cap and the lines below it all start at the character, none further in.
|
||||
let (here, starts) = indent_to_here_starts(&format!("Drop \u{e005}{LOREM}"), drop_cap(3, 1));
|
||||
for (i, start) in starts.iter().enumerate() {
|
||||
assert!((start - here).abs() < 0.01, "line {} starts at {start}, not at {here}", i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indent_to_here_inside_the_drop_cap_indents_the_lines_below_it() {
|
||||
// A two-character drop cap whose second character is Indent to Here: the lines beside the
|
||||
// drop cap and the ones below it all start at the character, at the drop cap's edge.
|
||||
let (here, starts) = indent_to_here_starts(&format!("A\u{e005}{LOREM}"), drop_cap(3, 2));
|
||||
assert!(here > 72.0 + 10.0, "the character sits past the drop cap: {here}");
|
||||
assert!(starts.len() > 3, "lines below the drop cap");
|
||||
for (i, start) in starts.iter().enumerate() {
|
||||
assert!((start - here).abs() < 0.01, "line {} starts at {start}, not at {here}", i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indent_to_here_in_a_list_number_indents_to_the_generated_character() {
|
||||
// "^#.^i^t": the paragraph's later lines start where the number's Indent to Here sits,
|
||||
// right after "1.", not at the text after the tab.
|
||||
let mut para = numbered(designcraft_doc::NumberStyle::Arabic, "^#.^i^t");
|
||||
para.list_tab = Some(Some(36.0));
|
||||
let (d, sid, _) = doc_with(LOREM, Rect::new(72.0, 36.0, 272.0, 600.0), para);
|
||||
let cs = compose_story(&d, sid, &Default::default());
|
||||
let lines = all_lines(&cs);
|
||||
assert!(lines.len() > 3, "the text wraps");
|
||||
let label = |c: char| lines[0].glyphs.iter().find(|g| g.len == 0 && g.rendered_char == c).unwrap();
|
||||
let (dot, here) = (label('.'), label('\u{e005}').x);
|
||||
assert!((here - (dot.x + dot.adv)).abs() < 0.01, "the character follows the number: {here}");
|
||||
let text = lines[0].glyphs.iter().find(|g| g.len > 0).unwrap().x;
|
||||
assert!((text - (72.0 + 36.0)).abs() < 0.01 && here < text - 1.0, "the text after the tab is further in: {text}");
|
||||
for (i, line) in lines.iter().enumerate().skip(1) {
|
||||
let start = line.glyphs.iter().find(|g| g.visible).unwrap().x;
|
||||
assert!((start - here).abs() < 0.01, "line {i} starts at {start}, not at {here}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indent_to_here_in_a_list_number_after_one_in_the_drop_cap_wins() {
|
||||
// The drop cap "A^i" sits at the indent and the number "1.^i" follows it on the first line,
|
||||
// so the number's character is the line's last one: the later lines start there, not at
|
||||
// the drop cap's.
|
||||
let mut para = ParaAttrs { drop_cap_lines: Some(3), drop_cap_chars: Some(2), ..numbered(designcraft_doc::NumberStyle::Arabic, "^#.^i^t") };
|
||||
para.list_tab = Some(Some(36.0));
|
||||
let (d, sid, _) = doc_with(&format!("A\u{e005}{LOREM}"), Rect::new(72.0, 36.0, 272.0, 600.0), para);
|
||||
let cs = compose_story(&d, sid, &Default::default());
|
||||
let lines = all_lines(&cs);
|
||||
assert!(lines.len() > 4, "the text wraps below the drop cap");
|
||||
let marker = |label: bool| lines[0].glyphs.iter().find(|g| g.rendered_char == '\u{e005}' && (g.len == 0) == label).unwrap().x;
|
||||
let (in_cap, in_label) = (marker(false), marker(true));
|
||||
assert!(in_label > in_cap + 1.0, "the number follows the drop cap: {in_cap} then {in_label}");
|
||||
for (i, line) in lines.iter().enumerate().skip(1) {
|
||||
let start = line.glyphs.iter().find(|g| g.visible).unwrap().x;
|
||||
assert!((start - in_label).abs() < 0.01, "line {i} starts at {start}, not at {in_label}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indent_to_here_in_a_right_to_left_paragraph_indents_from_the_right() {
|
||||
// Hebrew words (the bidi classes decide the order; no Hebrew font has to be present): the
|
||||
// lines after the first end where the character sits, measured from the right edge.
|
||||
let words = "שלום עולם ".repeat(40);
|
||||
let text = format!("שלום \u{e005}{words}");
|
||||
let para = ParaAttrs { direction: Some(designcraft_doc::TextDirection::RightToLeft), align: Some(Align::Right), ..Default::default() };
|
||||
let (d, sid, _) = doc_with(&text, Rect::new(72.0, 36.0, 272.0, 600.0), para);
|
||||
let cs = compose_story(&d, sid, &Default::default());
|
||||
let lines = all_lines(&cs);
|
||||
assert!(lines.len() > 3, "the text wraps");
|
||||
let byte = text.find('\u{e005}').unwrap();
|
||||
let here = lines[0].glyphs.iter().find(|g| g.byte == byte && g.len > 0).unwrap().x;
|
||||
assert!(here < 272.0 - 1.0, "the character follows the first word: {here}");
|
||||
let right_edge = |l: &Line| l.glyphs.iter().filter(|g| g.visible).map(|g| g.x + g.adv).fold(f64::MIN, f64::max);
|
||||
for (i, line) in lines.iter().enumerate().skip(1) {
|
||||
let end = right_edge(line);
|
||||
assert!((end - here).abs() < 0.01, "line {i} ends at {end}, not at {here}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_indent_tab_leaders_follow_stop_precedence_without_moving_text() {
|
||||
let text = "Intro\u{e006}12";
|
||||
|
||||
@@ -112,8 +112,13 @@ pub fn find_same_asset(assets: &BTreeMap<AssetId, Arc<Asset>>, asset: &Asset) ->
|
||||
|
||||
/// Add `asset` to `assets` unless they already hold the same file; returns the id placements
|
||||
/// should reference. A new asset with the bytes of an existing one shares its buffer.
|
||||
///
|
||||
/// This scans `assets`; an importer adding many files uses an [`AssetIndex`] instead.
|
||||
pub fn insert_asset(assets: &mut BTreeMap<AssetId, Arc<Asset>>, mut asset: Asset) -> AssetId {
|
||||
if let Some(id) = find_same_asset(assets, &asset) {
|
||||
// An empty, unlinked asset never holds the same file as another: nothing to search for.
|
||||
if (!asset.data.is_empty() || asset.link.is_some())
|
||||
&& let Some(id) = find_same_asset(assets, &asset)
|
||||
{
|
||||
return id;
|
||||
}
|
||||
if !asset.data.is_empty()
|
||||
@@ -126,6 +131,51 @@ pub fn insert_asset(assets: &mut BTreeMap<AssetId, Arc<Asset>>, mut asset: Asset
|
||||
id
|
||||
}
|
||||
|
||||
/// A lookup of the assets added through it, by content, so that adding many files (an import)
|
||||
/// costs time in proportion to their number rather than its square. It only knows the assets
|
||||
/// it inserted: use one index per asset map, and add every asset through it.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct AssetIndex {
|
||||
fingerprint: std::collections::hash_map::RandomState,
|
||||
/// Candidates for [`Asset::same_file`]: (bytes fingerprint, type, page, link).
|
||||
files: HashMap<(u64, String, u32, Option<String>), Vec<AssetId>>,
|
||||
/// One asset per distinct non-empty byte content, for buffer sharing.
|
||||
bytes: HashMap<u64, Vec<AssetId>>,
|
||||
}
|
||||
|
||||
impl AssetIndex {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// [`insert_asset`], looking up candidates by fingerprint instead of scanning `assets`.
|
||||
/// A fingerprint only selects candidates; equality still compares the actual files.
|
||||
pub fn insert_asset(&mut self, assets: &mut BTreeMap<AssetId, Arc<Asset>>, mut asset: Asset) -> AssetId {
|
||||
// Empty, unlinked assets never compare equal: don't search for or index them.
|
||||
if asset.data.is_empty() && asset.link.is_none() {
|
||||
let id = asset.id;
|
||||
assets.insert(id, Arc::new(asset));
|
||||
return id;
|
||||
}
|
||||
let hash = self.fingerprint.hash_one(asset.data.as_slice());
|
||||
let key = (hash, asset.mime.clone(), asset.page, asset.link.clone());
|
||||
if let Some(id) = self.files.get(&key).and_then(|ids| ids.iter().copied().find(|id| assets.get(id).is_some_and(|a| a.same_file(&asset)))) {
|
||||
return id;
|
||||
}
|
||||
if !asset.data.is_empty() {
|
||||
let bucket = self.bytes.entry(hash).or_default();
|
||||
match bucket.iter().find_map(|id| assets.get(id).filter(|a| a.same_bytes(&asset))) {
|
||||
Some(a) => asset.data = Arc::clone(&a.data),
|
||||
None => bucket.push(asset.id),
|
||||
}
|
||||
}
|
||||
let id = asset.id;
|
||||
self.files.entry(key).or_default().push(id);
|
||||
assets.insert(id, Arc::new(asset));
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
/// Calls `f` with every asset reference in `it` and its children; false when nesting exceeded
|
||||
/// [`MAX_DEPTH`] (some references weren't visited).
|
||||
fn item_refs(it: &Item, f: &mut impl FnMut(AssetId), depth: usize) -> bool {
|
||||
@@ -463,6 +513,54 @@ mod tests {
|
||||
assert_ne!(m3, m4);
|
||||
}
|
||||
|
||||
/// The index dedupes exactly as the scan does, and skips empty unlinked assets.
|
||||
#[test]
|
||||
fn indexed_insert_matches_scanning_insert() {
|
||||
let linked = |id, path: &str, data: &[u8]| Asset { link: Some(path.into()), ..asset(id, data) };
|
||||
let cases = vec![
|
||||
asset(1, b"same"),
|
||||
asset(2, b"same"),
|
||||
asset(3, b"diff"),
|
||||
Asset { page: 1, ..asset(4, b"same") },
|
||||
linked(5, "/p/a.png", b"same"),
|
||||
linked(6, "/q/b.png", b"same"),
|
||||
linked(7, "/p/a.png", b"same"),
|
||||
linked(8, "/m/1.png", b""),
|
||||
linked(9, "/m/1.png", b""),
|
||||
asset(10, b""),
|
||||
asset(11, b""),
|
||||
];
|
||||
let mut scanned = BTreeMap::new();
|
||||
let mut indexed = BTreeMap::new();
|
||||
let mut index = AssetIndex::new();
|
||||
for a in cases {
|
||||
let s = insert_asset(&mut scanned, a.clone());
|
||||
let i = index.insert_asset(&mut indexed, a);
|
||||
assert_eq!(s, i);
|
||||
}
|
||||
assert_eq!(scanned.keys().collect::<Vec<_>>(), indexed.keys().collect::<Vec<_>>());
|
||||
assert!(Arc::ptr_eq(&indexed[&AssetId(5)].data, &indexed[&AssetId(6)].data), "identical bytes share one buffer");
|
||||
}
|
||||
|
||||
/// Many empty unlinked placements (graphics with neither bytes nor a link) insert without
|
||||
/// comparing against each other.
|
||||
#[test]
|
||||
fn many_empty_unlinked_assets_insert_quickly() {
|
||||
let mut assets = BTreeMap::new();
|
||||
let mut index = AssetIndex::new();
|
||||
for id in 1..=200_000u64 {
|
||||
assert_eq!(insert_asset(&mut assets, asset(id, b"")), AssetId(id));
|
||||
}
|
||||
assert_eq!(assets.len(), 200_000);
|
||||
let mut indexed = BTreeMap::new();
|
||||
for id in 1..=20_000u64 {
|
||||
let mut bytes = vec![7; 64];
|
||||
bytes[56..].copy_from_slice(&id.to_le_bytes());
|
||||
assert_eq!(index.insert_asset(&mut indexed, asset(id, &bytes)), AssetId(id));
|
||||
}
|
||||
assert_eq!(indexed.len(), 20_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_at_different_paths_stay_separate_assets() {
|
||||
let mut d = Document::new(&Default::default());
|
||||
|
||||
@@ -44,7 +44,7 @@ use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub use anchored::{AnchorPosition, AnchoredObject, OBJECT_MARK};
|
||||
pub use assets::{find_same_asset, insert_asset, remap_story_assets, story_asset_refs};
|
||||
pub use assets::{AssetIndex, find_same_asset, insert_asset, remap_story_assets, story_asset_refs};
|
||||
pub use attrs::*;
|
||||
pub use datamerge::{
|
||||
DataField, DataFieldKind, DataMerge, DataSource, Delimiter, Fingerprint, MergeOptions, Placeholder, PlaceholderAnchor, PlaceholderRole,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::edit::SpreadRef;
|
||||
use crate::ids::{ItemId, StoryId};
|
||||
use crate::ids::{ItemId, SpreadId, StoryId};
|
||||
use crate::table::CellRange;
|
||||
|
||||
/// A selected ruler guide: index `index` in the guides of page `page` of spread `spread`.
|
||||
@@ -93,6 +93,9 @@ pub struct Selection {
|
||||
/// The page selected with the Page tool (absolute index).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub page: Option<usize>,
|
||||
/// Parent selected with the Page tool: stable spread id and page index within it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub parent_page: Option<(SpreadId, usize)>,
|
||||
/// Selected ruler guides. Never together with items, text or cells: selecting one clears
|
||||
/// the other.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
|
||||
@@ -210,6 +210,10 @@ fn select_range(range: &str, count: usize, idx: &mut Vec<usize>, seen: &mut std:
|
||||
}
|
||||
|
||||
fn parse_record_no(s: &str) -> Result<u32, String> {
|
||||
// `parse` alone would accept a leading '+'.
|
||||
if !s.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return Err(format!("bad record \"{s}\""));
|
||||
}
|
||||
s.parse::<u32>().map_err(|_| format!("bad record \"{s}\""))
|
||||
}
|
||||
|
||||
|
||||
@@ -1179,6 +1179,14 @@ fn select_records_dedupes_large_ranges_in_first_seen_order() {
|
||||
assert_eq!(&idx[..3], &[4, 0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_records_rejects_signed_record_numbers() {
|
||||
for (tok, bad) in [("+2", "+2"), ("1-+3", "+3"), ("+1,+4", "+1")] {
|
||||
let err = parse::select_records(5, "range", 1, tok, None).unwrap_err();
|
||||
assert!(err.contains(&format!("bad record \"{bad}\"")), "{tok}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_data_file_is_refused_before_reading() {
|
||||
let dir = scratch("oversized");
|
||||
|
||||
@@ -110,6 +110,8 @@ pub(super) fn export_pptx(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if rasterized > 0 {
|
||||
r.warnings.push(format!("{rasterized} object(s) went in as images"));
|
||||
}
|
||||
// A slide has no room for the overset text either (#1465).
|
||||
r.warnings.extend(crate::cmd::preflight::overset_warnings(s, opts.pages.as_deref()));
|
||||
match str_param(p, "path") {
|
||||
Some(path) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -87,8 +87,27 @@ fn first_page_ref(pdf: &[u8]) -> Option<usize> {
|
||||
s[i..].split_whitespace().next()?.parse().ok()
|
||||
}
|
||||
|
||||
/// Every page sharing a spread with one of `pages` (0-based), for an export that puts whole
|
||||
/// spreads on a sheet. The pages come back in document order.
|
||||
fn with_their_spreads(s: &Session, pages: &[usize]) -> Vec<usize> {
|
||||
let Some(st) = s.active() else { return pages.to_vec() };
|
||||
let d = &st.doc;
|
||||
let spreads: std::collections::BTreeSet<usize> = pages.iter().filter_map(|p| d.page_loc(*p)).map(|(si, _)| si).collect();
|
||||
if spreads.is_empty() {
|
||||
return pages.to_vec();
|
||||
}
|
||||
(0..d.page_count()).filter(|p| d.page_loc(*p).is_some_and(|(si, _)| spreads.contains(&si))).collect()
|
||||
}
|
||||
|
||||
pub(super) fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let r = pdf_export(s, p)?;
|
||||
let mut r = pdf_export(s, p)?;
|
||||
// Text the pages have no room for is not in the file: say so, as preflight does (#1465).
|
||||
// A spread export puts the whole spread on the sheet, so choosing page 2 exports page 3 as
|
||||
// well and page 3's overset is as absent from the file as page 2's would be. The selection
|
||||
// is widened to the spreads it touches before the warnings are collected (noted in review).
|
||||
let opts = options(p, s.doc()?.doc.page_count())?;
|
||||
let warn_pages = opts.pages.as_deref().map(|pages| if opts.spreads { with_their_spreads(s, pages) } else { pages.to_vec() });
|
||||
r.warnings.extend(crate::cmd::preflight::overset_warnings(s, warn_pages.as_deref()));
|
||||
match str_param(p, "path") {
|
||||
Some(path) => {
|
||||
write_file(path, &r.bytes)?;
|
||||
@@ -478,3 +497,219 @@ mod page_list_tests {
|
||||
assert!(s.execute("file.exportPdf", &json!({"pages": []})).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod overset_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// A page-based export leaves out the text its frames have no room for, and said nothing
|
||||
/// about it: an imported 58-page form lost whole sentences — a data-consent clause, a
|
||||
/// statutory rule, the last row of two tables — with `warnings` empty while preflight
|
||||
/// reported 19 overset stories (#1465).
|
||||
#[test]
|
||||
fn an_export_warns_about_the_text_the_pages_had_no_room_for() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 200, "pages": 2, "facingPages": false, "margins": 0})).unwrap();
|
||||
let text = "First line is visible.\nSecond paragraph does not fit: this sentence carries the legal notice and must not vanish from the PDF without a warning.";
|
||||
let frame = s.execute("frame.create", &json!({"rect": [20, 20, 280, 60], "content": "text", "text": text})).unwrap();
|
||||
let (id, sid) = (frame["id"].as_u64().unwrap(), frame["story"].clone());
|
||||
|
||||
// What preflight says about the same frame, for the warning to agree with.
|
||||
let issues = s.execute("preflight.run", &json!({})).unwrap();
|
||||
let overset = issues["issues"].as_array().unwrap().iter().find(|i| i["kind"] == "overset").expect("preflight reports it").clone();
|
||||
assert_eq!(overset["message"], "Overset text: 76 characters");
|
||||
|
||||
for command in ["file.exportPdf", "file.exportPptx"] {
|
||||
let r = s.execute(command, &json!({})).unwrap();
|
||||
let warnings = r["warnings"].as_array().expect("warnings").clone();
|
||||
let about: Vec<&str> = warnings.iter().filter_map(|w| w.as_str()).filter(|w| w.contains("not in the export")).collect();
|
||||
assert_eq!(about.len(), 1, "{command}: {warnings:?}");
|
||||
let w = about[0];
|
||||
assert!(w.contains("page 1"), "{command}: the page it is on: {w}");
|
||||
assert!(w.contains("76 character"), "{command}: the count preflight gives: {w}");
|
||||
assert!(w.contains(&format!("frame {id}")), "{command}: the frame: {w}");
|
||||
}
|
||||
|
||||
// The pages being exported are the ones reported on: page 2 has no overset frame.
|
||||
let r = s.execute("file.exportPdf", &json!({"pages": [2]})).unwrap();
|
||||
assert_eq!(r["warnings"], json!([]), "{r}");
|
||||
|
||||
// A reflowable export carries the whole story, so it has nothing to report.
|
||||
let r = s.execute("file.exportHtml", &json!({})).unwrap();
|
||||
assert_eq!(r["warnings"], json!([]), "{r}");
|
||||
assert!(r["text"].as_str().unwrap().contains("must not vanish"), "the HTML has the text the PDF left out");
|
||||
|
||||
// `overset` is the index where the text stops fitting; `oversetChars` is how much is left,
|
||||
// which is the number preflight reports. The two were read as the same thing.
|
||||
let story = s.execute("story.get", &json!({"story": sid})).unwrap();
|
||||
assert_eq!(story["overset"], json!(68), "{story}");
|
||||
assert_eq!(story["oversetChars"], json!(76), "{story}");
|
||||
|
||||
// A frame the text fits: nothing is reported anywhere.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 200, "pages": 1, "facingPages": false, "margins": 0})).unwrap();
|
||||
let frame = s.execute("frame.create", &json!({"rect": [20, 20, 280, 180], "content": "text", "text": "Short."})).unwrap();
|
||||
for command in ["file.exportPdf", "file.exportPptx"] {
|
||||
assert_eq!(s.execute(command, &json!({})).unwrap()["warnings"], json!([]), "{command}");
|
||||
}
|
||||
let story = s.execute("story.get", &json!({"story": frame["story"].clone()})).unwrap();
|
||||
assert!(story["overset"].is_null() && story["oversetChars"].is_null(), "{story}");
|
||||
}
|
||||
|
||||
/// A sheet draws its spread's items without asking which page each one sits on, so a frame
|
||||
/// straddling two pages is drawn, in part, on both. Choosing the page by the frame's centre
|
||||
/// alone meant a frame centred on a page the export leaves out had its warning dropped while
|
||||
/// the part on an exported page went into the file (#1493 review).
|
||||
#[test]
|
||||
fn an_export_warns_about_a_frame_that_only_crosses_into_one_of_its_pages() {
|
||||
let mut s = Session::new();
|
||||
// Facing pages, so pages 2 and 3 are one spread and a frame can straddle their boundary.
|
||||
s.execute("file.new", &json!({"width": 300, "height": 200, "pages": 4, "facingPages": true, "margins": 0})).unwrap();
|
||||
let text = "First line is visible.\nSecond paragraph does not fit: this sentence carries the legal notice and must not vanish from the PDF without a warning.";
|
||||
// Spread 1 is pages 2-3; its page 2 runs x 0..300 and page 3 runs x 300..600. The frame
|
||||
// is centred on page 3 and reaches back over the boundary into page 2.
|
||||
let frame = json!({"spread": 1, "rect": [260, 20, 420, 60], "content": "text", "text": text});
|
||||
let id = s.execute("frame.create", &frame).unwrap()["id"].as_u64().unwrap();
|
||||
|
||||
let about = |s: &mut Session, pages: serde_json::Value| {
|
||||
let params = if pages.is_null() { json!({}) } else { json!({"pages": pages}) };
|
||||
let r = s.execute("file.exportPdf", ¶ms).unwrap();
|
||||
let ws = r["warnings"].as_array().cloned().unwrap_or_default();
|
||||
ws.iter().filter_map(|w| w.as_str()).filter(|w| w.contains("not in the export")).map(str::to_owned).collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
// The whole document: reported once, at the page the frame is centred on.
|
||||
let all = about(&mut s, serde_json::Value::Null);
|
||||
assert_eq!(all.len(), 1, "{all:?}");
|
||||
assert!(all[0].contains("page 3") && all[0].contains(&format!("frame {id}")), "{}", all[0]);
|
||||
|
||||
// Exporting page 3 alone: the centre page, reported as before.
|
||||
let three = about(&mut s, json!([3]));
|
||||
assert_eq!(three.len(), 1, "{three:?}");
|
||||
assert!(three[0].contains("page 3"), "{}", three[0]);
|
||||
|
||||
// Exporting page 2 alone: the frame's centre is on page 3, which is not in the export --
|
||||
// but part of the frame is drawn on page 2, so it is still reported, and the page named is
|
||||
// one the export has.
|
||||
let two = about(&mut s, json!([2]));
|
||||
assert_eq!(two.len(), 1, "a frame crossing into page 2 is reported: {two:?}");
|
||||
assert!(two[0].contains("page 2"), "and the page it names is an exported one: {}", two[0]);
|
||||
assert!(two[0].contains(&format!("frame {id}")), "{}", two[0]);
|
||||
|
||||
// A page the frame does not reach at all: nothing to say.
|
||||
assert_eq!(about(&mut s, json!([4])), Vec::<String>::new());
|
||||
}
|
||||
|
||||
/// `overset` indexes `text` in UTF-8 bytes, not characters: a client that slices a JavaScript
|
||||
/// string at it cuts the wrong place once the text is not ASCII (noted in review).
|
||||
#[test]
|
||||
fn overset_is_a_byte_offset_into_the_text() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 200, "pages": 1, "facingPages": false, "margins": 0})).unwrap();
|
||||
// Accented letters are two bytes each, so the byte offset runs ahead of the character index.
|
||||
let text = "Réservé à l'évaluation détaillée des éléments.\nCette phrase dépasse le cadre et n'est pas dans le PDF exporté du tout.";
|
||||
let frame = s.execute("frame.create", &json!({"rect": [20, 20, 280, 40], "content": "text", "text": text})).unwrap();
|
||||
let story = s.execute("story.get", &json!({"story": frame["story"].clone()})).unwrap();
|
||||
let at = story["overset"].as_u64().expect("it oversets") as usize;
|
||||
assert!(text.is_char_boundary(at), "a byte offset that splits no character: {at}");
|
||||
assert!(at > text[..at].chars().count(), "ahead of the character index: {at} bytes, {} characters", text[..at].chars().count());
|
||||
// And `oversetChars` counts characters, which is why the two numbers differ.
|
||||
assert_eq!(story["oversetChars"], json!(text[at..].chars().count()), "{story}");
|
||||
}
|
||||
|
||||
/// A parent page's frame is composed once per page that shows it: its page number marker is
|
||||
/// that page's, so the same frame fits on page 1 and oversets on page 10 -- and a parent page
|
||||
/// no exported page uses puts nothing in the file to be missing. Composing it once with no
|
||||
/// page reported neither: every selection got the same unpaged warning (noted in review).
|
||||
#[test]
|
||||
fn a_parent_frame_is_checked_on_the_pages_that_show_it() {
|
||||
let about = |r: &serde_json::Value| -> Vec<String> {
|
||||
r["warnings"]
|
||||
.as_array()
|
||||
.map(|w| w.iter().filter_map(|w| w.as_str()).filter(|w| w.contains("not in the export")).map(str::to_string).collect())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
// A parent page that only page 1 uses.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 200, "pages": 2, "facingPages": false, "margins": 0})).unwrap();
|
||||
s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": null})).unwrap();
|
||||
let text = "First line is visible.\nSecond paragraph does not fit: this sentence carries the legal notice and must not vanish from the PDF without a warning.";
|
||||
let frame = s
|
||||
.execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [20, 20, 280, 60], "content": "text", "text": text}))
|
||||
.unwrap();
|
||||
let id = frame["id"].as_u64().unwrap();
|
||||
|
||||
let w = about(&s.execute("file.exportPdf", &json!({"pages": [1]})).unwrap());
|
||||
assert_eq!(w.len(), 1, "page 1 shows the parent: {w:?}");
|
||||
assert!(w[0].contains("page 1") && w[0].contains(&format!("frame {id}")) && w[0].contains("76 character"), "{}", w[0]);
|
||||
|
||||
// Page 2 has no parent, so nothing of that frame is in its export to be missing.
|
||||
let r = s.execute("file.exportPdf", &json!({"pages": [2]})).unwrap();
|
||||
assert!(about(&r).is_empty(), "page 2 does not use the parent: {r}");
|
||||
|
||||
// Page-dependent text: the marker is one digit up to page 9 and two from page 10, and the
|
||||
// wider line no longer fits. 112 pt is the width where that is the only difference.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 200, "pages": 12, "facingPages": false, "margins": 0})).unwrap();
|
||||
let text = format!("Continued on page {}", designcraft_doc::story::PAGE_NUMBER);
|
||||
s.execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [20, 20, 132, 36], "content": "text", "text": text}))
|
||||
.unwrap();
|
||||
|
||||
for page in [1, 9] {
|
||||
let r = s.execute("file.exportPdf", &json!({"pages": [page]})).unwrap();
|
||||
assert!(about(&r).is_empty(), "page {page} fits one digit: {r}");
|
||||
}
|
||||
let w = about(&s.execute("file.exportPdf", &json!({"pages": [10]})).unwrap());
|
||||
assert_eq!(w.len(), 1, "two digits do not fit: {w:?}");
|
||||
assert!(w[0].contains("page 10"), "{}", w[0]);
|
||||
|
||||
// The whole document: one line naming every page it happens on, not one line per page.
|
||||
let w = about(&s.execute("file.exportPdf", &json!({})).unwrap());
|
||||
assert_eq!(w.len(), 1, "one line for the story: {w:?}");
|
||||
assert!(w[0].starts_with("pages 10, 11, 12:"), "{}", w[0]);
|
||||
}
|
||||
|
||||
/// A spread export puts the whole spread on the sheet, so choosing one page of it exports its
|
||||
/// partner too -- and that partner's overset is just as absent from the file. Reporting only
|
||||
/// the pages that were named left it unmentioned (noted in review of #1465).
|
||||
#[test]
|
||||
fn a_spread_export_warns_about_the_pages_it_takes_with_it() {
|
||||
let mut s = Session::new();
|
||||
// Facing pages: 2 and 3 share a spread, 1 is the cover on its own.
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200, "pages": 4, "facingPages": true, "margins": 0})).unwrap();
|
||||
let text = "First line is visible.\nSecond paragraph does not fit: this sentence carries the legal notice and must not vanish from the PDF without a warning.";
|
||||
// A frame on page 3, whose text does not fit it. `frame.create` takes a spread and
|
||||
// spread coordinates, so page 3's own bounds say where to put it.
|
||||
let (si, b) = {
|
||||
let d = &s.active().expect("a document").doc;
|
||||
let (si, pi) = d.page_loc(2).expect("page 3 is there");
|
||||
(si, d.spreads[si].pages[pi].bounds())
|
||||
};
|
||||
let rect = json!([b.x0 + 20.0, b.y0 + 20.0, b.x0 + 180.0, b.y0 + 60.0]);
|
||||
let frame = s.execute("frame.create", &json!({"spread": si, "rect": rect, "content": "text", "text": text})).unwrap();
|
||||
let id = frame["id"].as_u64().unwrap();
|
||||
let about = |r: &serde_json::Value| -> Vec<String> {
|
||||
r["warnings"]
|
||||
.as_array()
|
||||
.map(|w| w.iter().filter_map(|w| w.as_str()).filter(|w| w.contains("not in the export")).map(str::to_string).collect())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
// Page by page, choosing page 2 reports nothing: page 2 holds no overset frame.
|
||||
let r = s.execute("file.exportPdf", &json!({"pages": [2]})).unwrap();
|
||||
assert!(about(&r).is_empty(), "page 2 alone has nothing to report: {r}");
|
||||
|
||||
// As spreads, choosing page 2 exports page 3 with it, so page 3's overset is reported.
|
||||
let r = s.execute("file.exportPdf", &json!({"pages": [2], "spreads": true})).unwrap();
|
||||
let w = about(&r);
|
||||
assert_eq!(w.len(), 1, "the page that came along is reported too: {r}");
|
||||
assert!(w[0].contains("page 3") && w[0].contains(&format!("frame {id}")), "{}", w[0]);
|
||||
|
||||
// And a page on another spread entirely is still not reported.
|
||||
let r = s.execute("file.exportPdf", &json!({"pages": [1], "spreads": true})).unwrap();
|
||||
assert!(about(&r).is_empty(), "the cover's spread holds no overset frame: {r}");
|
||||
}
|
||||
}
|
||||
@@ -420,6 +420,29 @@ mod open_bytes_tests {
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn legacy_and_idml_bytes_named_as_indesign_files_open_without_conversion() {
|
||||
let mut maker = Session::new();
|
||||
maker.execute("file.new", &json!({})).unwrap();
|
||||
let idml = designcraft_idml::export_idml(&maker.doc().unwrap().doc);
|
||||
let base64 = super::super::bytes::base64_encode(&idml);
|
||||
let legacy = super::super::bytes::base64_encode(&serde_json::to_vec(&maker.doc().unwrap().doc).unwrap());
|
||||
for name in ["Brochure.indd", "Brochure.INDT"] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.openBytes", &json!({"name": name, "base64": legacy})).unwrap();
|
||||
assert_eq!(s.documents().len(), 1, "legacy: {name}");
|
||||
assert_eq!(s.doc().unwrap().doc.title, name, "legacy: {name}");
|
||||
}
|
||||
for command in ["file.openBytes", "file.openIdml"] {
|
||||
for name in ["Brochure.indd", "Brochure.INDT"] {
|
||||
let mut s = Session::new();
|
||||
s.execute(command, &json!({"name": name, "base64": base64})).unwrap();
|
||||
assert_eq!(s.documents().len(), 1, "{command}: {name}");
|
||||
assert_eq!(s.doc().unwrap().doc.title, "Brochure", "{command}: {name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `.designcraft` file opened from bytes under its file name (a browser or host passes the
|
||||
/// name it has) is titled without the extension, as an IDML file is, so saving it gives
|
||||
/// `Brochure.designcraft` and not `Brochure.designcraft.designcraft`.
|
||||
@@ -433,6 +456,8 @@ mod open_bytes_tests {
|
||||
("Brochure.DesignCraft", "Brochure"),
|
||||
("Brochure", "Brochure"),
|
||||
("Brochure.v2", "Brochure.v2"),
|
||||
("Brochure.indd", "Brochure.indd"),
|
||||
("Brochure.INDT", "Brochure.INDT"),
|
||||
("Brochure.designcraft.designcraft", "Brochure.designcraft"),
|
||||
(".designcraft", ".designcraft"),
|
||||
// Names that aren't ASCII: cutting the extension's length off the end of the last one
|
||||
|
||||
@@ -455,9 +455,11 @@ fn warning_item(
|
||||
}
|
||||
|
||||
/// An InDesign document or template (.indd, .indt): known by its signature, or by its name when
|
||||
/// the bytes are something else (the converter then says why it can't read them).
|
||||
/// the bytes are neither a recognized DesignCraft document nor IDML. The converter explains
|
||||
/// unrecognized data with an InDesign extension.
|
||||
pub(crate) fn is_indd(name: &str, bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(&indd::header::SIGNATURE) || has_indd_extension(name)
|
||||
bytes.starts_with(&indd::header::SIGNATURE)
|
||||
|| (has_indd_extension(name) && !super::file::is_document(bytes) && !bytes.trim_ascii_start().starts_with(b"{"))
|
||||
}
|
||||
|
||||
pub(crate) fn has_indd_extension(name: &str) -> bool {
|
||||
@@ -525,7 +527,7 @@ mod tests {
|
||||
assert_eq!(*asset.data, png, "the packaged graphic must render even when its author-machine path no longer exists");
|
||||
let resolved = std::path::absolute(&path).unwrap();
|
||||
assert_eq!(asset.link.as_deref(), resolved.to_str(), "remember the resolved link so update and preflight use the packaged file");
|
||||
assert_eq!(super::super::links::status(asset, None), "ok");
|
||||
assert_eq!(super::super::links::status_cached(asset, None, &mut Default::default()), "ok");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
@@ -542,7 +544,7 @@ mod tests {
|
||||
let asset = doc.assets.values().next().unwrap();
|
||||
assert_eq!(*asset.data, png);
|
||||
assert_eq!(asset.link.as_deref(), std::path::absolute(&path).unwrap().to_str());
|
||||
assert_eq!(super::super::links::status(asset, None), "ok");
|
||||
assert_eq!(super::super::links::status_cached(asset, None, &mut Default::default()), "ok");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
@@ -558,7 +560,7 @@ mod tests {
|
||||
let asset = doc.assets.values().next().unwrap();
|
||||
assert_eq!(*asset.data, png);
|
||||
assert_eq!(asset.link.as_deref(), std::path::absolute(&path).unwrap().to_str());
|
||||
assert_eq!(super::super::links::status(asset, None), "ok");
|
||||
assert_eq!(super::super::links::status_cached(asset, None, &mut Default::default()), "ok");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
@@ -578,7 +580,7 @@ mod tests {
|
||||
let asset = doc.assets.values().next().unwrap();
|
||||
assert_eq!(std::path::Path::new(asset.link.as_deref().unwrap()), original.as_path(), "an existing original stays authoritative");
|
||||
assert_eq!(*asset.data, png);
|
||||
assert_eq!(super::super::links::status(asset, None), "modified");
|
||||
assert_eq!(super::super::links::status_cached(asset, None, &mut Default::default()), "modified");
|
||||
|
||||
std::fs::remove_file(&original).unwrap();
|
||||
std::fs::write(©, b"modified packaged copy").unwrap();
|
||||
@@ -586,7 +588,7 @@ mod tests {
|
||||
let asset = doc.assets.values().next().unwrap();
|
||||
assert_eq!(asset.link.as_deref(), copy.to_str());
|
||||
assert_eq!(*asset.data, png, "rebinding must not replace embedded pixels");
|
||||
assert_eq!(super::super::links::status(asset, None), "modified");
|
||||
assert_eq!(super::super::links::status_cached(asset, None, &mut Default::default()), "modified");
|
||||
|
||||
std::fs::remove_file(©).unwrap();
|
||||
let packaged = package.join("x.idml");
|
||||
@@ -595,7 +597,7 @@ mod tests {
|
||||
let asset = doc.assets.values().next().unwrap();
|
||||
assert_eq!(std::path::Path::new(asset.link.as_deref().unwrap()), original.as_path(), "keep a missing link when there is no candidate");
|
||||
assert_eq!(*asset.data, png);
|
||||
assert_eq!(super::super::links::status(asset, None), "missing");
|
||||
assert_eq!(super::super::links::status_cached(asset, None, &mut Default::default()), "missing");
|
||||
}
|
||||
let oversized = "a/".repeat(MAX_LINK_PATH_BYTES);
|
||||
assert!(packaged_link_candidates(&oversized, Some(&packaged)).is_empty());
|
||||
@@ -643,7 +645,7 @@ mod tests {
|
||||
assert!(seen.iter().all(|p| p.starts_with(&dir) || itself.as_ref() == Some(p)), "{link}: {seen:?}");
|
||||
let asset = doc.assets.values().next().unwrap();
|
||||
assert!(asset.data.is_empty(), "{link}");
|
||||
assert_eq!(super::super::links::status(asset, None), "missing", "{link}");
|
||||
assert_eq!(super::super::links::status_cached(asset, None, &mut Default::default()), "missing", "{link}");
|
||||
}
|
||||
// An image elsewhere on this computer is read, and so is one beside the package.
|
||||
let away = outside.join("logo.png");
|
||||
@@ -652,7 +654,7 @@ mod tests {
|
||||
let doc = import(&idml, Some(&package)).unwrap();
|
||||
let asset = doc.assets.values().next().unwrap();
|
||||
assert_eq!((&*asset.data, asset.link.as_deref()), (&png, away.to_str()));
|
||||
assert_eq!(super::super::links::status(asset, None), "ok");
|
||||
assert_eq!(super::super::links::status_cached(asset, None, &mut Default::default()), "ok");
|
||||
std::fs::write(dir.join("Links").join("logo.png"), &png).unwrap();
|
||||
let (idml, _) = linked_idml("Links/logo.png", false).unwrap();
|
||||
let doc = import(&idml, Some(&package)).unwrap();
|
||||
|
||||
@@ -20,3 +20,7 @@ pub use sections::chapter_numbering;
|
||||
#[cfg(test)]
|
||||
#[path = "parent_delete_tests.rs"]
|
||||
mod parent_delete_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "parent_resize_tests.rs"]
|
||||
mod parent_resize_tests;
|
||||
@@ -61,84 +61,93 @@ pub(crate) fn resize_pages(d: &mut designcraft_doc::Document, pages: &[usize], w
|
||||
/// [`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) {
|
||||
for page in pages {
|
||||
let Some((si, pi)) = page.checked_sub(1).and_then(|i| d.page_loc(i)) else { continue };
|
||||
resize_spread_page(d, designcraft_doc::SpreadRef::Doc(si), pi, w.map(|v| v.max(1.0)), h.map(|v| v.max(1.0)), anchor);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn resize_spread_page(
|
||||
d: &mut designcraft_doc::Document,
|
||||
sr: designcraft_doc::SpreadRef,
|
||||
pi: 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 {
|
||||
set_page_width(pg, w.max(1.0));
|
||||
}
|
||||
if let Some(h) = h {
|
||||
pg.height = h.max(1.0);
|
||||
}
|
||||
let left = pg.side == designcraft_doc::PageSide::Left;
|
||||
sp.relayout();
|
||||
// Keep the spine: shift the whole spread back when a left page grows.
|
||||
if left && dw != 0.0 {
|
||||
for q in &mut sp.pages {
|
||||
q.x -= dw;
|
||||
}
|
||||
}
|
||||
let new: Vec<f64> = sp.pages.iter().map(|x| x.x).collect();
|
||||
// Objects follow their page.
|
||||
let shifts: Vec<(f64, f64, f64)> =
|
||||
sp.pages.iter().enumerate().map(|(k, q)| (old[k], old[k] + q.width - if k == pi { dw } else { 0.0 }, new[k] - old[k])).collect();
|
||||
for it in &mut sp.items {
|
||||
let cx = it.bounds().center().x;
|
||||
if let Some((_, _, dx)) = shifts.iter().find(|(a, b, _)| cx >= *a && cx < *b)
|
||||
&& *dx != 0.0
|
||||
{
|
||||
let it = std::sync::Arc::make_mut(it);
|
||||
it.xf = designcraft_geom::Affine::translate((*dx, 0.0)) * it.xf;
|
||||
}
|
||||
}
|
||||
// Page guides move with their page.
|
||||
for (k, q) in sp.pages.iter_mut().enumerate() {
|
||||
let dx = new[k] - old[k];
|
||||
if dx != 0.0 {
|
||||
for g in q.guides.iter_mut().filter(|g| g.orientation == designcraft_doc::Orientation::Vertical) {
|
||||
g.position += dx;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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;
|
||||
crate::cmd::liquid::apply(d, designcraft_doc::SpreadRef::Doc(si), pi, moved);
|
||||
let Some(sp) = d.spread_mut(sr) else { return };
|
||||
let origin = sp.bounds().x0;
|
||||
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 Some((leftmost, rightmost)) = sp.outer_page_indices() else { return };
|
||||
let (lo, hi) = (if pi == leftmost { f64::NEG_INFINITY } else { old_bounds.x0 }, if pi == rightmost { 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 - pg.width);
|
||||
let dh = h.map_or(0.0, |h| h - pg.height);
|
||||
if let Some(w) = w {
|
||||
set_page_width(pg, w);
|
||||
}
|
||||
if let Some(h) = h {
|
||||
pg.height = h;
|
||||
}
|
||||
let left = pg.side == designcraft_doc::PageSide::Left;
|
||||
sp.relayout();
|
||||
// Relayout resets the origin; retain prior edits and grow left pages away from the spine.
|
||||
let offset = origin - if left { dw } else { 0.0 };
|
||||
for q in &mut sp.pages {
|
||||
q.x += offset;
|
||||
}
|
||||
let new: Vec<f64> = sp.pages.iter().map(|x| x.x).collect();
|
||||
// Objects follow their page.
|
||||
let shifts: Vec<(f64, f64, f64)> =
|
||||
sp.pages.iter().enumerate().map(|(k, q)| (old[k], old[k] + q.width - if k == pi { dw } else { 0.0 }, new[k] - old[k])).collect();
|
||||
for it in &mut sp.items {
|
||||
let cx = it.bounds().center().x;
|
||||
if let Some((_, _, dx)) = shifts.iter().find(|(a, b, _)| cx >= *a && cx < *b)
|
||||
&& *dx != 0.0
|
||||
{
|
||||
let it = std::sync::Arc::make_mut(it);
|
||||
it.xf = designcraft_geom::Affine::translate((*dx, 0.0)) * it.xf;
|
||||
}
|
||||
}
|
||||
// Page guides move with their page.
|
||||
for (k, q) in sp.pages.iter_mut().enumerate() {
|
||||
let dx = new[k] - old[k];
|
||||
if dx != 0.0 {
|
||||
for g in q.guides.iter_mut().filter(|g| g.orientation == designcraft_doc::Orientation::Vertical) {
|
||||
g.position += dx;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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;
|
||||
crate::cmd::liquid::apply(d, sr, pi, moved);
|
||||
}
|
||||
|
||||
/// File › Document Setup values.
|
||||
@@ -322,6 +331,51 @@ mod setup_tests {
|
||||
mod page_size_tests {
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn document_page_resize_preserves_shifted_spread_objects_and_guides() {
|
||||
for binding in ["leftToRight", "rightToLeft"] {
|
||||
let mut s = crate::Session::new();
|
||||
s.execute("file.new", &json!({"pages": 3, "facingPages": true, "width": 400, "height": 400})).unwrap();
|
||||
s.execute("layout.documentSetup", &json!({"binding": binding})).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let (si, _) = before.page_loc(1).unwrap();
|
||||
let (left, right) = before.spreads[si].outer_page_indices().unwrap();
|
||||
let mut items = Vec::new();
|
||||
for pi in [left, right] {
|
||||
let x = before.spreads[si].pages[pi].x;
|
||||
let id = s.execute("frame.create", &json!({"spread": si, "rect": [x + 80.0, 80, x + 180.0, 180]})).unwrap()["id"].as_u64().unwrap();
|
||||
items.push(designcraft_doc::ItemId(id));
|
||||
s.execute("guide.add", &json!({"spread": si, "page": pi, "orientation": "vertical", "position": x + 120.0})).unwrap();
|
||||
}
|
||||
let first = before.first_page_of_spread(si) + 1;
|
||||
s.execute("layout.pageSize", &json!({"pages": [first + left], "width": 300})).unwrap();
|
||||
let shifted = s.doc().unwrap().doc.clone();
|
||||
let sp = &shifted.spreads[si];
|
||||
assert_eq!(sp.bounds().x0, 100.0);
|
||||
assert_eq!(sp.pages[right].x, 400.0);
|
||||
assert_eq!(sp.pages[left].guides[0].position, 220.0);
|
||||
assert_eq!(sp.pages[right].guides[0].position, 520.0);
|
||||
assert_eq!(shifted.item(items[0]).unwrap().bounds().x0, 180.0);
|
||||
assert_eq!(shifted.item(items[1]).unwrap().bounds().x0, 480.0);
|
||||
for width in [500, 450, 520] {
|
||||
s.execute("layout.pageSize", &json!({"pages": [first + right], "width": width})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let sp = &d.spreads[si];
|
||||
assert_eq!(sp.bounds().x0, 100.0, "{binding}, width {width}");
|
||||
assert_eq!(sp.pages[right].x, 400.0);
|
||||
for pi in [left, right] {
|
||||
assert_eq!(sp.pages[pi].guides, shifted.spreads[si].pages[pi].guides);
|
||||
}
|
||||
for id in &items {
|
||||
assert_eq!(d.item(*id).unwrap().bounds(), shifted.item(*id).unwrap().bounds());
|
||||
}
|
||||
assert_eq!(d.parents, before.parents);
|
||||
assert_eq!(d.settings, before.settings);
|
||||
d.check().unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_size_per_page_keeps_objects_on_their_page() {
|
||||
let mut s = crate::Session::new();
|
||||
|
||||
@@ -65,6 +65,43 @@ pub(super) fn parent_options(s: &mut crate::Session, p: &Value) -> Result<Value>
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn parent_page_target(d: &designcraft_doc::Document, p: &Value, cmd: &str) -> Result<(usize, usize)> {
|
||||
let parent = p.get("parent").and_then(Value::as_u64).map(SpreadId).ok_or_else(|| bad(cmd, "give a parent spread id"))?;
|
||||
let si = d.parent_index(parent).ok_or_else(|| bad(cmd, "no such parent"))?;
|
||||
let pi = p
|
||||
.get("page")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|n| usize::try_from(n).ok())
|
||||
.filter(|i| *i < d.parents[si].pages.len())
|
||||
.ok_or_else(|| bad(cmd, "no such parent page"))?;
|
||||
Ok((si, pi))
|
||||
}
|
||||
|
||||
pub(super) fn parent_page_size(s: &mut crate::Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "layout.parentPageSize";
|
||||
let (si, pi) = parent_page_target(&s.doc()?.doc, p, ID)?;
|
||||
let size = |key| {
|
||||
p.get(key)
|
||||
.map(|v| {
|
||||
v.as_f64().filter(|v| v.is_finite() && *v > 0.0 && *v <= 15552.0).ok_or_else(|| bad(ID, "page size out of range (0 < size ≤ 216 in)"))
|
||||
})
|
||||
.transpose()
|
||||
};
|
||||
let (w, h) = (size("width")?, size("height")?);
|
||||
if w.is_none() && h.is_none() {
|
||||
return Err(bad(ID, "give width or height"));
|
||||
}
|
||||
let anchor = match p.get("anchor") {
|
||||
None => 0,
|
||||
Some(v) => v.as_u64().filter(|n| *n <= 8).ok_or_else(|| bad(ID, "anchor: 0–8"))? as usize,
|
||||
};
|
||||
s.edit(|d, _| {
|
||||
super::pages::resize_spread_page(d, designcraft_doc::SpreadRef::Parent(si), pi, w, h, anchor);
|
||||
d.sync_parent_margins();
|
||||
Ok(json!({"parent": d.parents[si].id.0, "page": pi}))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod parent_margin_tests {
|
||||
use designcraft_doc::{Document, Margins};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::layers::set_layer;
|
||||
use super::margins::{adjust_layout, column_guides_unlocked, margin_boxes, move_column_guide, set_margins_columns, set_page_width};
|
||||
use super::pages::{document_setup, page_options, page_size};
|
||||
use super::parents::{parent_options, parent_ref, parent_spread_index};
|
||||
use super::parents::{parent_options, parent_page_size, parent_page_target, parent_ref, parent_spread_index};
|
||||
use super::sections::{ChapterEdit, SectionEdit, chapter_numbering, start_number};
|
||||
use super::{CommandSpec, LAYER_COLORS, Layer, LayerId, Margins, SpreadId, Value, bad, cmd, has_doc, json, ok, str_param};
|
||||
|
||||
@@ -502,6 +502,30 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
page_size
|
||||
),
|
||||
cmd!(
|
||||
"layout.parentPageSize",
|
||||
"Parent Page Size",
|
||||
[],
|
||||
None,
|
||||
"{parent: spread id, page: 0-based parent page index, width?, height?, anchor?: 0–8} — resize only this parent page, keeping document page sizes independent",
|
||||
has_doc,
|
||||
parent_page_size
|
||||
),
|
||||
cmd!(
|
||||
noundo "layout.selectParentPage", "Select Parent Page", [], None,
|
||||
"{parent: spread id|null, page?: 0-based parent page index} — select a parent page with the Page tool; null clears the selection",
|
||||
has_doc, |s, p| {
|
||||
let target = match p.get("parent") {
|
||||
Some(Value::Null) => None,
|
||||
_ => Some(parent_page_target(&s.doc()?.doc, p, "layout.selectParentPage")?),
|
||||
};
|
||||
let st = s.doc_mut()?;
|
||||
st.selection = Default::default();
|
||||
st.selection.parent_page = target.map(|(si, pi)| (st.doc.parents[si].id, pi));
|
||||
st.bump_revision();
|
||||
ok()
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"layout.pageOptions",
|
||||
"Page Options",
|
||||
@@ -536,6 +560,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
st.selection = designcraft_doc::Selection::items(ids);
|
||||
}
|
||||
st.selection.page = page;
|
||||
st.selection.parent_page = None;
|
||||
st.bump_revision();
|
||||
Ok(json!({"page": page.map(|p| p + 1)}))
|
||||
}
|
||||
|
||||
@@ -27,8 +27,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(query "links.list", "Links", [], None, "{} → [{asset, name, path, status: ok|modified|missing|embedded, refused?: why a missing link isn't followed (a path on another computer, a device), pixels, pdfCrop (placed PDFs: the box shown), 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, _| {
|
||||
// Taken out for the call so the document needn't be cloned on every poll.
|
||||
let mut checks = std::mem::take(&mut s.text_link_checks);
|
||||
let links = s.doc().map(|d| list(&d.doc, &s.cache, d.path.as_deref().map(Path::new), &mut checks));
|
||||
let mut images = std::mem::take(&mut s.image_link_checks);
|
||||
let links = s.doc().map(|d| list(&d.doc, &s.cache, d.path.as_deref().map(Path::new), &mut checks, &mut images));
|
||||
s.text_link_checks = checks;
|
||||
s.image_link_checks = images;
|
||||
Ok(Value::Array(links?))
|
||||
}),
|
||||
cmd!(
|
||||
@@ -137,7 +139,7 @@ pub fn refused(link: &str, document: Option<&Path>) -> Option<crate::io::Refused
|
||||
crate::io::document_path(link, document).err()
|
||||
}
|
||||
|
||||
/// Whether an asset's linked file is gone ([`status`] "missing" without reading the file). An
|
||||
/// Whether an asset's linked file is gone ([`status_cached`] "missing" without reading the file). An
|
||||
/// asset can hold data while its file is missing: the document's preview of it. `document` is
|
||||
/// the document's file, which relative links are resolved against.
|
||||
pub fn link_missing(a: &designcraft_doc::Asset, document: Option<&Path>) -> bool {
|
||||
@@ -153,9 +155,20 @@ pub fn link_missing(a: &designcraft_doc::Asset, document: Option<&Path>) -> bool
|
||||
}
|
||||
}
|
||||
|
||||
/// Link status of an asset in the document at `document`. Only a regular file is compared, so a
|
||||
/// link to a device (`/dev/zero`) or a FIFO is missing rather than read.
|
||||
pub fn status(a: &designcraft_doc::Asset, document: Option<&Path>) -> &'static str {
|
||||
/// Linked images checked this session: (asset, the identity of its bytes, the file its link
|
||||
/// resolves to) → (the file's size and time when it was read, whether it matched the bytes). The
|
||||
/// resolved file, not the link text, is the key: a relative link names another file once the
|
||||
/// document is saved elsewhere. The bytes' identity (their `Arc`'s address, as the render cache
|
||||
/// keys them) changes with every relink, update or undo, so a remembered answer can never
|
||||
/// describe other bytes.
|
||||
pub(crate) type ImageChecks = std::collections::HashMap<(AssetId, usize, std::path::PathBuf), (Fingerprint, bool)>;
|
||||
|
||||
/// Link status of a placed graphic (`ok`, `modified`, `missing`, `embedded`), reading the file
|
||||
/// only when its size is the asset's and its time isn't the one remembered from the last read
|
||||
/// (`checks`): the Links panel polls twice a second, and a document
|
||||
/// of press-size images would otherwise be read whole on every poll. A file changed in the last
|
||||
/// two seconds is never remembered (see [`settled`]).
|
||||
pub(crate) fn status_cached(a: &designcraft_doc::Asset, document: Option<&Path>, checks: &mut ImageChecks) -> &'static str {
|
||||
let Some(link) = &a.link else { return "embedded" };
|
||||
// Nothing was read from the file (a package's link that wasn't followed): missing, as in
|
||||
// Preflight.
|
||||
@@ -168,20 +181,32 @@ pub fn status(a: &designcraft_doc::Asset, document: Option<&Path>) -> &'static s
|
||||
if !crate::io::is_file(&path) {
|
||||
return "missing";
|
||||
}
|
||||
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 crate::io::read_capped(&path, a.data.len() as u64) {
|
||||
Ok(b) if b == *a.data => "ok",
|
||||
Ok(_) => "modified",
|
||||
Err(_) => "missing",
|
||||
},
|
||||
let now = match crate::io::metadata(&path) {
|
||||
Err(_) => return "missing",
|
||||
Ok(m) => super::datamerge::fingerprint_from(&m),
|
||||
};
|
||||
if now.size != a.data.len() as u64 {
|
||||
return "modified";
|
||||
}
|
||||
let key = (a.id, Arc::as_ptr(&a.data) as usize, path.clone());
|
||||
if let Some((at, same)) = checks.get(&key)
|
||||
&& *at == now
|
||||
{
|
||||
return if *same { "ok" } else { "modified" };
|
||||
}
|
||||
let same = match crate::io::read_capped(&path, a.data.len() as u64) {
|
||||
Ok(b) => b == *a.data,
|
||||
Err(_) => return "missing",
|
||||
};
|
||||
// The time from before the read: a change during it is seen next time.
|
||||
if settled(&now) {
|
||||
checks.insert(key, (now, same));
|
||||
}
|
||||
if same { "ok" } else { "modified" }
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = (link, document);
|
||||
let _ = (link, document, checks);
|
||||
"ok"
|
||||
}
|
||||
}
|
||||
@@ -317,7 +342,9 @@ fn shown<'a>(d: &'a Document, cache: &Cache, aid: AssetId) -> Vec<Shown<'a>> {
|
||||
out
|
||||
}
|
||||
|
||||
fn list(d: &Document, cache: &Cache, document: Option<&Path>, checks: &mut TextChecks) -> Vec<Value> {
|
||||
/// `links.list`: every used placed graphic, then every linked text file, each with its status
|
||||
/// (`images` and `checks` remember what was read this session, so a poll reads nothing unchanged).
|
||||
fn list(d: &Document, cache: &Cache, document: Option<&Path>, checks: &mut TextChecks, images: &mut ImageChecks) -> 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();
|
||||
@@ -350,7 +377,7 @@ fn list(d: &Document, cache: &Cache, document: Option<&Path>, checks: &mut TextC
|
||||
.collect();
|
||||
let pdf_crop = (a.mime == "application/pdf").then(|| a.pdf_crop.name());
|
||||
let why = a.link.as_deref().and_then(|l| refused(l, document)).map(|r| r.to_string());
|
||||
json!({"asset": a.id.0, "name": a.name, "path": a.link, "status": status(a, document), "refused": why, "pixels": a.pixels, "pdfCrop": pdf_crop, "uses": uses})
|
||||
json!({"asset": a.id.0, "name": a.name, "path": a.link, "status": status_cached(a, document, images), "refused": why, "pixels": a.pixels, "pdfCrop": pdf_crop, "uses": uses})
|
||||
})
|
||||
.chain(texts)
|
||||
.collect()
|
||||
@@ -450,6 +477,7 @@ fn relinked_asset(a: &designcraft_doc::Asset, read: &Path, link: &str) -> Result
|
||||
Ok(Arc::new(a))
|
||||
}
|
||||
|
||||
/// `links.relink`: a placed graphic or linked text file takes its bytes from `path` from now on.
|
||||
fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("links.relink", "missing path"))?.to_string();
|
||||
if let Some(sid) = story_param(p) {
|
||||
@@ -464,13 +492,17 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let a = s.doc()?.doc.assets.get(&aid).ok_or_else(|| bad("links.relink", "no such graphic"))?;
|
||||
// Build the replacement before the edit: a file that cannot be read leaves the document as it was.
|
||||
let fresh = relinked_asset(a, Path::new(&path), &path)?;
|
||||
s.edit(|d, _| {
|
||||
let r = s.edit(|d, _| {
|
||||
let name = fresh.name.clone();
|
||||
d.assets.insert(aid, fresh);
|
||||
Ok(json!({"name": name}))
|
||||
})
|
||||
});
|
||||
s.image_link_checks.clear();
|
||||
r
|
||||
}
|
||||
|
||||
/// `links.relinkFolder`: relink graphics to same-named files in a folder and/or with another
|
||||
/// extension (the missing ones by default).
|
||||
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 {
|
||||
@@ -486,12 +518,13 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let missing_only = p.get("missingOnly").and_then(Value::as_bool).unwrap_or(dir.is_some() && ext.is_none());
|
||||
let only: Option<Vec<u64>> = p.get("assets").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect());
|
||||
let mut images = std::mem::take(&mut s.image_link_checks);
|
||||
let st = s.doc()?;
|
||||
let (d, document) = (&st.doc, st.path.as_deref().map(Path::new));
|
||||
let mut todo: Vec<(AssetId, std::path::PathBuf)> = Vec::new();
|
||||
let mut not_found = Vec::new();
|
||||
for a in d.assets.values() {
|
||||
if only.as_ref().is_some_and(|o| !o.contains(&a.id.0)) || (missing_only && status(a, document) != "missing") {
|
||||
if only.as_ref().is_some_and(|o| !o.contains(&a.id.0)) || (missing_only && status_cached(a, document, &mut images) != "missing") {
|
||||
continue;
|
||||
}
|
||||
let old = std::path::PathBuf::from(a.link.clone().unwrap_or_else(|| a.name.clone()));
|
||||
@@ -538,10 +571,13 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
images.clear();
|
||||
}
|
||||
s.image_link_checks = images;
|
||||
Ok(json!({"relinked": n, "notFound": not_found}))
|
||||
}
|
||||
|
||||
/// `links.update`: re-read the modified links (one, or every one) from where they resolve.
|
||||
fn update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (d, document) = {
|
||||
let st = s.doc()?;
|
||||
@@ -549,6 +585,7 @@ fn update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let document = document.as_deref().map(Path::new);
|
||||
let checks = &mut s.text_link_checks;
|
||||
let images = &mut s.image_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();
|
||||
// A link craft-docpath refuses is missing, so it is never read or refreshed.
|
||||
@@ -563,7 +600,7 @@ fn update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
.collect();
|
||||
let mut fresh = Vec::new();
|
||||
for a in d.assets.values().filter(|a| all || only_asset == Some(a.id.0)).filter(|a| status(a, document) == "modified") {
|
||||
for a in d.assets.values().filter(|a| all || only_asset == Some(a.id.0)).filter(|a| status_cached(a, document, images) == "modified") {
|
||||
// The bytes come from where the stored link resolves; the link itself stays as the
|
||||
// document stored it, so refreshing never rewrites a relative link to an absolute one.
|
||||
if let Some((link, at)) = a.link.as_deref().and_then(|l| resolved(l).map(|at| (l, at))) {
|
||||
@@ -579,7 +616,7 @@ fn update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if n == 0 {
|
||||
return Ok(json!({"updated": 0}));
|
||||
}
|
||||
s.edit(|d, _| {
|
||||
let r = s.edit(|d, _| {
|
||||
for (aid, a) in fresh {
|
||||
d.assets.insert(aid, a);
|
||||
}
|
||||
@@ -588,7 +625,9 @@ fn update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
warnings.extend(reimport(d, *sid, path, bytes, *before)?);
|
||||
}
|
||||
Ok(json!({"updated": n, "warnings": warnings}))
|
||||
})
|
||||
});
|
||||
s.image_link_checks.clear();
|
||||
r
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -4,6 +4,78 @@ fn png(w: u32, h: u32) -> Vec<u8> {
|
||||
designcraft_render::Rendered { width: w, height: h, pixels: vec![200u8; (w * h * 4) as usize] }.to_png()
|
||||
}
|
||||
|
||||
/// The Links panel polls `links.list` twice a second: a linked image whose file keeps its size
|
||||
/// and time is not read again (press-size images would otherwise be read whole on every poll).
|
||||
/// A new time is read once; changed bytes show as modified; an update is remembered.
|
||||
#[cfg(all(unix, not(target_arch = "wasm32")))]
|
||||
#[test]
|
||||
fn unchanged_linked_images_are_not_read_again() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = std::env::temp_dir().join(format!("dc-image-link-poll-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("photo.png");
|
||||
// Two valid images of the same byte length (a uniform fill compresses to the same size
|
||||
// whatever its value), so a change shows only in the bytes.
|
||||
let a = png(40, 40);
|
||||
let b = designcraft_render::Rendered { width: 40, height: 40, pixels: vec![100u8; 40 * 40 * 4] }.to_png();
|
||||
assert_eq!(a.len(), b.len(), "the test needs two files of one size");
|
||||
assert_ne!(a, b);
|
||||
std::fs::write(&path, &a).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| {
|
||||
let l = s.execute("links.list", &json!({})).unwrap();
|
||||
l.as_array().unwrap().iter().find(|r| r.get("asset").is_some()).unwrap()["status"].as_str().unwrap().to_string()
|
||||
};
|
||||
|
||||
// First poll reads and remembers; the second doesn't read.
|
||||
assert_eq!(status(&mut s), "ok");
|
||||
readable(false);
|
||||
if std::fs::read(&path).is_ok() {
|
||||
// Running as root: permissions don't stop reads, so this can't be observed.
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
return;
|
||||
}
|
||||
assert_eq!(status(&mut s), "ok", "same size and time: not read");
|
||||
// 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 — and remembered, not re-read.
|
||||
readable(true);
|
||||
std::fs::write(&path, &b).unwrap();
|
||||
touch(20);
|
||||
assert_eq!(status(&mut s), "modified");
|
||||
readable(false);
|
||||
assert_eq!(status(&mut s), "modified", "a modified answer is remembered too");
|
||||
// Updated: the document's bytes are the file's, so the next poll reads once and says ok.
|
||||
readable(true);
|
||||
assert_eq!(s.execute("links.update", &json!({})).unwrap()["updated"], 1);
|
||||
assert_eq!(status(&mut s), "ok");
|
||||
readable(false);
|
||||
assert_eq!(status(&mut s), "ok", "remembered after the update");
|
||||
// A different size needs no read at all.
|
||||
readable(true);
|
||||
std::fs::write(&path, png(41, 41)).unwrap();
|
||||
touch(10);
|
||||
readable(false);
|
||||
assert_eq!(status(&mut s), "modified", "size differs: decided from metadata");
|
||||
readable(true);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A linked file's size is the document's choice, so a link is read under a cap (#1596).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
@@ -154,7 +226,7 @@ fn eps_status_tracks_source_bytes_across_save_update_relink_and_embed() {
|
||||
std::fs::remove_file(&replacement).unwrap();
|
||||
let embedded = designcraft_format::load(&designcraft_format::save(&s.doc().unwrap().doc).unwrap()).unwrap();
|
||||
// The asset is embedded, so no link is followed and the document is irrelevant.
|
||||
assert_eq!(status(&embedded.assets[&aid], None), "embedded");
|
||||
assert_eq!(status_cached(&embedded.assets[&aid], None, &mut Default::default()), "embedded");
|
||||
assert_eq!(embedded.assets[&aid].data.as_slice(), changed);
|
||||
assert!(designcraft_render::decode_pixmap(&embedded.assets[&aid].data).is_some());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
|
||||
@@ -70,8 +70,8 @@ pub(crate) fn apply(d: &mut Document, sr: SpreadRef, pi: usize, old: Rect) {
|
||||
let liquid_guides = |o: Orientation| pg.guides.iter().filter(|g| g.liquid && g.orientation == o).map(|g| g.position).collect::<Vec<_>>();
|
||||
let (vg, hg) = (liquid_guides(Orientation::Vertical), liquid_guides(Orientation::Horizontal));
|
||||
// The page's objects: centres within its old horizontal extent (outer pages take the pasteboard).
|
||||
let last = sp.pages.len() - 1;
|
||||
let (lo, hi) = (if pi == 0 { f64::NEG_INFINITY } else { old.x0 }, if pi == last { f64::INFINITY } else { old.x1 });
|
||||
let Some((leftmost, rightmost)) = sp.outer_page_indices() else { return };
|
||||
let (lo, hi) = (if pi == leftmost { f64::NEG_INFINITY } else { old.x0 }, if pi == rightmost { f64::INFINITY } else { old.x1 });
|
||||
let strokes = false;
|
||||
let Some(sp) = d.spread_mut(sr) else { return };
|
||||
for it in &mut sp.items {
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use designcraft_doc::{Columns, LiquidRule, PageSide, SpreadId};
|
||||
use designcraft_geom::Rect;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn parent_id(s: &Session, index: usize) -> u64 {
|
||||
s.doc().unwrap().doc.parents[index].id.0
|
||||
}
|
||||
|
||||
fn parent_page(s: &Session, index: usize, page: usize) -> designcraft_doc::Page {
|
||||
s.doc().unwrap().doc.parents[index].pages[page].clone()
|
||||
}
|
||||
|
||||
fn parent_frame(s: &mut Session, x: f64) -> u64 {
|
||||
s.execute(
|
||||
"frame.create",
|
||||
&json!({
|
||||
"spread": {"kind": "parent", "index": 0},
|
||||
"rect": [x, 80.0, x + 100.0, 180.0]
|
||||
}),
|
||||
)
|
||||
.unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn bounds(s: &Session, id: u64) -> Rect {
|
||||
s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds()
|
||||
}
|
||||
|
||||
fn guide_positions(s: &Session, page: usize) -> Vec<f64> {
|
||||
s.doc().unwrap().doc.parents[0].pages[page].guides.iter().map(|g| g.position).collect()
|
||||
}
|
||||
|
||||
fn prepare_parent_pages(s: &mut Session, binding: &str, rule: LiquidRule) -> (u64, u64, u64, usize, usize) {
|
||||
s.execute("file.new", &json!({"pages": 2, "facingPages": true, "width": 400.0, "height": 400.0})).unwrap();
|
||||
s.execute("layout.documentSetup", &json!({"binding": binding})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.settings.right_to_left_binding, binding == "rightToLeft");
|
||||
assert_eq!(s.doc().unwrap().doc.parents[0].pages[0].side, if binding == "rightToLeft" { PageSide::Right } else { PageSide::Left });
|
||||
let target = s.doc().unwrap().doc.parents[0].pages.iter().position(|p| p.side == PageSide::Right).unwrap();
|
||||
let other = 1 - target;
|
||||
let target_page = parent_page(s, 0, target);
|
||||
let other_page = parent_page(s, 0, other);
|
||||
let target_item = parent_frame(s, target_page.x + 80.0);
|
||||
let other_item = parent_frame(s, other_page.x + 80.0);
|
||||
s.execute(
|
||||
"guide.add",
|
||||
&json!({"spread": {"kind": "parent", "index": 0}, "page": target, "orientation": "vertical", "position": target_page.x + 120.0}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute(
|
||||
"guide.add",
|
||||
&json!({"spread": {"kind": "parent", "index": 0}, "page": target, "orientation": "horizontal", "position": 120.0, "at": target_page.x + 10.0}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute(
|
||||
"guide.add",
|
||||
&json!({"spread": {"kind": "parent", "index": 0}, "page": other, "orientation": "vertical", "position": other_page.x + 120.0}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute(
|
||||
"guide.add",
|
||||
&json!({"spread": {"kind": "parent", "index": 0}, "page": other, "orientation": "horizontal", "position": 120.0, "at": other_page.x + 10.0}),
|
||||
)
|
||||
.unwrap();
|
||||
s.edit(|d, _| {
|
||||
let sp = Arc::make_mut(&mut d.parents[0]);
|
||||
let positions = |page: &designcraft_doc::Page| {
|
||||
let w = page.margin_rect().width();
|
||||
vec![0.0, 100.0, 120.0, w]
|
||||
};
|
||||
for page in &mut sp.pages {
|
||||
page.columns = Columns { count: 2, gutter: 20.0, positions: Some(positions(page)) };
|
||||
}
|
||||
sp.pages[target].liquid = rule;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
(parent_id(s, 0), target_item, other_item, target, other)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_resize_changes_only_the_target_page_in_ltr_and_rtl() {
|
||||
for binding in ["leftToRight", "rightToLeft"] {
|
||||
for rule in [
|
||||
LiquidRule::Off,
|
||||
LiquidRule::Scale,
|
||||
LiquidRule::ReCenter,
|
||||
LiquidRule::ObjectBased,
|
||||
LiquidRule::GuideBased,
|
||||
LiquidRule::ControlledByParent,
|
||||
] {
|
||||
let mut s = Session::new();
|
||||
let (parent, target_item, other_item, target, other) = prepare_parent_pages(&mut s, binding, rule);
|
||||
let before_target = parent_page(&s, 0, target);
|
||||
let before_other = parent_page(&s, 0, other);
|
||||
let before_target_item = bounds(&s, target_item);
|
||||
let before_other_item = bounds(&s, other_item);
|
||||
let before_target_guides = guide_positions(&s, target);
|
||||
let before_other_guides = guide_positions(&s, other);
|
||||
let before_target_columns = before_target.columns.positions.clone();
|
||||
let before_other_columns = before_other.columns.positions.clone();
|
||||
|
||||
s.execute("layout.selectParentPage", &json!({"parent": parent, "page": target})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.parent_page, Some((SpreadId(parent), target)));
|
||||
assert!(s.doc().unwrap().selection.items.is_empty());
|
||||
s.execute("layout.parentPageSize", &json!({"parent": parent, "page": target, "width": 500.0, "height": 500.0, "anchor": 8})).unwrap();
|
||||
|
||||
let after_target = parent_page(&s, 0, target);
|
||||
let after_other = parent_page(&s, 0, other);
|
||||
assert_eq!((after_target.width, after_target.height), (500.0, 500.0), "{binding} {rule:?}");
|
||||
assert_eq!((after_other.width, after_other.height), (before_other.width, before_other.height), "{binding} {rule:?}");
|
||||
assert_ne!(bounds(&s, target_item), before_target_item, "target object did not follow the resize: {binding} {rule:?}");
|
||||
assert_eq!(bounds(&s, other_item), before_other_item, "other page object moved: {binding} {rule:?}");
|
||||
assert_ne!(guide_positions(&s, target), before_target_guides, "target guides did not follow the resize: {binding} {rule:?}");
|
||||
assert_eq!(guide_positions(&s, other), before_other_guides, "other page guides moved: {binding} {rule:?}");
|
||||
assert_ne!(after_target.columns.positions, before_target_columns, "target columns did not scale: {binding} {rule:?}");
|
||||
assert_eq!(after_other.columns.positions, before_other_columns, "other page columns changed: {binding} {rule:?}");
|
||||
assert_eq!(s.doc().unwrap().doc.settings.page_width, 400.0);
|
||||
assert_eq!(s.doc().unwrap().doc.settings.page_height, 400.0);
|
||||
s.doc().unwrap().doc.check().unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_resize_is_independent_of_applied_and_based_on_parents_and_undoable() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 1, "facingPages": false, "width": 400.0, "height": 400.0})).unwrap();
|
||||
let a = parent_id(&s, 0);
|
||||
let b = s.execute("layout.parents.new", &json!({"prefix": "B", "name": "Child"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("layout.parents.options", &json!({"parent": b, "basedOn": a})).unwrap();
|
||||
s.execute("layout.pages.applyParent", &json!({"pages": [0], "parent": b})).unwrap();
|
||||
let before = s.doc().unwrap().doc.as_ref().clone();
|
||||
let undo_before = s.doc().unwrap().history.undo.len();
|
||||
|
||||
s.execute("layout.parentPageSize", &json!({"parent": a, "page": 0, "width": 0.5, "anchor": 0})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.parents[0].pages[0].width, 0.5);
|
||||
assert_eq!(d.parents[1].pages[0].width, before.parents[1].pages[0].width);
|
||||
assert_eq!(d.spreads[0].pages[0].width, before.spreads[0].pages[0].width);
|
||||
assert_eq!(d.parents[1].parent.as_ref().unwrap().based_on, Some(SpreadId(a)));
|
||||
assert_eq!(d.settings.page_width, before.settings.page_width);
|
||||
d.check().unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo_before + 1);
|
||||
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.parents[0].pages[0].width, before.parents[0].pages[0].width);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.parents[0].pages[0].width, 0.5);
|
||||
s.doc().unwrap().doc.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_resize_survives_native_and_idml_round_trips() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 1, "facingPages": false, "width": 400.0, "height": 400.0})).unwrap();
|
||||
let a = parent_id(&s, 0);
|
||||
s.execute("layout.parentPageSize", &json!({"parent": a, "page": 0, "width": 0.5, "height": 0.75})).unwrap();
|
||||
let d = s.doc().unwrap().doc.as_ref().clone();
|
||||
d.check().unwrap();
|
||||
|
||||
let native = crate::cmd::from_bytes(&crate::cmd::to_bytes(&d).unwrap()).unwrap();
|
||||
assert_eq!((native.parents[0].pages[0].width, native.parents[0].pages[0].height), (0.5, 0.75));
|
||||
native.check().unwrap();
|
||||
|
||||
let idml = designcraft_idml::export_idml(&d);
|
||||
let reopened = designcraft_idml::import_idml(&idml).unwrap();
|
||||
assert_eq!((reopened.parents[0].pages[0].width, reopened.parents[0].pages[0].height), (0.5, 0.75));
|
||||
reopened.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_resize_rejects_invalid_inputs_atomically() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap();
|
||||
let parent = parent_id(&s, 0);
|
||||
let before = s.doc().unwrap().doc.as_ref().clone();
|
||||
let undo_before = s.doc().unwrap().history.undo.len();
|
||||
let invalid = [
|
||||
json!({"parent": parent, "page": 0, "width": null}),
|
||||
json!({"parent": parent, "page": 0, "width": "400"}),
|
||||
json!({"parent": parent, "page": 0, "width": "NaN"}),
|
||||
json!({"parent": parent, "page": 0, "width": "Infinity"}),
|
||||
json!({"parent": parent, "page": 0, "width": 0}),
|
||||
json!({"parent": parent, "page": 0, "width": -1}),
|
||||
json!({"parent": parent, "page": 0, "width": 15552.1}),
|
||||
json!({"parent": parent, "page": 0, "width": 400, "anchor": 9}),
|
||||
json!({"parent": parent, "page": 1, "width": 400}),
|
||||
json!({"parent": 999_999, "page": 0, "width": 400}),
|
||||
];
|
||||
for params in invalid {
|
||||
assert!(s.execute("layout.parentPageSize", ¶ms).is_err(), "accepted {params}");
|
||||
assert_eq!(s.doc().unwrap().doc.as_ref(), &before, "mutated after {params}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo_before, "added undo after {params}");
|
||||
}
|
||||
assert!(s.execute("layout.parentPageSize", &json!({"parent": parent, "page": 0})).is_err());
|
||||
assert_eq!(s.doc().unwrap().doc.as_ref(), &before);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo_before);
|
||||
s.doc().unwrap().doc.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_and_document_page_selection_validate_and_clear_each_other() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap();
|
||||
let parent = parent_id(&s, 0);
|
||||
let item = s.execute("frame.create", &json!({"rect": [40, 40, 100, 100]})).unwrap()["id"].as_u64().unwrap();
|
||||
assert!(!s.doc().unwrap().selection.items.is_empty());
|
||||
s.execute("layout.selectParentPage", &json!({"parent": parent, "page": 0})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.parent_page, Some((SpreadId(parent), 0)));
|
||||
assert!(s.doc().unwrap().selection.items.is_empty());
|
||||
assert!(s.execute("layout.selectParentPage", &json!({"parent": parent, "page": 1})).is_err());
|
||||
assert_eq!(s.doc().unwrap().selection.parent_page, Some((SpreadId(parent), 0)));
|
||||
s.execute("layout.selectPage", &json!({"page": 1, "ids": [item]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.page, Some(0));
|
||||
assert_eq!(s.doc().unwrap().selection.parent_page, None);
|
||||
s.execute("layout.selectParentPage", &json!({"parent": null})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection, Default::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_resize_repeated_edits_keep_the_spine_and_other_page_objects() {
|
||||
for binding in ["leftToRight", "rightToLeft"] {
|
||||
for pi in 0..2 {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width":400,"height":600,"pages":3})).unwrap();
|
||||
s.execute("layout.documentSetup", &json!({"binding":binding})).unwrap();
|
||||
let id = parent_id(&s, 0);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let spine = before.parents[0].spine_x();
|
||||
let other = 1 - pi;
|
||||
let ox = before.parents[0].pages[other].x + 80.0;
|
||||
let object = parent_frame(&mut s, ox);
|
||||
let old_object = bounds(&s, object);
|
||||
for params in
|
||||
[json!({"parent":id,"page":pi,"width":500}), json!({"parent":id,"page":pi,"height":700}), json!({"parent":id,"page":pi,"width":550})]
|
||||
{
|
||||
s.execute("layout.parentPageSize", ¶ms).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.parents[0].spine_x(), spine, "spine moved: {binding}, page {pi}, {params}");
|
||||
assert_eq!(bounds(&s, object), old_object, "other-page object moved: {binding}, page {pi}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_resize_keeps_margin_and_column_inheritance_without_resizing_applied_pages() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width":400,"height":600,"pages":3})).unwrap();
|
||||
let id = parent_id(&s, 0);
|
||||
s.edit(|d, _| {
|
||||
let pg = &mut Arc::make_mut(&mut d.parents[0]).pages[1];
|
||||
pg.columns = Columns { count: 2, gutter: 20.0, positions: Some(vec![0.0, 100.0, 120.0, 328.0]) };
|
||||
d.sync_parent_margins();
|
||||
Arc::make_mut(&mut d.spreads[1]).pages[1].local_margins = true;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
s.execute("layout.parentPageSize", &json!({"parent":id,"page":1,"width":500})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.page(0).unwrap().columns, d.parents[0].pages[1].columns);
|
||||
assert_eq!(d.page(0).unwrap().margins, d.parents[0].pages[1].margins);
|
||||
assert_eq!((d.page(0).unwrap().width, d.page(0).unwrap().height), (400.0, 600.0));
|
||||
assert_eq!(d.page(2).unwrap(), before.page(2).unwrap());
|
||||
d.check().unwrap();
|
||||
s.execute("layout.pages.applyParent", &json!({"pages":[0],"parent":null})).unwrap();
|
||||
s.execute("layout.pages.applyParent", &json!({"pages":[0],"parent":id})).unwrap();
|
||||
assert_eq!((s.doc().unwrap().doc.page(0).unwrap().width, s.doc().unwrap().doc.page(0).unwrap().height), (400.0, 600.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_resize_facing_sizes_survive_idml_in_both_bindings() {
|
||||
for binding in ["leftToRight", "rightToLeft"] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width":400,"height":600,"pages":3,"facingPages":true})).unwrap();
|
||||
s.execute("layout.documentSetup", &json!({"binding":binding})).unwrap();
|
||||
let id = parent_id(&s, 0);
|
||||
for (pi, w, h) in [(0, 500, 700), (1, 300, 550)] {
|
||||
s.execute("layout.parentPageSize", &json!({"parent":id,"page":pi,"width":w,"height":h})).unwrap();
|
||||
}
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let reopened = designcraft_idml::import_idml(&designcraft_idml::export_idml(d)).unwrap();
|
||||
assert_eq!(reopened.settings.right_to_left_binding, d.settings.right_to_left_binding);
|
||||
for page in &d.parents[0].pages {
|
||||
let restored = reopened.parents[0].pages.iter().find(|p| p.side == page.side).unwrap();
|
||||
assert_eq!((restored.width, restored.height), (page.width, page.height), "{binding}, {:?}", page.side);
|
||||
}
|
||||
for abs in 0..d.page_count() {
|
||||
let (old, new) = (d.page(abs).unwrap(), reopened.page(abs).unwrap());
|
||||
assert_eq!((new.width, new.height), (old.width, old.height));
|
||||
}
|
||||
reopened.check().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,25 @@ fn page_of(d: &Document, sr: SpreadRef, it: &Item, parent: Affine) -> Option<usi
|
||||
Some(d.first_page_of_spread(si) + sp.page_at_x(bounds.center().x).unwrap_or(0))
|
||||
}
|
||||
|
||||
/// Every page of the spread the frame reaches, not just the one its centre is on.
|
||||
///
|
||||
/// A sheet draws the items of its spread without testing which page they sit on, so a frame that
|
||||
/// straddles two pages is drawn, in part, on both. Choosing by the centre alone dropped the
|
||||
/// warning for a frame whose centre is on a page the export leaves out while the part on an
|
||||
/// exported page went into the file (noted in review). Empty when the frame is on no document
|
||||
/// spread, which is the same answer `page_of` gives.
|
||||
fn pages_of(d: &Document, sr: SpreadRef, it: &Item, parent: Affine) -> Vec<usize> {
|
||||
let SpreadRef::Doc(si) = sr else { return vec![] };
|
||||
let Some(sp) = d.spreads.get(si) else { return vec![] };
|
||||
let bounds = parent.transform_rect_bbox(it.bounds());
|
||||
let first = d.first_page_of_spread(si);
|
||||
let touched: Vec<usize> =
|
||||
sp.pages.iter().enumerate().filter(|(_, pg)| bounds.x1 > pg.x && bounds.x0 < pg.x + pg.width).map(|(i, _)| first + i).collect();
|
||||
// A frame wholly on the pasteboard touches no page's span; it still belongs to the page
|
||||
// `page_at_x` nominates, so the two functions agree about where it is.
|
||||
if touched.is_empty() { page_of(d, sr, it, parent).into_iter().collect() } else { touched }
|
||||
}
|
||||
|
||||
/// Characters a missing-glyph report names before "and N more".
|
||||
const MISSING_GLYPHS_LISTED: usize = 20;
|
||||
|
||||
@@ -74,11 +93,19 @@ fn missing_glyphs(
|
||||
}
|
||||
}
|
||||
|
||||
/// Locate an embedded frame at the outer page frame that actually renders it.
|
||||
fn overset_location(s: &Session, target: designcraft_doc::ItemId) -> Option<(designcraft_doc::ItemId, Option<usize>)> {
|
||||
/// Locate an embedded frame at the outer page frame that actually renders it: the frame, the page
|
||||
/// its centre is on, and every page it reaches (see [`pages_of`]).
|
||||
type Located = (designcraft_doc::ItemId, Option<usize>, Vec<usize>);
|
||||
|
||||
fn overset_location(s: &Session, target: designcraft_doc::ItemId) -> Option<Located> {
|
||||
let d = &s.active()?.doc;
|
||||
let at = |id: designcraft_doc::ItemId, loc: &designcraft_doc::ItemLoc| {
|
||||
let it = d.item(id);
|
||||
let xf = d.parent_xf(loc);
|
||||
(id, it.and_then(|it| page_of(d, loc.spread, it, xf)), it.map(|it| pages_of(d, loc.spread, it, xf)).unwrap_or_default())
|
||||
};
|
||||
if let Some(loc) = d.find(target) {
|
||||
return Some((target, d.item(target).and_then(|it| page_of(d, loc.spread, it, d.parent_xf(&loc)))));
|
||||
return Some(at(target, &loc));
|
||||
}
|
||||
fn contains(root: &designcraft_compose::FrameText, target: designcraft_doc::ItemId) -> bool {
|
||||
let mut frames = vec![root];
|
||||
@@ -107,12 +134,131 @@ fn overset_location(s: &Session, target: designcraft_doc::ItemId) -> Option<(des
|
||||
let frame = cs.frames.iter().find(|f| contains(f, current))?;
|
||||
current = frame.frame;
|
||||
if let Some(loc) = d.find(current) {
|
||||
return Some((current, d.item(current).and_then(|it| page_of(d, loc.spread, it, d.parent_xf(&loc)))));
|
||||
return Some(at(current, &loc));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Text that is in the document but not in a page-based export (PDF, PowerPoint): the overset
|
||||
/// part of a story, and a story with no frame at all. The export used to write the file and
|
||||
/// report nothing, so whole sentences left an imported document without a hint anywhere (#1465).
|
||||
///
|
||||
/// One line per story, naming the page and the frame as the preflight issue does. `pages` are the
|
||||
/// 0-based pages being exported; a story whose overset is on a page that is not being exported is
|
||||
/// not reported, and one with no page of its own always is. A reflowable export (EPUB, HTML) or a
|
||||
/// text export carries the whole story, so neither calls this.
|
||||
/// Pages a single warning names before "and N more".
|
||||
const MAX_WARNED_PAGES: usize = 10;
|
||||
|
||||
/// How the pages one warning covers are named: "page 3", "pages 3, 4", "pages 3, 4 and 6 more".
|
||||
fn page_list(pages: &[usize]) -> String {
|
||||
let shown: Vec<String> = pages.iter().take(MAX_WARNED_PAGES).map(|p| (p + 1).to_string()).collect();
|
||||
let rest = pages.len() - shown.len();
|
||||
let list = shown.join(", ");
|
||||
match (pages.len(), rest) {
|
||||
(1, _) => format!("page {list}"),
|
||||
(_, 0) => format!("pages {list}"),
|
||||
_ => format!("pages {list} and {rest} more"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The parent spread and the page of it a frame sits on, or `None` for a frame on a document page.
|
||||
fn parent_page_of(d: &Document, target: designcraft_doc::ItemId) -> Option<(usize, usize)> {
|
||||
let loc = d.find(target)?;
|
||||
let SpreadRef::Parent(pi) = loc.spread else { return None };
|
||||
let bounds = d.parent_xf(&loc).transform_rect_bbox(d.item(target)?.bounds());
|
||||
Some((pi, d.spread(loc.spread)?.page_at_x(bounds.center().x).unwrap_or(0)))
|
||||
}
|
||||
|
||||
/// Document pages that draw parent page `pp` of parent spread `pi`, in document order.
|
||||
fn pages_showing(d: &Document, (pi, pp): (usize, usize)) -> Vec<usize> {
|
||||
(0..d.page_count())
|
||||
.filter(|p| d.page_loc(*p).is_some_and(|(si, pgi)| d.shown_parent_pages(SpreadRef::Doc(si), pgi).contains(&(pi, pp))))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Characters of `story` that `cs` had no room for.
|
||||
fn overset_chars(story: &designcraft_doc::Story, cs: &ComposedStory) -> usize {
|
||||
story.text.get(cs.overset_at.unwrap_or(0)..).unwrap_or("").chars().count()
|
||||
}
|
||||
|
||||
pub fn overset_warnings(s: &Session, pages: Option<&[usize]>) -> Vec<String> {
|
||||
let Some(st) = s.active() else { return vec![] };
|
||||
let d = &st.doc;
|
||||
let mut out = Vec::new();
|
||||
for story in d.stories.values() {
|
||||
let last = story.frames.last().copied();
|
||||
// A story on a parent page is composed once per page that shows it: its page number
|
||||
// markers and variables are that page's, so the same frame can fit on one page and
|
||||
// overset on another, and a parent page no exported page uses puts nothing in the file
|
||||
// to be missing. Composing it once with no page reported neither (noted in review).
|
||||
if let Some(parent) = last.and_then(|f| parent_page_of(d, f)) {
|
||||
let mut by_count: std::collections::BTreeMap<usize, Vec<usize>> = std::collections::BTreeMap::new();
|
||||
for page in pages_showing(d, parent) {
|
||||
if pages.is_some_and(|sel| !sel.contains(&page)) {
|
||||
continue;
|
||||
}
|
||||
let cs = s.cache.get(d, story.id, Some(page));
|
||||
let n = overset_chars(story, &cs);
|
||||
if cs.is_overset() && n > 0 {
|
||||
by_count.entry(n).or_default().push(page);
|
||||
}
|
||||
}
|
||||
let item = last.map_or(0, |f| f.0);
|
||||
out.extend(
|
||||
by_count.into_iter().map(|(n, ps)| {
|
||||
format!("{}: {n} character(s) of the story in frame {item} do not fit it and are not in the export", page_list(&ps))
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let cs = s.cache.get(d, story.id, None);
|
||||
if !cs.is_overset() {
|
||||
continue;
|
||||
}
|
||||
let n = overset_chars(story, &cs);
|
||||
if n == 0 {
|
||||
continue;
|
||||
}
|
||||
if story.frames.is_empty() {
|
||||
out.push(format!("a story with no text frame: {n} character(s) of it are not in the export"));
|
||||
continue;
|
||||
}
|
||||
let location = last.and_then(|f| overset_location(s, f));
|
||||
let centre = location.as_ref().and_then(|(_, page, _)| *page);
|
||||
let touched: &[usize] = location.as_ref().map(|(_, _, ps)| ps.as_slice()).unwrap_or_default();
|
||||
// A sheet draws its spread's items without asking which page they are on, so a frame
|
||||
// straddling two pages is drawn in part on both. Testing the centre page alone dropped
|
||||
// the warning for a frame centred on a page the export leaves out while the part on an
|
||||
// exported page went into the file (noted in review).
|
||||
//
|
||||
// The page named is one the export has: the centre page when that is exported, so an
|
||||
// ordinary frame reads as before, otherwise the first exported page the frame reaches.
|
||||
let page = match pages {
|
||||
None => centre,
|
||||
Some(sel) => {
|
||||
let exported: Vec<usize> = touched.iter().copied().filter(|p| sel.contains(p)).collect();
|
||||
match centre.filter(|p| sel.contains(p)).or_else(|| exported.first().copied()) {
|
||||
Some(p) => Some(p),
|
||||
// Nothing of this frame is on an exported page.
|
||||
None if centre.is_some() || !touched.is_empty() => continue,
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
};
|
||||
let item = location.as_ref().map(|(item, _, _)| *item).or(last);
|
||||
out.push(match (page, item) {
|
||||
(Some(page), Some(item)) => {
|
||||
format!("page {}: {n} character(s) of the story in frame {} do not fit it and are not in the export", page + 1, item.0)
|
||||
}
|
||||
(None, Some(item)) => format!("the story in frame {}: {n} character(s) do not fit and are not in the export", item.0),
|
||||
_ => format!("{n} character(s) of a story do not fit their frame and are not in the export"),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> {
|
||||
let Some(st) = s.active() else { return vec![] };
|
||||
let d = &st.doc;
|
||||
@@ -138,8 +284,8 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> {
|
||||
} else {
|
||||
format!("Overset text: {n} characters")
|
||||
},
|
||||
item: location.map(|(item, _)| item.0).or(last.map(|i| i.0)),
|
||||
page: location.and_then(|(_, page)| page),
|
||||
item: location.as_ref().map(|(item, _, _)| item.0).or(last.map(|i| i.0)),
|
||||
page: location.as_ref().and_then(|(_, page, _)| *page),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,12 +191,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
}
|
||||
),
|
||||
cmd!(query "story.get", "Get Story", [], None, "{story? | frame?} → text, frames, paragraphs, vertical, overset", has_doc, |s, p| {
|
||||
cmd!(query "story.get", "Get Story", [], None, "{story? | frame?} → text, frames, paragraphs, vertical, lines, overset (the UTF-8 byte offset into `text` where it stops fitting its frames, not a character index, null when it all fits) and oversetChars (how many characters that leaves, as preflight reports them)", has_doc, |s, p| {
|
||||
let st = s.doc()?;
|
||||
let sid = story_of(s, p).ok_or_else(|| bad("story.get", "no story"))?;
|
||||
let mut v = st.doc.story_summary(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?;
|
||||
let cs = s.cache.get(&st.doc, sid, None);
|
||||
// `overset` is where the text stops fitting, not how much is left over: the two were
|
||||
// read as the same number and disagreed with preflight's count (#1465).
|
||||
v["overset"] = json!(cs.overset_at);
|
||||
v["oversetChars"] = json!(cs
|
||||
.overset_at
|
||||
.map(|at| st.doc.stories.get(&sid).and_then(|story| story.text.get(at..)).unwrap_or("").chars().count()));
|
||||
v["lines"] = json!(cs.line_count());
|
||||
Ok(v)
|
||||
}),
|
||||
@@ -206,7 +211,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character Formatting",
|
||||
[],
|
||||
None,
|
||||
"{attrs: {fontFamily?, fontStyle?, size?, leading?: {kind:auto}|{kind:points,value}, tracking?, kerning?, hScale?, vScale?, baselineShift?, fill?, capitalization?, underline?, …}}",
|
||||
"{attrs: {fontFamily?, fontStyle?, size?, leading?: {kind:auto}|{kind:points,value}, tracking?, kerning?, hScale?, vScale?, baselineShift?, fill?, capitalization?, underline?, …}} → null, or {warnings} naming a fontFamily or fontStyle that isn't available (still applied; a substitute draws the text)",
|
||||
has_text_or_frames,
|
||||
|s, p| format_chars(s, p.get("attrs").unwrap_or(p))
|
||||
),
|
||||
|
||||
@@ -162,6 +162,8 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
validate_char_fill(&s.doc()?.doc, &a, "type.char")?;
|
||||
let warnings = missing_font_warnings(&s.doc()?.doc, &a);
|
||||
let result = if warnings.is_empty() { Value::Null } else { json!({ "warnings": warnings }) };
|
||||
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. On an empty paragraph's hidden mark it
|
||||
@@ -185,14 +187,33 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result<Value> {
|
||||
})?;
|
||||
}
|
||||
s.typing_format = Some(f);
|
||||
return ok();
|
||||
return Ok(result);
|
||||
}
|
||||
s.edit(|d, _| {
|
||||
apply_char_attrs(d, &targets, &a, &cleared);
|
||||
ok()
|
||||
Ok(result)
|
||||
})
|
||||
}
|
||||
|
||||
/// A font family or style the app doesn't have is still applied, so the document keeps its name
|
||||
/// and preflight and Find Font list it, but a substitute draws the text. Say so, in preflight's
|
||||
/// words, instead of succeeding silently.
|
||||
fn missing_font_warnings(document: &designcraft_doc::Document, attrs: &CharAttrs) -> Vec<String> {
|
||||
let Some(family) = attrs.font_family.as_deref().filter(|f| !f.trim().is_empty()) else {
|
||||
return vec![];
|
||||
};
|
||||
let db = designcraft_fonts::FontDb::global().scoped(document.font_scope);
|
||||
let style = attrs.font_style.as_deref().unwrap_or("Regular");
|
||||
let face = db.face(family, style);
|
||||
if !db.has_family(family) {
|
||||
vec![format!("Missing font: {family} (using {})", face.family)]
|
||||
} else if attrs.font_style.is_some() && !db.has_style(family, style) {
|
||||
vec![format!("Missing font style: {family} {style} (using {} {})", face.family, face.style)]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
/// Kerning spaces a character from the next one, so the pair at a caret is kerned through the
|
||||
/// character before it. With no such pair (story or paragraph start, a selection) this is
|
||||
/// `type.char`.
|
||||
@@ -1073,3 +1094,37 @@ mod size_bounds_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod missing_font_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// A font the app doesn't have was applied with no word about it, so a script that asked for
|
||||
/// Courier Prime exported a PDF in the substitute and exited 0. It is still applied (preflight
|
||||
/// and Find Font list it), and the command now says which font is missing.
|
||||
#[test]
|
||||
fn a_font_that_is_not_available_is_applied_with_a_warning() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 1})).unwrap();
|
||||
let f = s.execute("frame.create", &json!({"rect": [20, 20, 400, 300], "content": "text", "text": "Hello world"})).unwrap();
|
||||
for (attrs, warning) in [
|
||||
(json!({"fontFamily": "Source Serif 4", "fontStyle": "Bold"}), None),
|
||||
(json!({"fontFamily": "Nonexistent Sans"}), Some("Missing font: Nonexistent Sans (using ")),
|
||||
(json!({"fontFamily": "Source Serif 4", "fontStyle": "Nope"}), Some("Missing font style: Source Serif 4 Nope (using Source Serif 4 ")),
|
||||
] {
|
||||
s.execute("text.select", &json!({"story": f["story"].clone(), "anchor": 0, "focus": 5})).unwrap();
|
||||
let r = s.execute("type.char", &json!({"attrs": attrs})).unwrap();
|
||||
match warning {
|
||||
None => assert_eq!(r, json!(null), "{attrs}"),
|
||||
Some(w) => assert!(r["warnings"][0].as_str().is_some_and(|m| m.starts_with(w)), "{attrs}: {r}"),
|
||||
}
|
||||
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fontFamily"], attrs["fontFamily"], "{attrs}");
|
||||
}
|
||||
// At a caret the font is for the next typing, and the warning is the same.
|
||||
s.execute("text.select", &json!({"story": f["story"].clone(), "anchor": 5, "focus": 5})).unwrap();
|
||||
let r = s.execute("type.char", &json!({"attrs": {"fontFamily": "Nonexistent Sans"}})).unwrap();
|
||||
assert!(r["warnings"][0].as_str().is_some_and(|m| m.starts_with("Missing font: Nonexistent Sans")), "{r}");
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Define Text Variable…",
|
||||
["Type", "Text Variables"],
|
||||
None,
|
||||
"{name, type: custom|lastPageNumber|chapterNumber|fileName|creationDate|modificationDate|outputDate|runningHeader, text?, format?, style?, use?: firstOnPage|lastOnPage, character?, section?, extension?, before?, after?} — creates or replaces by name",
|
||||
"{name, type: custom|lastPageNumber|chapterNumber|fileName|creationDate|modificationDate|outputDate|runningHeader, text? (a string; a number or a boolean is shown as typed, anything else is refused), format?, style?, use?: firstOnPage|lastOnPage, character?, section?, extension?, before?, after?} — creates or replaces by name",
|
||||
has_doc,
|
||||
define
|
||||
),
|
||||
@@ -391,11 +391,30 @@ fn unvar(st: &mut designcraft_doc::Story, i: usize, n: usize, value: &str, depth
|
||||
}
|
||||
}
|
||||
|
||||
/// A parameter's text: a string as it is; a number or a boolean spelled out. A Custom variable's
|
||||
/// text typed as `2026` reaches the command as a number (the dialog and JSON callers type their
|
||||
/// values), and it is still the text the variable shows (#2093).
|
||||
fn text_param(p: &Value, key: &str) -> Option<String> {
|
||||
match p.get(key)? {
|
||||
Value::String(s) => Some(s.clone()),
|
||||
Value::Number(n) => Some(n.to_string()),
|
||||
Value::Bool(b) => Some(b.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn define(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("variables.define", "missing name"))?.to_string();
|
||||
let fmt = || str_param(p, "format").unwrap_or("MM/dd/yy").to_string();
|
||||
let kind = match str_param(p, "type").unwrap_or("custom") {
|
||||
"custom" => VarKind::Custom { text: str_param(p, "text").unwrap_or("").into() },
|
||||
"custom" => VarKind::Custom {
|
||||
// No `text` is empty text; an object or an array is refused rather than stored as
|
||||
// empty text in place of what a variable of that name holds.
|
||||
text: match p.get("text") {
|
||||
None | Some(Value::Null) => String::new(),
|
||||
Some(_) => text_param(p, "text").ok_or_else(|| bad("variables.define", "`text` must be a string, a number or a boolean"))?,
|
||||
},
|
||||
},
|
||||
"lastPageNumber" => VarKind::LastPageNumber { section: p.get("section").and_then(Value::as_bool).unwrap_or(false) },
|
||||
"chapterNumber" => VarKind::ChapterNumber,
|
||||
"fileName" => VarKind::FileName { extension: p.get("extension").and_then(Value::as_bool).unwrap_or(false) },
|
||||
|
||||
@@ -434,3 +434,30 @@ fn running_header_orders_stories_by_their_first_heading() {
|
||||
assert_eq!(idx.value("Heading", false, designcraft_doc::vars::Use::FirstOnPage, 0), "Beta");
|
||||
assert_eq!(idx.value("Heading", false, designcraft_doc::vars::Use::LastOnPage, 0), "Gamma");
|
||||
}
|
||||
|
||||
/// #2093: a Custom variable whose text arrives as a number (the dialog types a typed `2026` as
|
||||
/// one) shows those digits; it showed nothing at all before.
|
||||
#[test]
|
||||
fn a_custom_variable_defined_with_a_number_shows_its_digits() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("variables.define", &json!({"name": "Year", "type": "custom", "text": 2026})).unwrap();
|
||||
s.execute("variables.define", &json!({"name": "Half", "type": "custom", "text": 0.5})).unwrap();
|
||||
s.execute("variables.define", &json!({"name": "Word", "type": "custom", "text": "year"})).unwrap();
|
||||
s.execute("variables.define", &json!({"name": "Flag", "type": "custom", "text": true})).unwrap();
|
||||
// Anything else is refused, and a variable of that name keeps its text; no text is empty text.
|
||||
assert!(s.execute("variables.define", &json!({"name": "Year", "type": "custom", "text": {"y": 2026}})).is_err());
|
||||
assert!(s.execute("variables.define", &json!({"name": "Year", "type": "custom", "text": [2026]})).is_err());
|
||||
s.execute("variables.define", &json!({"name": "Blank", "type": "custom"})).unwrap();
|
||||
let list = s.execute("variables.list", &json!({})).unwrap();
|
||||
let value = |name: &str| list.as_array().unwrap().iter().find(|v| v["name"] == name).unwrap()["value"].as_str().unwrap().to_string();
|
||||
assert_eq!((value("Year"), value("Half"), value("Word")), ("2026".to_string(), "0.5".to_string(), "year".to_string()));
|
||||
assert_eq!((value("Flag"), value("Blank")), ("true".to_string(), String::new()));
|
||||
// Inserted into a story, the instance composes as four digits.
|
||||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "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": 0})).unwrap();
|
||||
s.execute("variables.insert", &json!({"name": "Year"})).unwrap();
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
assert_eq!(shown_on_parent(&s, &d, sid), 4, "2026 shows");
|
||||
}
|
||||
@@ -435,6 +435,10 @@ pub struct Session {
|
||||
/// 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)>,
|
||||
/// Linked images checked against their files this session (`links.list` polls twice a second):
|
||||
/// a file of the recorded size and time isn't read again.
|
||||
pub(crate) image_link_checks:
|
||||
std::collections::HashMap<(designcraft_doc::AssetId, usize, std::path::PathBuf), (designcraft_doc::Fingerprint, bool)>,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
@@ -467,6 +471,7 @@ impl Session {
|
||||
recovery_dir: None,
|
||||
text_clipboard: None,
|
||||
text_link_checks: Default::default(),
|
||||
image_link_checks: Default::default(),
|
||||
ui_requests: vec![],
|
||||
loaded: None,
|
||||
transforms: Default::default(),
|
||||
|
||||
@@ -1721,6 +1721,62 @@ fn a_line_fits_when_its_baseline_does() {
|
||||
assert!(overset(&mut s, 50.0), "a baseline below the frame still oversets the line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_resize_tool_previews_cancels_commits_and_undoes_on_the_selected_spread() {
|
||||
use designcraft_tools::{Mods, Overlay, PointerEvent, PointerKind};
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width":400,"height":600,"pages":3,"facingPages":true})).unwrap();
|
||||
s.execute("layout.parents.new", &json!({"prefix":"B"})).unwrap();
|
||||
s.execute("layout.parents.edit", &json!({"on":true})).unwrap();
|
||||
s.set_tool("page");
|
||||
let v = ViewInfo::at_zoom(1.0);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
for si in 0..2 {
|
||||
for pi in 0..2 {
|
||||
let sr = designcraft_doc::SpreadRef::Parent(si);
|
||||
let rect = |s: &Session| s.layout().spread_page_rect(&s.doc().unwrap().doc, sr, pi).unwrap();
|
||||
let c = rect(&s).center();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, c.x, c.y), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, c.x, c.y), v).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.parent_page, Some((before.parents[si].id, pi)));
|
||||
assert_eq!(s.doc().unwrap().selection.page, None);
|
||||
assert!(s.overlays(v).iter().any(|o| matches!(o,Overlay::PageHandles(r) if *r==rect(&s))));
|
||||
for alt in [false, true] {
|
||||
let original = s.doc().unwrap().doc.clone();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let r = rect(&s);
|
||||
let p = designcraft_geom::Point::new(r.x1, r.center().y);
|
||||
let ev = |kind, x| PointerEvent::new(kind, x, p.y).with_mods(Mods { alt, ..Default::default() });
|
||||
s.pointer(&ev(PointerKind::Down, p.x), v).unwrap();
|
||||
s.pointer(&ev(PointerKind::Drag, p.x + 80.0), v).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.parents[si].pages[pi].width, 480.0);
|
||||
assert_eq!(s.doc().unwrap().doc.spreads, before.spreads);
|
||||
assert_eq!(s.doc().unwrap().doc.parents[1 - si], before.parents[1 - si]);
|
||||
s.pointer(&ev(PointerKind::Up, p.x + 80.0), v).unwrap();
|
||||
if alt {
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
assert_eq!(s.doc().unwrap().doc.parents[si].pages[pi].width, 480.0);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.parents, original.parents);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.parents[si].pages[pi].width, 480.0);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
} else {
|
||||
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &original));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
}
|
||||
}
|
||||
let c = rect(&s).center();
|
||||
s.pointer(&PointerEvent::new(PointerKind::DoubleClick, c.x, c.y), v).unwrap();
|
||||
let req = std::mem::take(&mut s.ui_requests);
|
||||
assert!(
|
||||
matches!(req.last(),Some(crate::UiRequest::Dialog{id,params}) if id=="cmd:layout.parentPageSize" && params["parent"]==before.parents[si].id.0 && params["page"]==pi)
|
||||
);
|
||||
s.doc().unwrap().doc.check().unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset To Base with a new Based On: a partial rule edit resolves over the new parent, not the
|
||||
/// old one.
|
||||
#[test]
|
||||
|
||||
+25
-6
@@ -342,13 +342,21 @@ impl PathData {
|
||||
if self.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let bp = self.to_bezpath();
|
||||
if self.anchor_count() == 1
|
||||
&& let Some(a) = self.subpaths.iter().find_map(|s| s.anchors.first())
|
||||
{
|
||||
return Some(Rect::from_points(a.p, a.p));
|
||||
// `bounding_box` skips a lone MoveTo, so single-anchor subpaths are folded in by point.
|
||||
let mut bp = BezPath::new();
|
||||
let mut lone: Option<Rect> = None;
|
||||
for sp in &self.subpaths {
|
||||
if let [a] = sp.anchors.as_slice() {
|
||||
lone = Some(lone.map_or(Rect::from_points(a.p, a.p), |r| r.union_pt(a.p)));
|
||||
} else {
|
||||
sp.to_bezpath_into(&mut bp);
|
||||
}
|
||||
}
|
||||
Some(bp.bounding_box())
|
||||
if bp.elements().is_empty() {
|
||||
return lone;
|
||||
}
|
||||
let bb = bp.bounding_box();
|
||||
Some(lone.map_or(bb, |r| r.union(bb)))
|
||||
}
|
||||
/// Bounds including control handles (used for quick culling).
|
||||
pub fn control_bounds(&self) -> Option<Rect> {
|
||||
@@ -421,6 +429,17 @@ mod tests {
|
||||
PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0), Point::new(0.0, 10.0)], true))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_cover_single_anchor_subpaths() {
|
||||
let mut pd = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0)], true));
|
||||
pd.subpaths.push(SubPath::polyline(&[Point::new(50.0, 50.0)], false));
|
||||
assert_eq!(pd.bounds(), Some(Rect::new(0.0, 0.0, 50.0, 50.0)));
|
||||
|
||||
let mut two = PathData::single(SubPath::polyline(&[Point::new(5.0, 7.0)], false));
|
||||
two.subpaths.push(SubPath::polyline(&[Point::new(50.0, 50.0)], false));
|
||||
assert_eq!(two.bounds(), Some(Rect::new(5.0, 7.0, 50.0, 50.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_bezpath_detects_smooth_on_every_open_subpath() {
|
||||
let mut bp = BezPath::new();
|
||||
|
||||
@@ -9,6 +9,28 @@ pub fn offset_path(bp: &BezPath, d: f64, tol: f64) -> BezPath {
|
||||
return bp.clone();
|
||||
}
|
||||
let mut out = BezPath::new();
|
||||
for sub in subpaths(bp) {
|
||||
offset_subpath(&sub, d, tol, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `bp` split at each `MoveTo` into its subpaths.
|
||||
fn subpaths(bp: &BezPath) -> Vec<BezPath> {
|
||||
let mut out: Vec<BezPath> = Vec::new();
|
||||
for el in bp.elements() {
|
||||
if matches!(el, kurbo::PathEl::MoveTo(_)) || out.is_empty() {
|
||||
out.push(BezPath::new());
|
||||
}
|
||||
if let Some(sub) = out.last_mut() {
|
||||
sub.push(*el);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One subpath offset by `d`, its segments joined into one line, appended to `out`.
|
||||
fn offset_subpath(bp: &BezPath, d: f64, tol: f64, out: &mut BezPath) {
|
||||
let mut started = false;
|
||||
for seg in bp.segments() {
|
||||
let c = match seg {
|
||||
@@ -30,7 +52,6 @@ pub fn offset_path(bp: &BezPath, d: f64, tol: f64) -> BezPath {
|
||||
}
|
||||
started = true;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Bands (start, width) across a stroke, as fractions of the weight from its left edge.
|
||||
@@ -54,8 +75,13 @@ pub fn stripes(bp: &BezPath, weight: f64, bands: &Bands, tol: f64) -> BezPath {
|
||||
out
|
||||
}
|
||||
|
||||
/// Points along `bp` every `step` of arc length, with the unit tangent there.
|
||||
fn samples(bp: &BezPath, step: f64) -> Vec<(Point, Vec2)> {
|
||||
/// Points along each subpath of `bp` every `step` of arc length, with the unit tangent there.
|
||||
fn samples(bp: &BezPath, step: f64) -> Vec<Vec<(Point, Vec2)>> {
|
||||
subpaths(bp).iter().map(|sub| subpath_samples(sub, step)).collect()
|
||||
}
|
||||
|
||||
/// Points along one subpath every `step` of arc length, with the unit tangent there.
|
||||
fn subpath_samples(bp: &BezPath, step: f64) -> Vec<(Point, Vec2)> {
|
||||
let mut out = Vec::new();
|
||||
let mut carry = 0.0;
|
||||
for seg in bp.segments() {
|
||||
@@ -85,13 +111,14 @@ fn samples(bp: &BezPath, step: f64) -> Vec<(Point, Vec2)> {
|
||||
/// A wavy stroke: the line swings `weight` wide, drawn a third of the weight thick.
|
||||
pub fn wavy(bp: &BezPath, weight: f64, tol: f64) -> BezPath {
|
||||
let wavelength = (weight * 4.0).max(2.0);
|
||||
let pts = samples(bp, wavelength / 16.0);
|
||||
let mut line = BezPath::new();
|
||||
for (i, (p, t)) in pts.iter().enumerate() {
|
||||
let normal = Vec2::new(-t.y, t.x);
|
||||
let phase = i as f64 / 16.0 * std::f64::consts::TAU;
|
||||
let q = *p + normal * (phase.sin() * weight * 0.33);
|
||||
if i == 0 { line.move_to(q) } else { line.line_to(q) }
|
||||
for pts in samples(bp, wavelength / 16.0) {
|
||||
for (i, (p, t)) in pts.iter().enumerate() {
|
||||
let normal = Vec2::new(-t.y, t.x);
|
||||
let phase = i as f64 / 16.0 * std::f64::consts::TAU;
|
||||
let q = *p + normal * (phase.sin() * weight * 0.33);
|
||||
if i == 0 { line.move_to(q) } else { line.line_to(q) }
|
||||
}
|
||||
}
|
||||
let s = Stroke::new(weight / 3.0).with_join(kurbo::Join::Round);
|
||||
kurbo::stroke(line.iter(), &s, &kurbo::StrokeOpts::default(), tol)
|
||||
@@ -100,7 +127,7 @@ pub fn wavy(bp: &BezPath, weight: f64, tol: f64) -> BezPath {
|
||||
/// A straight-hash stroke: short ticks across the path, as wide as the weight.
|
||||
pub fn hashed(bp: &BezPath, weight: f64, tol: f64) -> BezPath {
|
||||
let mut ticks = BezPath::new();
|
||||
for (p, t) in samples(bp, (weight * 0.6).max(1.0)) {
|
||||
for (p, t) in samples(bp, (weight * 0.6).max(1.0)).into_iter().flatten() {
|
||||
let normal = Vec2::new(-t.y, t.x) * (weight / 2.0);
|
||||
ticks.move_to(p - normal);
|
||||
ticks.line_to(p + normal);
|
||||
@@ -141,4 +168,20 @@ mod tests {
|
||||
let h = hashed(&line(), 8.0, 0.01).bounding_box();
|
||||
assert!((h.height() - 8.0).abs() < 0.5 && h.width() > 90.0, "{h:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styled_strokes_keep_subpaths_apart() {
|
||||
// Two separate lines, 50 pt apart, in one compound path.
|
||||
let mut b = line();
|
||||
b.move_to((0.0, 50.0));
|
||||
b.line_to((100.0, 50.0));
|
||||
let moves = |p: &BezPath| p.elements().iter().filter(|e| matches!(e, kurbo::PathEl::MoveTo(_))).count();
|
||||
assert_eq!(moves(&offset_path(&b, 3.0, 0.01)), 2, "one offset subpath per input subpath");
|
||||
let s = stripes(&b, 12.0, &[(0.0, 0.5), (0.75, 0.25)], 0.01);
|
||||
assert!(!s.contains(Point::new(50.0, 25.0)), "stripe bridges the subpaths: {:?}", s.bounding_box());
|
||||
assert!(s.contains(Point::new(50.0, 46.0)), "second line is still striped");
|
||||
let w = wavy(&b, 4.0, 0.01);
|
||||
let bridged = (0..=2000).map(|i| f64::from(i) * 0.05).filter(|&x| w.contains(Point::new(x, 25.0))).count();
|
||||
assert_eq!(bridged, 0, "wavy bridges the subpaths: {:?}", w.bounding_box());
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,11 @@ impl<'a> Ex<'a> {
|
||||
}
|
||||
self.char_attrs(&mut el, &mut props, &cs.chars);
|
||||
}
|
||||
cg.insert(&cs.name, with_props(el, props));
|
||||
let mut el = with_props(el, props);
|
||||
if let Some(t) = styles.export_tag(&cs.name, true) {
|
||||
el.push(self.export_tag_map(t));
|
||||
}
|
||||
cg.insert(&cs.name, el);
|
||||
}
|
||||
let mut rc = El::new("RootCharacterStyleGroup").attr("Self", self.fresh());
|
||||
cg.emit(&mut rc, "CharacterStyleGroup", "");
|
||||
@@ -65,10 +69,15 @@ impl<'a> Ex<'a> {
|
||||
let pa = with_list_tab(pa, || (styles.resolve_para_style(&ps.name).0, parent_props));
|
||||
self.para_attrs(&mut el, &mut props, &pa, parent);
|
||||
self.char_attrs(&mut el, &mut props, &ca);
|
||||
if styles.export_tag(&ps.name, false).is_some_and(|t| t.split) {
|
||||
let tag = styles.export_tag(&ps.name, false);
|
||||
if tag.is_some_and(|t| t.split) {
|
||||
el.set("SplitDocument", "true");
|
||||
}
|
||||
pg.insert(&ps.name, with_props(el, props));
|
||||
let mut el = with_props(el, props);
|
||||
if let Some(t) = tag {
|
||||
el.push(self.export_tag_map(t));
|
||||
}
|
||||
pg.insert(&ps.name, el);
|
||||
}
|
||||
let mut rp = El::new("RootParagraphStyleGroup").attr("Self", self.fresh());
|
||||
pg.emit(&mut rp, "ParagraphStyleGroup", "");
|
||||
@@ -159,6 +168,18 @@ impl<'a> Ex<'a> {
|
||||
document(&root)
|
||||
}
|
||||
|
||||
/// A style's Export Tagging as its EPUB export tag map.
|
||||
fn export_tag_map(&mut self, t: &designcraft_doc::ExportTag) -> El {
|
||||
El::new("StyleExportTagMap")
|
||||
.attr("Self", self.fresh())
|
||||
.attr("ExportType", "EPUB")
|
||||
.attr("ExportTag", &t.tag)
|
||||
.attr("ExportClass", &t.class)
|
||||
.attr("ExportAttributes", "")
|
||||
.attr("SplitDocument", bool_s(t.split))
|
||||
.attr("EmitCss", "true")
|
||||
}
|
||||
|
||||
fn append_cell_styles(&mut self, root: &mut El, styles: &designcraft_doc::Styles) {
|
||||
let mut cells = El::new("RootCellStyleGroup")
|
||||
.attr("Self", self.fresh())
|
||||
|
||||
@@ -195,6 +195,8 @@ struct Importer<'r> {
|
||||
page_index: HashMap<String, usize>,
|
||||
item_ids: HashMap<String, ItemId>,
|
||||
assets: BTreeMap<AssetId, Arc<Asset>>,
|
||||
/// Content lookup for `assets`, so each placed graphic doesn't compare against every earlier one.
|
||||
asset_index: designcraft_doc::AssetIndex,
|
||||
/// Assets read from a link path at a page and crop, so later placements of that path, page
|
||||
/// and crop reuse them unread.
|
||||
linked_assets: HashMap<(String, u32, designcraft_doc::PdfCrop), AssetId>,
|
||||
@@ -277,6 +279,7 @@ impl<'r> Importer<'r> {
|
||||
page_index: HashMap::new(),
|
||||
item_ids: HashMap::new(),
|
||||
assets: BTreeMap::new(),
|
||||
asset_index: designcraft_doc::AssetIndex::new(),
|
||||
linked_assets: HashMap::new(),
|
||||
ctx: ItemCtx { threads: Vec::new() },
|
||||
sections: Vec::new(),
|
||||
|
||||
@@ -371,7 +371,7 @@ impl<'r> Importer<'r> {
|
||||
uri.as_deref().and_then(|p| p.rsplit(['/', '\\']).next()).filter(|n| !n.is_empty()).map(str::to_string).unwrap_or_else(|| "image".into());
|
||||
let fresh = AssetId(self.alloc());
|
||||
let link_path = uri.filter(|p| p.contains('/') || p.contains('\\'));
|
||||
let id = designcraft_doc::insert_asset(
|
||||
let id = self.asset_index.insert_asset(
|
||||
&mut self.assets,
|
||||
Asset {
|
||||
page,
|
||||
|
||||
@@ -86,6 +86,7 @@ impl<'r> Importer<'r> {
|
||||
let split = e.get("SplitDocument") == Some("true")
|
||||
|| e.elements()
|
||||
.any(|m| m.local() == "StyleExportTagMap" && m.get("ExportType") == Some("EPUB") && m.get("SplitDocument") == Some("true"));
|
||||
self.export_tag(e, &format!("p:{name}"));
|
||||
if split {
|
||||
self.styles.export_tags.entry(format!("p:{name}")).or_default().split = true;
|
||||
}
|
||||
@@ -99,6 +100,7 @@ impl<'r> Importer<'r> {
|
||||
Some(slot) => *slot = s,
|
||||
None => self.styles.character.push(s),
|
||||
}
|
||||
self.export_tag(e, &format!("c:{name}"));
|
||||
}
|
||||
// Keep built-ins as well: [Basic Table] is editable, and [No table style]
|
||||
// supplies the defaults used by named styles in ordinary IDML files.
|
||||
@@ -201,6 +203,19 @@ impl<'r> Importer<'r> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Export Tagging (tag and class) of style `e` from its EPUB export tag map, under `key`.
|
||||
fn export_tag(&mut self, e: &El, key: &str) {
|
||||
let Some(m) = e.elements().find(|m| m.local() == "StyleExportTagMap" && m.get("ExportType") == Some("EPUB")) else { return };
|
||||
let tag = m.get("ExportTag").unwrap_or("").to_string();
|
||||
let class = m.get("ExportClass").unwrap_or("").to_string();
|
||||
if tag.is_empty() && class.is_empty() {
|
||||
return;
|
||||
}
|
||||
let t = self.styles.export_tags.entry(key.to_string()).or_default();
|
||||
t.tag = tag;
|
||||
t.class = class;
|
||||
}
|
||||
|
||||
fn based_on(&self, e: &El, para: bool) -> Option<String> {
|
||||
let b = e.prop("BasedOn")?;
|
||||
let b = b.trim();
|
||||
|
||||
@@ -4894,6 +4894,41 @@ fn split_document_round_trips() {
|
||||
assert!(split(&back, "Part") && split(&back, "Chapter") && !split(&back, "Body"));
|
||||
}
|
||||
|
||||
/// Export Tagging (tag and class) of paragraph and character styles comes from the style's EPUB
|
||||
/// export tag map, not another export type's, and is written back.
|
||||
#[test]
|
||||
fn export_tags_round_trip() {
|
||||
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<idPkg:Styles xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0">
|
||||
<RootCharacterStyleGroup Self="rc">
|
||||
<CharacterStyle Self="CharacterStyle/$ID/[No character style]" Name="$ID/[No character style]"/>
|
||||
<CharacterStyle Self="CharacterStyle/Lead" Name="Lead">
|
||||
<StyleExportTagMap Self="cs1" ExportType="PDF" ExportTag="Artifact" ExportClass="" ExportAttributes=""/>
|
||||
<StyleExportTagMap Self="cs2" ExportType="EPUB" ExportTag="em" ExportClass="lead" ExportAttributes="" SplitDocument="false" EmitCss="true"/>
|
||||
</CharacterStyle>
|
||||
</RootCharacterStyleGroup>
|
||||
<RootParagraphStyleGroup Self="rp">
|
||||
<ParagraphStyle Self="ParagraphStyle/$ID/[No paragraph style]" Name="$ID/[No paragraph style]" PointSize="12"/>
|
||||
<ParagraphStyle Self="ParagraphStyle/Chapter" Name="Chapter" SplitDocument="false">
|
||||
<StyleExportTagMap Self="ps1" ExportType="PDF" ExportTag="H1" ExportClass="" ExportAttributes=""/>
|
||||
<StyleExportTagMap Self="ps2" ExportType="EPUB" ExportTag="h1" ExportClass="title" ExportAttributes="" SplitDocument="false" EmitCss="true"/>
|
||||
</ParagraphStyle>
|
||||
<ParagraphStyle Self="ParagraphStyle/Body" Name="Body"/>
|
||||
</RootParagraphStyleGroup>
|
||||
</idPkg:Styles>"#;
|
||||
let d = import_idml_with(&fixture_with_styles(xml), &|_| None).unwrap();
|
||||
let tag = |d: &designcraft_doc::Document, name: &str, character: bool| {
|
||||
d.styles.export_tag(name, character).map(|t| (t.tag.clone(), t.class.clone(), t.split))
|
||||
};
|
||||
let check = |d: &designcraft_doc::Document| {
|
||||
assert_eq!(tag(d, "Chapter", false), Some(("h1".into(), "title".into(), false)));
|
||||
assert_eq!(tag(d, "Lead", true), Some(("em".into(), "lead".into(), false)));
|
||||
assert_eq!(tag(d, "Body", false), None);
|
||||
};
|
||||
check(&d);
|
||||
check(&import_idml(&export_idml(&d)).unwrap());
|
||||
}
|
||||
|
||||
/// EPUB Export Options › Split Document: off, one style (built in or not) or the styles' export
|
||||
/// tags, read from the file's `EPubExportPreference` and written back.
|
||||
#[test]
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! Where spreads sit on the canvas (the pasteboard): stacked vertically, spines aligned at x = 0,
|
||||
//! parents shown on their own canvas when a parent spread is being edited.
|
||||
|
||||
use designcraft_doc::{Document, SpreadRef};
|
||||
use designcraft_doc::{Document, Selection, SpreadRef};
|
||||
use designcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
||||
pub struct SpreadSlot {
|
||||
@@ -100,6 +101,31 @@ impl CanvasLayout {
|
||||
(m * Point::new(d.x, d.y)) - (m * Point::ORIGIN)
|
||||
}
|
||||
|
||||
/// The Page tool selection on the canvas currently shown.
|
||||
pub fn selected_page(&self, doc: &Document, selection: &Selection) -> Option<(SpreadRef, usize)> {
|
||||
let (sr, pi) = if self.slots.first().is_some_and(|s| matches!(s.spread, SpreadRef::Parent(_))) {
|
||||
let (id, pi) = selection.parent_page?;
|
||||
(SpreadRef::Parent(doc.parent_index(id)?), pi)
|
||||
} else {
|
||||
let (si, pi) = doc.page_loc(selection.page?)?;
|
||||
(SpreadRef::Doc(si), pi)
|
||||
};
|
||||
self.slot(sr)?;
|
||||
doc.spread(sr)?.pages.get(pi)?;
|
||||
Some((sr, pi))
|
||||
}
|
||||
|
||||
/// A page on the visible canvas, including parent pages.
|
||||
pub fn page_at(&self, doc: &Document, p: Point) -> Option<(SpreadRef, usize)> {
|
||||
self.slots
|
||||
.iter()
|
||||
.find_map(|slot| doc.spread(slot.spread)?.pages.iter().position(|pg| pg.bounds().contains(slot.to_spread(p))).map(|pi| (slot.spread, pi)))
|
||||
}
|
||||
|
||||
pub fn spread_page_rect(&self, doc: &Document, sr: SpreadRef, pi: usize) -> Option<Rect> {
|
||||
Some(self.slot(sr)?.xf.transform_rect_bbox(doc.spread(sr)?.pages.get(pi)?.bounds()))
|
||||
}
|
||||
|
||||
/// Canvas rect of a document page.
|
||||
pub fn page_rect(&self, doc: &Document, abs: usize) -> Option<Rect> {
|
||||
let (si, pi) = doc.page_loc(abs)?;
|
||||
@@ -107,6 +133,24 @@ impl CanvasLayout {
|
||||
}
|
||||
}
|
||||
|
||||
/// The same resize contract for panels, handles and the exact-size dialog.
|
||||
pub fn page_size_command(doc: &Document, sr: SpreadRef, page: usize, mut params: Value) -> Option<(&'static str, Value)> {
|
||||
let sp = doc.spread(sr)?;
|
||||
sp.pages.get(page)?;
|
||||
let command = match sr {
|
||||
SpreadRef::Doc(si) => {
|
||||
params["pages"] = json!([doc.first_page_of_spread(si) + page + 1]);
|
||||
"layout.pageSize"
|
||||
}
|
||||
SpreadRef::Parent(_) => {
|
||||
params["parent"] = json!(sp.id.0);
|
||||
params["page"] = json!(page);
|
||||
"layout.parentPageSize"
|
||||
}
|
||||
};
|
||||
Some((command, params))
|
||||
}
|
||||
|
||||
fn dist_y(r: Rect, y: f64) -> f64 {
|
||||
if y < r.y0 - SPREAD_GAP / 2.0 {
|
||||
r.y0 - y
|
||||
|
||||
+60
-23
@@ -305,7 +305,9 @@ fn liquid_control_points(cx: &ToolContext, r: designcraft_geom::Rect) -> [(Liqui
|
||||
/// A page handle being dragged.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct PageDrag {
|
||||
/// Absolute page index.
|
||||
spread: designcraft_doc::SpreadRef,
|
||||
parent: Option<designcraft_doc::SpreadId>,
|
||||
/// Page index within the spread.
|
||||
page: usize,
|
||||
/// Reference-point index of the handle (0–8, row-major; 4 is the centre and has none).
|
||||
handle: usize,
|
||||
@@ -353,6 +355,9 @@ impl PageTool {
|
||||
fn liquid_target(
|
||||
cx: &ToolContext,
|
||||
) -> Option<(designcraft_doc::ItemId, designcraft_doc::ObjectLiquid, designcraft_geom::Rect, designcraft_geom::Rect)> {
|
||||
if cx.layout.slots.first().is_some_and(|s| matches!(s.spread, designcraft_doc::SpreadRef::Parent(_))) {
|
||||
return None;
|
||||
}
|
||||
let page = cx.selection.page?;
|
||||
let [id] = cx.selection.items.as_slice() else { return None };
|
||||
let (si, _) = cx.doc.page_loc(page)?;
|
||||
@@ -372,17 +377,16 @@ impl PageTool {
|
||||
}
|
||||
|
||||
/// The selected page's handle under `p`.
|
||||
fn handle_at(&self, cx: &ToolContext, p: Point) -> Option<(usize, usize)> {
|
||||
let page = cx.selection.page?;
|
||||
let r = cx.layout.page_rect(cx.doc, page)?;
|
||||
page_handles(r).find(|(_, h)| (h.x - p.x).abs() <= cx.tol(5.0) && (h.y - p.y).abs() <= cx.tol(5.0)).map(|(i, _)| (page, i))
|
||||
fn handle_at(&self, cx: &ToolContext, p: Point) -> Option<(designcraft_doc::SpreadRef, usize, usize)> {
|
||||
let (sr, page) = cx.layout.selected_page(cx.doc, cx.selection)?;
|
||||
let r = cx.layout.spread_page_rect(cx.doc, sr, page)?;
|
||||
page_handles(r).find(|(_, h)| (h.x - p.x).abs() <= cx.tol(5.0) && (h.y - p.y).abs() <= cx.tol(5.0)).map(|(i, _)| (sr, page, i))
|
||||
}
|
||||
|
||||
/// The new page size for the drag at `p`: the handle's edges follow the pointer (Shift: in
|
||||
/// proportion).
|
||||
fn size_at(cx: &ToolContext, d: &PageDrag, p: Point, shift: bool) -> (f64, f64) {
|
||||
let si = cx.doc.page_loc(d.page).map_or(0, |l| l.0);
|
||||
let v = cx.layout.delta_to_spread(designcraft_doc::SpreadRef::Doc(si), p - d.start);
|
||||
let v = cx.layout.delta_to_spread(d.spread, p - d.start);
|
||||
let (col, row) = (d.handle % 3, d.handle / 3);
|
||||
let dx = match col {
|
||||
0 => -v.x,
|
||||
@@ -417,10 +421,21 @@ impl Tool for PageTool {
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
match ev.kind {
|
||||
PointerKind::Down => {
|
||||
if let Some((page, handle)) = self.handle_at(cx, ev.pos) {
|
||||
let Some((si, pi)) = cx.doc.page_loc(page) else { return vec![] };
|
||||
let pg = &cx.doc.spreads[si].pages[pi];
|
||||
self.drag = Some(PageDrag { page, handle, start: ev.pos, cur: ev.pos, size: (pg.width, pg.height), alt: ev.mods.alt });
|
||||
if let Some((spread, page, handle)) = self.handle_at(cx, ev.pos) {
|
||||
let Some(pg) = cx.doc.spread(spread).and_then(|sp| sp.pages.get(page)) else { return vec![] };
|
||||
self.drag = Some(PageDrag {
|
||||
spread,
|
||||
parent: match spread {
|
||||
designcraft_doc::SpreadRef::Parent(si) => cx.doc.parents.get(si).map(|sp| sp.id),
|
||||
_ => None,
|
||||
},
|
||||
page,
|
||||
handle,
|
||||
start: ev.pos,
|
||||
cur: ev.pos,
|
||||
size: (pg.width, pg.height),
|
||||
alt: ev.mods.alt,
|
||||
});
|
||||
return vec![Action::Begin("Resize Page".into())];
|
||||
}
|
||||
// An object-based page's object: a click on a pin or lock toggles it.
|
||||
@@ -429,7 +444,18 @@ impl Tool for PageTool {
|
||||
}
|
||||
// A click selects the page under the pointer (none on the pasteboard), and on an
|
||||
// object-based page the object clicked too.
|
||||
let page = (0..cx.doc.page_count()).find(|p| cx.layout.page_rect(cx.doc, *p).is_some_and(|r| r.contains(ev.pos)));
|
||||
let target = cx.layout.page_at(cx.doc, ev.pos);
|
||||
if let Some((designcraft_doc::SpreadRef::Parent(si), page)) = target {
|
||||
let Some(sp) = cx.doc.parents.get(si) else { return vec![] };
|
||||
return vec![Action::Exec("layout.selectParentPage".into(), json!({"parent": sp.id.0, "page": page}))];
|
||||
}
|
||||
if cx.layout.slots.first().is_some_and(|s| matches!(s.spread, designcraft_doc::SpreadRef::Parent(_))) {
|
||||
return vec![Action::Exec("layout.selectParentPage".into(), json!({"parent": null}))];
|
||||
}
|
||||
let page = target.and_then(|(sr, pi)| match sr {
|
||||
designcraft_doc::SpreadRef::Doc(si) => Some(cx.doc.first_page_of_spread(si) + pi),
|
||||
_ => None,
|
||||
});
|
||||
let object = page
|
||||
.filter(|p| object_based(cx.doc, *p))
|
||||
.and_then(|p| cx.hit(ev.pos).map(|(_, id)| id).filter(|id| cx.doc.page_of_item(*id) == Some(p)));
|
||||
@@ -443,9 +469,21 @@ impl Tool for PageTool {
|
||||
let Some(d) = self.drag.as_mut() else { return vec![] };
|
||||
d.cur = ev.pos;
|
||||
d.alt |= ev.mods.alt;
|
||||
let d = *d;
|
||||
let mut d = *d;
|
||||
if let Some(id) = d.parent {
|
||||
let Some(si) = cx.doc.parent_index(id) else {
|
||||
self.drag = None;
|
||||
return vec![Action::Cancel];
|
||||
};
|
||||
d.spread = designcraft_doc::SpreadRef::Parent(si);
|
||||
}
|
||||
let (w, h) = Self::size_at(cx, &d, ev.pos, ev.mods.shift);
|
||||
vec![Action::Preview("layout.pageSize".into(), json!({"pages": [d.page + 1], "width": w, "height": h, "anchor": 8 - d.handle}))]
|
||||
let Some((cmd, params)) =
|
||||
crate::layout::page_size_command(cx.doc, d.spread, d.page, json!({"width": w, "height": h, "anchor": 8 - d.handle}))
|
||||
else {
|
||||
return vec![];
|
||||
};
|
||||
vec![Action::Preview(cmd.into(), params)]
|
||||
}
|
||||
PointerKind::Up => match self.drag.take() {
|
||||
// Alt-drag resizes; a plain drag previews the liquid rule and springs back.
|
||||
@@ -455,12 +493,12 @@ impl Tool for PageTool {
|
||||
},
|
||||
PointerKind::DoubleClick => {
|
||||
// Exact sizes: the Page Size dialog for the page.
|
||||
let Some(page) = (0..cx.doc.page_count()).find(|p| cx.layout.page_rect(cx.doc, *p).is_some_and(|r| r.contains(ev.pos))) else {
|
||||
let Some((sr, pi)) = cx.layout.page_at(cx.doc, ev.pos) else { return vec![] };
|
||||
let Some(pg) = cx.doc.spread(sr).and_then(|sp| sp.pages.get(pi)) else { return vec![] };
|
||||
let Some((cmd, params)) = crate::layout::page_size_command(cx.doc, sr, pi, json!({"width": pg.width, "height": pg.height})) else {
|
||||
return vec![];
|
||||
};
|
||||
let Some((si, pi)) = cx.doc.page_loc(page) else { return vec![] };
|
||||
let pg = &cx.doc.spreads[si].pages[pi];
|
||||
vec![Action::Dialog("cmd:layout.pageSize".into(), json!({"pages": [page + 1], "width": pg.width, "height": pg.height}))]
|
||||
vec![Action::Dialog(format!("cmd:{cmd}"), params)]
|
||||
}
|
||||
PointerKind::Move => {
|
||||
self.hover = Self::control_at(cx, ev.pos).map(|(_, _, c)| (c, ev.pos));
|
||||
@@ -469,10 +507,9 @@ impl Tool for PageTool {
|
||||
}
|
||||
}
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let Some(page) = cx.selection.page else { return vec![] };
|
||||
let Some(r) = cx.layout.page_rect(cx.doc, page) else { return vec![] };
|
||||
let Some((si, pi)) = cx.doc.page_loc(page) else { return vec![] };
|
||||
let pg = &cx.doc.spreads[si].pages[pi];
|
||||
let Some((sr, pi)) = cx.layout.selected_page(cx.doc, cx.selection) else { return vec![] };
|
||||
let Some(r) = cx.layout.spread_page_rect(cx.doc, sr, pi) else { return vec![] };
|
||||
let Some(pg) = cx.doc.spread(sr).and_then(|sp| sp.pages.get(pi)) else { return vec![] };
|
||||
let mut v = vec![Overlay::PageHandles(r), Overlay::Tag { p: Point::new(r.x0, r.y0 - cx.tol(6.0)), text: rule_label(pg.liquid).into() }];
|
||||
if let Some((_, l, object, page)) = Self::liquid_target(cx) {
|
||||
v.push(Overlay::LiquidPins {
|
||||
@@ -501,7 +538,7 @@ impl Tool for PageTool {
|
||||
if self.drag.is_none() && Self::control_at(cx, p).is_some() {
|
||||
return Cursor::Hand;
|
||||
}
|
||||
let handle = self.drag.map(|d| d.handle).or_else(|| self.handle_at(cx, p).map(|(_, h)| h));
|
||||
let handle = self.drag.map(|d| d.handle).or_else(|| self.handle_at(cx, p).map(|(_, _, h)| h));
|
||||
match handle {
|
||||
Some(1 | 7) => Cursor::ResizeV,
|
||||
Some(3 | 5) => Cursor::ResizeH,
|
||||
|
||||
@@ -2179,6 +2179,41 @@ fn loaded_text_cursor_threads_into_a_frame_inside_a_group() {
|
||||
assert_eq!(t2.cursor(&cx2, q, Mods::default()), Cursor::NotAllowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_resize_page_tool_targets_the_visible_parent() {
|
||||
let d = Document::new(&NewDocument { width: 400.0, height: 600.0, pages: 3, facing_pages: true, ..Default::default() });
|
||||
let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, true));
|
||||
let cx = ctx(&d, &s, &c, &l);
|
||||
let p = l.to_canvas(SpreadRef::Parent(0), d.parents[0].pages[0].bounds().center());
|
||||
let mut t = create("page");
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::DoubleClick, p.x, p.y));
|
||||
assert_eq!(
|
||||
a,
|
||||
vec![Action::Dialog(
|
||||
"cmd:layout.parentPageSize".into(),
|
||||
serde_json::json!({"parent": d.parents[0].id.0, "page": 0, "width": 400.0, "height": 600.0})
|
||||
)],
|
||||
"double click must target the visible parent instead of document page zero"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_resize_selection_tracks_ids_and_never_crosses_canvas_modes() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let second = d.add_parent("B", "Parent", 1, 12.0, designcraft_doc::Margins::uniform(36.0));
|
||||
let selection = Selection { page: Some(0), parent_page: Some((second, 1)), ..Default::default() };
|
||||
let l = CanvasLayout::new(&d, true);
|
||||
assert_eq!(l.selected_page(&d, &selection), Some((SpreadRef::Parent(1), 1)));
|
||||
d.parents.swap(0, 1);
|
||||
let l = CanvasLayout::new(&d, true);
|
||||
assert_eq!(l.selected_page(&d, &selection), Some((SpreadRef::Parent(0), 1)));
|
||||
assert_eq!(CanvasLayout::new(&d, false).selected_page(&d, &selection), Some((SpreadRef::Doc(0), 0)));
|
||||
d.parents.remove(0);
|
||||
assert_eq!(CanvasLayout::new(&d, true).selected_page(&d, &selection), None);
|
||||
let selection = Selection { page: Some(0), ..Default::default() };
|
||||
assert_eq!(CanvasLayout::new(&d, true).selected_page(&d, &selection), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_selection_click_on_one_of_several_selected_frames_selects_only_it() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
|
||||
@@ -435,20 +435,28 @@ pub(crate) mod browser_import_tests {
|
||||
("Brochure.designcraft", "Brochure"),
|
||||
("Brochure.designcraft.designcraft", "Brochure.designcraft"),
|
||||
("Brochure.v2.designcraft", "Brochure.v2"),
|
||||
("Brochure.indd", "Brochure.indd"),
|
||||
("Brochure.INDT", "Brochure.INDT"),
|
||||
("日本.DESIGNCRAFT", "日本"),
|
||||
(".designcraft", ".designcraft"),
|
||||
("Brochure", "Brochure"),
|
||||
] {
|
||||
let before = app.session.documents().len();
|
||||
let request = app.import_request(purpose);
|
||||
deliver(&mut app, request, name, native.clone());
|
||||
assert_eq!(app.session.documents().len(), before + 1, "{purpose}: {name}");
|
||||
assert_eq!(app.session.doc().unwrap().doc.title, title, "{purpose}: {name}");
|
||||
}
|
||||
}
|
||||
let idml = designcraft_idml::export_idml(&app.session.doc().unwrap().doc);
|
||||
for purpose in ["open", "drop"] {
|
||||
let request = app.import_request(purpose);
|
||||
deliver(&mut app, request, "Brochure.idml", idml.clone());
|
||||
assert_eq!(app.session.doc().unwrap().doc.title, "Brochure", "{purpose}: IDML");
|
||||
for name in ["Brochure.idml", "Brochure.indd", "Brochure.INDT"] {
|
||||
let before = app.session.documents().len();
|
||||
let request = app.import_request(purpose);
|
||||
deliver(&mut app, request, name, idml.clone());
|
||||
assert_eq!(app.session.documents().len(), before + 1, "{purpose}: {name}");
|
||||
assert_eq!(app.session.doc().unwrap().doc.title, "Brochure", "{purpose}: {name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
fn image() -> Vec<u8> {
|
||||
|
||||
@@ -62,7 +62,7 @@ pub(crate) use view::page_in_view;
|
||||
use view::present_fit;
|
||||
pub use view::{
|
||||
PageBox, apply_view_request, can_go_through_history, current_page, current_parent_page, current_slot, current_spread, current_spread_page,
|
||||
drop_point, fit, go_through_history, go_to_page, page_box, page_box_go, set_zoom, show_spread, step_spread, view_centre, zoom_at,
|
||||
drop_point, fit, go_through_history, go_to_page, page_box, page_box_go, page_target, set_zoom, show_spread, step_spread, view_centre, zoom_at,
|
||||
};
|
||||
|
||||
pub const RULER: f32 = 15.0;
|
||||
|
||||
@@ -113,7 +113,7 @@ pub fn current_page(app: &DesignApp) -> Option<usize> {
|
||||
let layout = CanvasLayout::new(&st.doc, st.editing_parents);
|
||||
let i = current_slot(app, &layout)?;
|
||||
let slot = layout.slots.get(i)?;
|
||||
let SpreadRef::Doc(si) = slot.spread else { return Some(0) };
|
||||
let SpreadRef::Doc(si) = slot.spread else { return None };
|
||||
let v = app.view()?;
|
||||
let r = app.canvas_rect?;
|
||||
let c = slot.to_spread(Point::new(v.origin.x + r.width() as f64 / 2.0 / v.zoom, slot.bounds.center().y));
|
||||
@@ -121,6 +121,16 @@ pub fn current_page(app: &DesignApp) -> Option<usize> {
|
||||
Some(st.doc.first_page_of_spread(si) + pi)
|
||||
}
|
||||
|
||||
/// The Page tool selection, or the page in view, without crossing canvas modes.
|
||||
pub fn page_target(app: &DesignApp) -> Option<(SpreadRef, usize)> {
|
||||
let st = app.session.active()?;
|
||||
let selected = CanvasLayout::new(&st.doc, st.editing_parents).selected_page(&st.doc, &st.selection);
|
||||
if st.editing_parents && st.selection.parent_page.is_some() {
|
||||
return selected;
|
||||
}
|
||||
selected.or_else(|| current_spread_page(app))
|
||||
}
|
||||
|
||||
/// The spread (document or parent) under the view centre and the page index within it: where
|
||||
/// commands that act "on the page I am working on" should go.
|
||||
pub fn current_spread_page(app: &DesignApp) -> Option<(SpreadRef, usize)> {
|
||||
@@ -135,22 +145,10 @@ pub fn current_spread_page(app: &DesignApp) -> Option<(SpreadRef, usize)> {
|
||||
Some((slot.spread, pg))
|
||||
}
|
||||
|
||||
/// While parent spreads are being edited: (parent spread index, page index within it) under the
|
||||
/// view centre. `None` when document pages are shown.
|
||||
/// The selected parent page, or the parent page under the view centre.
|
||||
pub fn current_parent_page(app: &DesignApp) -> Option<(usize, usize)> {
|
||||
let st = app.session.active()?;
|
||||
if !st.editing_parents {
|
||||
return None;
|
||||
}
|
||||
let layout = CanvasLayout::new(&st.doc, true);
|
||||
let i = current_slot(app, &layout)?;
|
||||
let slot = layout.slots.get(i)?;
|
||||
let SpreadRef::Parent(pi) = slot.spread else { return None };
|
||||
let v = app.view()?;
|
||||
let r = app.canvas_rect?;
|
||||
let c = slot.to_spread(Point::new(v.origin.x + r.width() as f64 / 2.0 / v.zoom, slot.bounds.center().y));
|
||||
let qi = st.doc.parents.get(pi)?.page_at_x(c.x).unwrap_or(0);
|
||||
Some((pi, qi))
|
||||
let (SpreadRef::Parent(si), pi) = page_target(app)? else { return None };
|
||||
Some((si, pi))
|
||||
}
|
||||
|
||||
/// The page box's entries (the status bar's, and Properties › Page), as InDesign's: the
|
||||
|
||||
@@ -34,25 +34,34 @@ pub fn control_bar(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
|
||||
/// Page-size preset menu and the portrait / landscape toggles of the Control panel.
|
||||
fn page_preset_and_orientation(app: &mut DesignApp, ui: &mut egui::Ui, lang: &str, size: (f64, f64), n: usize, anchor: u8) {
|
||||
fn page_preset_and_orientation(
|
||||
app: &mut DesignApp,
|
||||
ui: &mut egui::Ui,
|
||||
lang: &str,
|
||||
size: (f64, f64),
|
||||
anchor: u8,
|
||||
resize: impl Fn(&mut DesignApp, serde_json::Value),
|
||||
) {
|
||||
let landscape = size.0 > size.1;
|
||||
ui.vertical(|ui| {
|
||||
let cur = panels::properties::preset_name(size.0, size.1);
|
||||
craft_ui::ComboBox::from_id_salt("ctl_page_preset").selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, cur))).width(130.0).show_ui(
|
||||
ui,
|
||||
|ui| {
|
||||
craft_ui::ComboBox::from_id_salt("ctl_page_preset")
|
||||
.selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, cur)))
|
||||
.width(130.0)
|
||||
.show_ui(ui, |ui| {
|
||||
for (name, w, h) in panels::properties::PAGE_PRESETS {
|
||||
if ui.selectable_label(cur == *name, crate::rtl::widget(ui, crate::i18n::tr(lang, name))).clicked() {
|
||||
let (w, h) = if landscape { (h, w) } else { (w, h) };
|
||||
let _ = app.run("layout.pageSize", json!({"pages": [n], "width": w, "height": h, "anchor": anchor}));
|
||||
resize(app, json!({"width": w, "height": h, "anchor": anchor}));
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
})
|
||||
.response
|
||||
.accessible_name(crate::i18n::tr(lang, "Page Size"));
|
||||
ui.horizontal(|ui| {
|
||||
for (icon, tip, want) in [("orient-portrait", "Portrait", false), ("orient-landscape", "Landscape", true)] {
|
||||
if crate::widgets::icon_toggle(ui, icon, landscape == want, crate::i18n::tr(lang, tip)).clicked() && landscape != want {
|
||||
let _ = app.run("layout.pageSize", json!({"pages": [n], "width": size.1, "height": size.0, "anchor": anchor}));
|
||||
resize(app, json!({"width": size.1, "height": size.0, "anchor": anchor}));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -62,16 +71,24 @@ fn page_preset_and_orientation(app: &mut DesignApp, ui: &mut egui::Ui, lang: &st
|
||||
/// The Control panel with the Page tool, as InDesign's: reference point, X/Y, W/H, page-size
|
||||
/// preset, orientation, Liquid Page Rule and Show Parent Page Overlay, for
|
||||
/// the page the Page tool selected (else the page in view). W/H keep the reference point in place.
|
||||
fn control_page(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
pub(super) fn control_page(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
let lang = app.ui.language.clone();
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let units = st.doc.settings.horizontal_units;
|
||||
let Some(page) = st.selection.page.or_else(|| crate::canvas::current_page(app)) else { return };
|
||||
let Some((spread, page)) = crate::canvas::page_target(app) else { return };
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let d = st.doc.clone();
|
||||
let Some((si, pi)) = d.page_loc(page) else { return };
|
||||
let pg = d.spreads[si].pages[pi].clone();
|
||||
let n = page + 1;
|
||||
let Some(sp) = d.spread(spread) else { return };
|
||||
let Some(pg) = sp.pages.get(page).cloned() else { return };
|
||||
let n = match spread {
|
||||
designcraft_doc::SpreadRef::Doc(si) => Some(d.first_page_of_spread(si) + page + 1),
|
||||
_ => None,
|
||||
};
|
||||
let resize = |app: &mut DesignApp, params| {
|
||||
if let Some((cmd, params)) = designcraft_tools::layout::page_size_command(&d, spread, page, params) {
|
||||
let _ = app.run(cmd, params);
|
||||
}
|
||||
};
|
||||
// Reference point: W/H edits keep it in place.
|
||||
let (r, _) = ui.allocate_exact_size(vec2(30.0, 44.0), Sense::hover());
|
||||
if let Some(p) = panels::properties::ref_point_proxy(ui, egui::Rect::from_center_size(r.center(), vec2(27.0, 27.0)), app.ui.ref_point) {
|
||||
@@ -80,46 +97,53 @@ fn control_page(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
let anchor = app.ui.ref_point.min(8);
|
||||
let (fx, fy) = ((anchor % 3) as f64 / 2.0, (anchor / 3) as f64 / 2.0);
|
||||
// The reference point's position: from the spine across, from the top down.
|
||||
let (x, y) = (pg.x - d.spreads[si].spine_x() + fx * pg.width, fy * pg.height);
|
||||
let (x, y) = (pg.x - sp.spine_x() + fx * pg.width, fy * pg.height);
|
||||
egui::Grid::new("ctl_page_xywh").num_columns(4).spacing(vec2(4.0, 4.0)).show(ui, |ui| {
|
||||
caption(ui, crate::i18n::tr(&lang, "X:"));
|
||||
// Pages don't move within their spread yet.
|
||||
ui.add_enabled_ui(false, |ui| measure(ui, "cpx", Some(x), units, 64.0));
|
||||
caption(ui, crate::i18n::tr(&lang, "W:"));
|
||||
if let Some(v) = measure(ui, "cpw", Some(pg.width), units, 64.0) {
|
||||
let _ = app.run("layout.pageSize", json!({"pages": [n], "width": v, "anchor": anchor}));
|
||||
resize(app, json!({"width": v, "anchor": anchor}));
|
||||
}
|
||||
ui.end_row();
|
||||
caption(ui, crate::i18n::tr(&lang, "Y:"));
|
||||
ui.add_enabled_ui(false, |ui| measure(ui, "cpy", Some(y), units, 64.0));
|
||||
caption(ui, crate::i18n::tr(&lang, "H:"));
|
||||
if let Some(v) = measure(ui, "cph", Some(pg.height), units, 64.0) {
|
||||
let _ = app.run("layout.pageSize", json!({"pages": [n], "height": v, "anchor": anchor}));
|
||||
resize(app, json!({"height": v, "anchor": anchor}));
|
||||
}
|
||||
ui.end_row();
|
||||
});
|
||||
vsep(ui);
|
||||
// Page-size preset and orientation.
|
||||
page_preset_and_orientation(app, ui, &lang, (pg.width, pg.height), n, anchor);
|
||||
page_preset_and_orientation(app, ui, &lang, (pg.width, pg.height), anchor, resize);
|
||||
vsep(ui);
|
||||
ui.vertical(|ui| {
|
||||
ui.add_enabled_ui(n.is_some(), |ui| {
|
||||
caption(ui, crate::i18n::tr(&lang, "Liquid Page Rule:"));
|
||||
let label = panels::interactive::liquid_rule_label(pg.liquid);
|
||||
craft_ui::ComboBox::from_id_salt("ctl_page_rule").selected_text(crate::rtl::widget(ui, crate::i18n::tr(&lang, label))).width(150.0).show_ui(
|
||||
ui,
|
||||
|ui| {
|
||||
craft_ui::ComboBox::from_id_salt("ctl_page_rule")
|
||||
.selected_text(crate::rtl::widget(ui, crate::i18n::tr(&lang, label)))
|
||||
.width(150.0)
|
||||
.show_ui(ui, |ui| {
|
||||
for (r, l) in panels::interactive::LIQUID_RULES {
|
||||
if ui.selectable_label(pg.liquid == r, crate::rtl::widget(ui, crate::i18n::tr(&lang, l))).clicked() && pg.liquid != r {
|
||||
if ui.selectable_label(pg.liquid == r, crate::rtl::widget(ui, crate::i18n::tr(&lang, l))).clicked()
|
||||
&& pg.liquid != r
|
||||
&& let Some(n) = n
|
||||
{
|
||||
let _ = app.run("liquid.pageRule", json!({"rule": r, "pages": [n]}));
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
})
|
||||
.response
|
||||
.accessible_name(crate::i18n::tr(&lang, "Liquid Page Rule:"));
|
||||
});
|
||||
vsep(ui);
|
||||
ui.vertical(|ui| {
|
||||
ui.add_enabled_ui(n.is_some(), |ui| {
|
||||
let mut overlay = pg.show_parent_overlay;
|
||||
if ui.checkbox(&mut overlay, crate::rtl::widget(ui, crate::i18n::tr(&lang, "Show Parent Page Overlay"))).changed() {
|
||||
if ui.checkbox(&mut overlay, crate::rtl::widget(ui, crate::i18n::tr(&lang, "Show Parent Page Overlay"))).changed()
|
||||
&& let Some(n) = n
|
||||
{
|
||||
let _ = app.run("layout.pageOptions", json!({"pages": [n], "showParentOverlay": overlay}));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -515,7 +515,7 @@ fn the_page_tool_has_its_own_control_panel() {
|
||||
app.run("file.new", serde_json::json!({"width": 252, "height": 144, "facingPages": false})).unwrap();
|
||||
app.session.set_tool("page");
|
||||
let mut h = test_window::open(app, vec2(1800.0, 600.0));
|
||||
let _ = h.get_by_label("Liquid Page Rule:");
|
||||
let _ = h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Liquid Page Rule:");
|
||||
// Pages don't move within their spread, so there is no "Objects Move with Page" option, and
|
||||
// no "Controlled by Parent" rule until parent pages have rules of their own.
|
||||
assert!(h.query_by_label("Objects Move with Page").is_none());
|
||||
@@ -911,3 +911,144 @@ fn caption_buttons_fit_the_minimum_window_width() {
|
||||
assert!(!h.click(p).is_empty(), "{id} remains clickable");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_resize_properties_keep_size_and_inline_parent_margins() {
|
||||
parent_resize_panel(true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_resize_control_changes_the_parent_not_document_page_zero() {
|
||||
parent_resize_panel(false);
|
||||
}
|
||||
|
||||
fn parent_resize_panel(properties: bool) {
|
||||
use egui_kittest::Harness;
|
||||
{
|
||||
let mut app = app();
|
||||
app.run("file.new", serde_json::json!({"width": 400, "height": 600, "pages": 3, "facingPages": true})).unwrap();
|
||||
app.run("layout.parents.new", serde_json::json!({"prefix": "B"})).unwrap();
|
||||
app.run("layout.pages.applyParent", serde_json::json!({"pages": [0], "parent": "A"})).unwrap();
|
||||
if properties {
|
||||
app.run("layout.marginsAndColumns", json!({"parent": 1, "pages": [0], "margins": 24})).unwrap();
|
||||
app.run(
|
||||
"layout.marginsAndColumns",
|
||||
json!({"parent": 1, "pages": [1], "margins": {"top": 36, "bottom": 48, "inside": 60, "outside": 72}}),
|
||||
)
|
||||
.unwrap();
|
||||
app.run("layout.pages.applyParent", json!({"pages": [0, 1, 2], "parent": "B"})).unwrap();
|
||||
app.run("layout.marginsAndColumns", json!({"pages": [0], "margins": 90})).unwrap();
|
||||
}
|
||||
app.run("layout.parents.edit", serde_json::json!({"on": true, "parent": "A"})).unwrap();
|
||||
app.session.set_tool("page");
|
||||
app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(800.0, 600.0)));
|
||||
*app.view_mut().unwrap() = crate::View { zoom: 1.0, origin: designcraft_geom::Point::new(-600.0, 0.0), fitted: true, rotation: 0 };
|
||||
let before = app.session.doc().unwrap().doc.clone();
|
||||
let mut h = Harness::builder().with_size(vec2(1600.0, 900.0)).build_ui_state(
|
||||
move |ui, app: &mut DesignApp| {
|
||||
let id = egui::Id::unique("parent_resize_fonts");
|
||||
if ui.data(|d| d.get_temp::<bool>(id)).is_none() {
|
||||
crate::theme::install_fonts(ui.ctx(), "");
|
||||
ui.data_mut(|d| d.insert_temp(id, true));
|
||||
return;
|
||||
}
|
||||
if properties {
|
||||
panels::properties::show(app, ui);
|
||||
} else {
|
||||
super::control_bar::control_page(app, ui);
|
||||
}
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
edit_parent_resize_field(&mut h, "33p4", "500 pt");
|
||||
let d = &h.state().session.doc().unwrap().doc;
|
||||
assert_eq!(d.parents[0].pages[0].width, 500.0, "the parent page in view must change");
|
||||
assert_eq!(d.spreads, before.spreads);
|
||||
assert_eq!(d.parents[1], before.parents[1]);
|
||||
let b = before.parents[1].id.0;
|
||||
h.state_mut().run("layout.selectParentPage", json!({"parent": b, "page": 1})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert_eq!(crate::canvas::current_parent_page(h.state()), Some((1, 1)));
|
||||
if properties {
|
||||
h.state_mut().session.set_tool("selection");
|
||||
}
|
||||
h.run_steps(3);
|
||||
edit_parent_resize_field(&mut h, "50p0", "700 pt");
|
||||
edit_parent_resize_field(&mut h, "33p4", "560 pt");
|
||||
let d = &h.state().session.doc().unwrap().doc;
|
||||
assert_eq!((d.parents[1].pages[1].width, d.parents[1].pages[1].height), (560.0, 700.0));
|
||||
assert_eq!(d.parents[1].pages[0].width, 400.0);
|
||||
assert_eq!(d.parents[0].pages[0].width, 500.0);
|
||||
assert_eq!(d.spreads, before.spreads);
|
||||
let parents = d.parents.clone();
|
||||
if properties {
|
||||
let saved = d.clone();
|
||||
check_parent_margin_edits(&mut h);
|
||||
h.get_by_label("Back").click();
|
||||
h.run_steps(3);
|
||||
assert!(!h.state().session.doc().unwrap().editing_parents, "Back returns to document properties");
|
||||
assert_eq!(h.state().session.tool_id(), "selection");
|
||||
assert!(h.query_by_label("Back").is_none(), "the next frame must leave the page properties screen");
|
||||
assert!(std::sync::Arc::ptr_eq(&saved, &h.state().session.doc().unwrap().doc));
|
||||
}
|
||||
h.state_mut().run("layout.parents.edit", json!({"on": false})).unwrap();
|
||||
h.state_mut().session.set_tool("page");
|
||||
h.state_mut().run("layout.selectPage", json!({"page": 1})).unwrap();
|
||||
h.run_steps(3);
|
||||
edit_parent_resize_field(&mut h, "33p4", "530 pt");
|
||||
let d = &h.state().session.doc().unwrap().doc;
|
||||
assert_eq!(d.spreads[0].pages[0].width, 530.0);
|
||||
assert_eq!(d.parents, parents);
|
||||
if properties {
|
||||
let saved = d.clone();
|
||||
h.get_by_label("Back").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.tool_id(), "selection");
|
||||
assert!(h.query_by_label("Back").is_none());
|
||||
assert_eq!(h.state().session.doc().unwrap().selection.page, None);
|
||||
assert!(std::sync::Arc::ptr_eq(&saved, &h.state().session.doc().unwrap().doc));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check_parent_margin_edits(h: &mut egui_kittest::Harness<'_, DesignApp>) {
|
||||
let saved = h.state().session.doc().unwrap().doc.clone();
|
||||
assert_eq!(h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Top").value().as_deref(), Some("3p0"));
|
||||
edit_parent_resize_field(h, "3p0", "72 pt");
|
||||
let edited = &h.state().session.doc().unwrap().doc;
|
||||
assert_eq!(edited.parents[1].pages[1].margins, designcraft_doc::Margins::uniform(72.0));
|
||||
assert_eq!(edited.parents[1].pages[0], saved.parents[1].pages[0]);
|
||||
assert_eq!(edited.parents[0], saved.parents[0]);
|
||||
assert_eq!(edited.page(0), saved.page(0), "local document margins stay unchanged");
|
||||
assert_eq!(edited.page(1), saved.page(1), "the other parent page's inheritor stays unchanged");
|
||||
assert_eq!(edited.page(2).unwrap().margins, designcraft_doc::Margins::uniform(72.0));
|
||||
assert_eq!(edited.settings, saved.settings);
|
||||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.doc().unwrap().doc, saved);
|
||||
h.get_by_label("Make all settings the same").click();
|
||||
h.run_steps(3);
|
||||
edit_parent_resize_field(h, "3p0", "84 pt");
|
||||
let edited = &h.state().session.doc().unwrap().doc;
|
||||
let margins = designcraft_doc::Margins { top: 84.0, bottom: 48.0, inside: 60.0, outside: 72.0 };
|
||||
assert_eq!(edited.parents[1].pages[1].margins, margins);
|
||||
assert_eq!(edited.parents[1].pages[0], saved.parents[1].pages[0]);
|
||||
assert_eq!(edited.parents[0], saved.parents[0]);
|
||||
assert_eq!(edited.page(0), saved.page(0));
|
||||
assert_eq!(edited.page(1), saved.page(1));
|
||||
assert_eq!(edited.page(2).unwrap().margins, margins);
|
||||
assert_eq!(edited.settings, saved.settings);
|
||||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.doc().unwrap().doc, saved);
|
||||
}
|
||||
|
||||
fn edit_parent_resize_field(h: &mut egui_kittest::Harness<'_, DesignApp>, value: &str, input: &str) {
|
||||
h.query_all_by(|n| n.role() == egui::accesskit::Role::TextInput && n.value().as_deref() == Some(value)).next().expect("page size field").click();
|
||||
h.run_steps(2);
|
||||
h.event(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND });
|
||||
h.event(egui::Event::Text(input.into()));
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(3);
|
||||
}
|
||||
@@ -419,7 +419,12 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest
|
||||
}
|
||||
"ui.render" => {
|
||||
let Some(st) = app.session.active() else { return err("no document") };
|
||||
let page = match page_index(p, crate::canvas::current_page(app).unwrap_or(0), st.doc.page_count()) {
|
||||
let current = match crate::canvas::current_page(app) {
|
||||
Some(page) => page,
|
||||
None if !st.editing_parents || p.get("page").is_some() => 0,
|
||||
None => return err("select a document page or give an explicit page"),
|
||||
};
|
||||
let page = match page_index(p, current, st.doc.page_count()) {
|
||||
Ok(page) => page,
|
||||
Err(e) => return err(e),
|
||||
};
|
||||
@@ -689,6 +694,83 @@ mod automation_contract_tests {
|
||||
assert_eq!(app.ui.palette.as_deref(), Some("field"));
|
||||
assert_eq!(app.session.execute("story.get", &json!({"story":f["story"]})).unwrap()["text"], "Hello");
|
||||
}
|
||||
#[test]
|
||||
fn parent_resize_no_canvas_render_keeps_document_page_zero_default() {
|
||||
let mut app = app();
|
||||
let ctx = egui::Context::default();
|
||||
app.run("layout.pageSize", json!({"pages": [2], "width": 700})).unwrap();
|
||||
assert!(app.canvas_rect.is_none());
|
||||
let before = app.session.doc().unwrap().doc.clone();
|
||||
let omitted = call(&mut app, &ctx, "ui.render", json!({"scale": 0.1}));
|
||||
assert_eq!(omitted["ok"], true, "{omitted}");
|
||||
let first = call(&mut app, &ctx, "ui.render", json!({"page": 0, "scale": 0.1}));
|
||||
let second = call(&mut app, &ctx, "ui.render", json!({"page": 1, "scale": 0.1}));
|
||||
assert_eq!(omitted, first);
|
||||
assert_ne!(omitted, second);
|
||||
assert_eq!(app.session.doc().unwrap().doc, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_resize_no_canvas_delete_uses_first_document_page() {
|
||||
let mut app = app();
|
||||
assert!(app.canvas_rect.is_none());
|
||||
let before = app.session.doc().unwrap().doc.clone();
|
||||
app.run("app.deletePage", json!({})).unwrap();
|
||||
let after = &app.session.doc().unwrap().doc;
|
||||
assert_eq!(after.page_count(), 1);
|
||||
assert_eq!(after.page(0).unwrap().id, before.page(1).unwrap().id);
|
||||
assert_eq!(after.parents, before.parents);
|
||||
assert!(app.ui.dialog.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_resize_no_canvas_delete_keeps_the_object_warning() {
|
||||
let mut app = app();
|
||||
app.run("frame.create", json!({"rect": [36, 36, 100, 100]})).unwrap();
|
||||
assert!(app.canvas_rect.is_none());
|
||||
let before = app.session.doc().unwrap().doc.clone();
|
||||
app.run("app.deletePage", json!({})).unwrap();
|
||||
let dialog = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!(dialog.id, "deletePages");
|
||||
assert_eq!(dialog.fields["pages"], json!([0]));
|
||||
assert_eq!(app.session.doc().unwrap().doc, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_resize_no_canvas_duplicate_uses_first_document_spread() {
|
||||
let mut app = app();
|
||||
assert!(app.canvas_rect.is_none());
|
||||
let before = app.session.doc().unwrap().doc.clone();
|
||||
let ids: Vec<_> = before.spreads.iter().flat_map(|sp| sp.pages.iter().map(|p| p.id)).collect();
|
||||
let result = app.run("app.duplicateSpread", json!({})).unwrap();
|
||||
let after = &app.session.doc().unwrap().doc;
|
||||
let first = result["page"].as_u64().unwrap() as usize;
|
||||
assert_eq!(first, before.spreads[0].pages.len());
|
||||
assert_eq!(after.page_count(), before.page_count() + first);
|
||||
for i in first..first * 2 {
|
||||
assert!(!ids.contains(&after.page(i).unwrap().id));
|
||||
}
|
||||
let retained: Vec<_> = after.spreads.iter().flat_map(|sp| sp.pages.iter().map(|p| p.id)).filter(|id| ids.contains(id)).collect();
|
||||
assert_eq!(retained, ids);
|
||||
assert_eq!(after.parents, before.parents);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_resize_mode_does_not_default_document_actions_to_page_zero() {
|
||||
for canvas in [None, Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0)))] {
|
||||
let mut app = app();
|
||||
let ctx = egui::Context::default();
|
||||
app.canvas_rect = canvas;
|
||||
app.run("layout.parents.edit", json!({"on":true,"parent":"A"})).unwrap();
|
||||
let before = app.session.doc().unwrap().doc.clone();
|
||||
assert!(app.run("app.deletePage", json!({})).is_err());
|
||||
assert!(app.run("app.duplicateSpread", json!({})).is_err());
|
||||
assert_eq!(call(&mut app, &ctx, "ui.render", json!({"scale":0.1}))["ok"], false);
|
||||
assert_eq!(call(&mut app, &ctx, "ui.render", json!({"page":0,"scale":0.1}))["ok"], true);
|
||||
assert_eq!(app.session.doc().unwrap().doc, before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_pages_validate_before_writer_and_preserve_omission() {
|
||||
let mut app = app();
|
||||
|
||||
@@ -3016,9 +3016,16 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
result = Some(false);
|
||||
}
|
||||
}
|
||||
// A centred window only offers its body last frame's height: let this one grow to its
|
||||
// options' height (the screen still caps it).
|
||||
let min_body = if side_actions { body_height.min(crate::section_options::BODY_H) } else { 1.0 };
|
||||
// A centred window only offers its body last frame's height: let these grow to their
|
||||
// content's height (the screen still caps it). Numbering & Section Options has its
|
||||
// options beside its buttons; the Color Picker lays itself out on a fixed canvas, which
|
||||
// otherwise shows only its top strip (the field cut off, no values, no hex) behind a
|
||||
// scrollbar.
|
||||
let min_body = match d.id.as_str() {
|
||||
"numberingSection" => body_height.min(crate::section_options::BODY_H),
|
||||
"colorPicker" => body_height.min(picker_layout::SIZE.y + 2.0 * ui.spacing().item_spacing.y),
|
||||
_ => 1.0,
|
||||
};
|
||||
egui::ScrollArea::both()
|
||||
.id_salt(("dialog_body", &d.id))
|
||||
.min_scrolled_width(1.0)
|
||||
@@ -9021,6 +9028,8 @@ fn color_picker(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
let tr = |s: &str| crate::i18n::tr(&lang, s).to_string();
|
||||
let old = d.s("hex");
|
||||
d.fields.entry("old").or_insert_with(|| json!(old));
|
||||
// The component the picker was last left on, unless the opener chose one.
|
||||
d.fields.entry("component").or_insert_with(|| json!(app.ui.color_picker_component));
|
||||
let comp = PickComp::from_key(&d.s("component"));
|
||||
let mut p = picked_of(d);
|
||||
let before = p;
|
||||
@@ -9215,6 +9224,7 @@ fn color_picker(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
}
|
||||
if comp_now != comp {
|
||||
d.fields.insert("component".into(), json!(comp_now.key()));
|
||||
app.ui.color_picker_component = comp_now.key().to_string();
|
||||
}
|
||||
// The hex code, and the eyedropper beside it.
|
||||
let cy = area.min.y + at::ROWS[7];
|
||||
|
||||
@@ -3698,6 +3698,42 @@ fn the_color_picker_picks_on_its_field_and_slider_and_adds_a_cmyk_swatch() {
|
||||
assert_eq!(st.doc.resolve_color(&fill, 1.0).unwrap().to_hex(), "#7c1315");
|
||||
}
|
||||
|
||||
/// The Color Picker reopens on the component it was left on, and the choice survives the
|
||||
/// preferences file; an opener that names a component still gets it.
|
||||
#[test]
|
||||
fn the_color_picker_remembers_its_component() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
app.run("frame.create", json!({"rect": [72, 72, 200, 200], "shape": "rectangle"})).unwrap();
|
||||
assert_eq!(app.ui.color_picker_component, "r", "R at first, as before");
|
||||
app.ui.dialog = Some(Dialog::new("colorPicker", json!({"target": "fill", "hex": "#7c1315", "text": false})));
|
||||
let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0));
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().app.ui.dialog.as_ref().unwrap().s("component"), "r");
|
||||
h.get_by_role_and_label(egui::accesskit::Role::RadioButton, "H:").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().app.ui.dialog.as_ref().unwrap().s("component"), "h");
|
||||
assert_eq!(h.state().app.ui.color_picker_component, "h", "the choice is kept outside the dialog");
|
||||
h.get_by_label("Cancel").click();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().app.ui.dialog.is_none());
|
||||
// Reopened: H, not R.
|
||||
h.state_mut().app.ui.dialog = Some(Dialog::new("colorPicker", json!({"target": "fill", "hex": "#7c1315", "text": false})));
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().app.ui.dialog.as_ref().unwrap().s("component"), "h");
|
||||
h.get_by_label("Cancel").click();
|
||||
h.run_steps(2);
|
||||
// An opener's own choice wins.
|
||||
h.state_mut().app.ui.dialog = Some(Dialog::new("colorPicker", json!({"target": "fill", "hex": "#7c1315", "text": false, "component": "l"})));
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().app.ui.dialog.as_ref().unwrap().s("component"), "l");
|
||||
// Saved with the preferences, and read back; a file without it is R.
|
||||
let saved = serde_json::to_vec(&h.state().app.ui).unwrap();
|
||||
assert_eq!(crate::UiState::from_preferences_json(&saved).unwrap().color_picker_component, "h");
|
||||
assert_eq!(crate::UiState::from_preferences_json(b"{}").unwrap().color_picker_component, "r");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_guides_dialog_covers_the_spread_in_view() {
|
||||
let mut app = facing_app(6);
|
||||
@@ -4931,3 +4967,33 @@ fn frame_size_dialog_keeps_its_units_while_open() {
|
||||
let b = added[0].bounds();
|
||||
assert!((b.width() - 144.0).abs() < 0.01 && (b.height() - 216.0).abs() < 0.01, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_resize_exact_dialog_keeps_its_target_and_rejects_invalid_input() {
|
||||
use designcraft_tools::{PointerEvent, PointerKind};
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width":400,"height":600,"pages":3,"facingPages":true})).unwrap();
|
||||
app.run("layout.parents.new", json!({"prefix":"B"})).unwrap();
|
||||
app.run("layout.parents.edit", json!({"on":true})).unwrap();
|
||||
app.session.set_tool("page");
|
||||
let before = app.session.doc().unwrap().doc.clone();
|
||||
let sr = designcraft_doc::SpreadRef::Parent(1);
|
||||
let p = app.session.layout().spread_page_rect(&before, sr, 1).unwrap().center();
|
||||
app.session.pointer(&PointerEvent::new(PointerKind::DoubleClick, p.x, p.y), designcraft_engine::ViewInfo::at_zoom(1.0)).unwrap();
|
||||
let request = app.session.ui_requests.pop().unwrap();
|
||||
let designcraft_engine::UiRequest::Dialog { id, params } = request else { panic!("expected exact-size dialog") };
|
||||
app.ui.dialog = Some(Dialog::new(&id, params));
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
d.fields.insert("width".into(), json!("530"));
|
||||
d.fields.insert("height".into(), json!("710"));
|
||||
confirm(&mut app).unwrap();
|
||||
let doc = &app.session.doc().unwrap().doc;
|
||||
assert_eq!((doc.parents[1].pages[1].width, doc.parents[1].pages[1].height), (530.0, 710.0));
|
||||
assert_eq!(doc.parents[0], before.parents[0]);
|
||||
assert_eq!(doc.spreads, before.spreads);
|
||||
let saved = doc.clone();
|
||||
app.ui.dialog = Some(Dialog::new("cmd:layout.parentPageSize", json!({"parent":saved.parents[1].id.0,"page":1,"width":"NaN"})));
|
||||
assert!(confirm(&mut app).is_err());
|
||||
assert!(app.ui.dialog.is_some());
|
||||
assert!(std::sync::Arc::ptr_eq(&saved, &app.session.doc().unwrap().doc));
|
||||
}
|
||||
@@ -462,7 +462,11 @@ fn run_ui_part2(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
|
||||
}
|
||||
"app.deletePage" | "app.duplicateSpread" => {
|
||||
let Some(st) = app.session.active() else { return Some(Err("no document open".into())) };
|
||||
let page = crate::canvas::current_page(app).unwrap_or(0);
|
||||
let page = match crate::canvas::current_page(app) {
|
||||
Some(page) => page,
|
||||
None if !st.editing_parents => 0,
|
||||
None => return Some(Err("select a document page".into())),
|
||||
};
|
||||
let r = if id == "app.deletePage" {
|
||||
crate::dialogs::delete_pages(app, vec![page])
|
||||
} else {
|
||||
|
||||
@@ -147,12 +147,11 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
return;
|
||||
}
|
||||
// The Page tool with no objects selected: the page's own settings, as InDesign's.
|
||||
if info.is_none() && app.session.tool_id() == "page" {
|
||||
let page = app.session.active().and_then(|st| st.selection.page).or_else(|| crate::canvas::current_page(app));
|
||||
if let Some(page) = page {
|
||||
page_sections(app, ui, page);
|
||||
return;
|
||||
if info.is_none() && (app.session.tool_id() == "page" || app.session.active().is_some_and(|st| st.editing_parents)) {
|
||||
if let Some((spread, page)) = crate::canvas::page_target(app) {
|
||||
page_sections(app, ui, spread, page);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(i) = info else {
|
||||
document_sections(app, ui);
|
||||
|
||||
@@ -59,15 +59,14 @@ fn page_preset_row(ui: &mut Ui, lang: &str, w: f64, h: f64) -> Option<(f64, f64)
|
||||
|
||||
/// Properties with the Page tool (Properties › Edit Page), as InDesign's: a "Page" band with Back,
|
||||
/// Page Size for this page alone (which page, preset, orientation, W/H), Margins and Columns and
|
||||
/// Quick Actions. Back returns to the Selection tool and the document's properties.
|
||||
pub(super) fn page_sections(app: &mut DesignApp, ui: &mut Ui, page: usize) {
|
||||
/// Quick Actions. Back leaves parent editing and returns to the Selection tool and document properties.
|
||||
pub(super) fn page_sections(app: &mut DesignApp, ui: &mut Ui, spread: designcraft_doc::SpreadRef, page: usize) {
|
||||
let u = units(app);
|
||||
let lang = app.ui.language.clone();
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let d = st.doc.clone();
|
||||
let Some((si, pi)) = d.page_loc(page) else { return };
|
||||
let Some(pg) = d.spreads.get(si).and_then(|s| s.pages.get(pi)).cloned() else { return };
|
||||
let n = page + 1;
|
||||
let Some(sp) = d.spread(spread) else { return };
|
||||
let Some(pg) = sp.pages.get(page).cloned() else { return };
|
||||
let t = Tokens::get(ui.ctx());
|
||||
// The band: "Page", and Back at its end.
|
||||
let band = block(ui, 33.0);
|
||||
@@ -84,6 +83,9 @@ pub(super) fn page_sections(app: &mut DesignApp, ui: &mut Ui, page: usize) {
|
||||
});
|
||||
rule(ui);
|
||||
if back {
|
||||
if matches!(spread, designcraft_doc::SpreadRef::Parent(_)) {
|
||||
let _ = app.run("layout.parents.edit", json!({"on": false}));
|
||||
}
|
||||
let _ = app.run("layout.selectPage", json!({"page": null}));
|
||||
app.select_tool("selection");
|
||||
return;
|
||||
@@ -91,13 +93,25 @@ pub(super) fn page_sections(app: &mut DesignApp, ui: &mut Ui, page: usize) {
|
||||
section(ui, crate::i18n::tr(&lang, "Page Size"), true);
|
||||
// Which page.
|
||||
let b = block(ui, FIELD_H);
|
||||
let names: Vec<String> = (0..d.page_count()).map(|i| app.session.page_label(i)).collect();
|
||||
let (names, selected): (Vec<String>, usize) = match spread {
|
||||
designcraft_doc::SpreadRef::Doc(si) => ((0..d.page_count()).map(|i| app.session.page_label(i)).collect(), d.first_page_of_spread(si) + page),
|
||||
designcraft_doc::SpreadRef::Parent(_) => {
|
||||
((0..sp.pages.len()).map(|i| format!("{} · {}", sp.parent.as_ref().map(|p| p.label()).unwrap_or_default(), i + 1)).collect(), page)
|
||||
}
|
||||
};
|
||||
let picked = place(ui, sub(b, 0.0, 0.0, 93.0, FIELD_H), |ui| {
|
||||
widgets::dropdown_list(ui, names.get(page).map_or("", String::as_str), 93.0, &names, Some(page))
|
||||
widgets::dropdown_list(ui, names.get(selected).map_or("", String::as_str), 93.0, &names, Some(selected))
|
||||
});
|
||||
if let Some(p) = picked {
|
||||
let _ = app.run("layout.selectPage", json!({"page": p + 1}));
|
||||
crate::canvas::go_to_page(app, p);
|
||||
match spread {
|
||||
designcraft_doc::SpreadRef::Doc(_) => {
|
||||
let _ = app.run("layout.selectPage", json!({"page": p + 1}));
|
||||
crate::canvas::go_to_page(app, p);
|
||||
}
|
||||
designcraft_doc::SpreadRef::Parent(_) => {
|
||||
let _ = app.run("layout.selectParentPage", json!({"parent": sp.id.0, "page": p}));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
ui.add_space(3.0);
|
||||
@@ -116,13 +130,22 @@ pub(super) fn page_sections(app: &mut DesignApp, ui: &mut Ui, page: usize) {
|
||||
size = Some(if key == "width" { (nv, h) } else { (w, nv) });
|
||||
}
|
||||
}
|
||||
if let Some((nw, nh)) = size {
|
||||
let _ = app.run("layout.pageSize", json!({"pages": [n], "width": nw, "height": nh, "anchor": app.ui.ref_point.min(8)}));
|
||||
if let Some((nw, nh)) = size
|
||||
&& let Some((cmd, params)) =
|
||||
designcraft_tools::layout::page_size_command(&d, spread, page, json!({"width": nw, "height": nh, "anchor": app.ui.ref_point.min(8)}))
|
||||
{
|
||||
let _ = app.run(cmd, params);
|
||||
}
|
||||
if matches!(spread, designcraft_doc::SpreadRef::Parent(_)) {
|
||||
margin_section(app, ui);
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
let fw = full_width(ui);
|
||||
if outline_button(ui, crate::i18n::tr(&lang, "Margins and Columns"), fw).clicked() {
|
||||
crate::canvas::go_to_page(app, page);
|
||||
match spread {
|
||||
designcraft_doc::SpreadRef::Doc(si) => crate::canvas::go_to_page(app, d.first_page_of_spread(si) + page),
|
||||
designcraft_doc::SpreadRef::Parent(_) => crate::canvas::show_spread(app, spread),
|
||||
}
|
||||
crate::menus::activate(app, "layout.marginsAndColumns", &Value::Null);
|
||||
}
|
||||
divider(ui);
|
||||
@@ -181,6 +204,61 @@ fn page_box_field(app: &mut DesignApp, ui: &mut Ui, b: Rect) {
|
||||
}
|
||||
}
|
||||
|
||||
fn margin_section(app: &mut DesignApp, ui: &mut Ui) {
|
||||
let u = units(app);
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let d = st.doc.clone();
|
||||
let t = Tokens::get(ui.ctx());
|
||||
section(ui, crate::i18n::tr(&app.ui.language, "Margins"), true);
|
||||
let cur = crate::canvas::current_page(app).unwrap_or(0);
|
||||
// While parent spreads are edited the fields belong to the parent page in view; they used to
|
||||
// show (and change) the document pages instead, so nothing changed on the parent.
|
||||
let parent_target = crate::canvas::current_parent_page(app);
|
||||
let shown = match parent_target {
|
||||
Some((pi, qi)) => d.parents.get(pi).and_then(|s| s.pages.get(qi)).cloned(),
|
||||
None => d.page(cur).cloned(),
|
||||
};
|
||||
if let Some(p) = shown {
|
||||
let linked = temp_bool(ui, "margins_linked", true);
|
||||
let b = block(ui, 2.0 * FIELD_H + 5.0);
|
||||
let mut set: Option<designcraft_doc::Margins> = None;
|
||||
let fields = [
|
||||
("margin-top", "Top", p.margins.top, 0.0, 0.0),
|
||||
("margin-bottom", "Bottom", p.margins.bottom, 0.0, 26.0),
|
||||
("margin-outside", if d.settings.facing_pages { "Outside" } else { "Right" }, p.margins.outside, 127.0, 0.0),
|
||||
("margin-inside", if d.settings.facing_pages { "Inside" } else { "Left" }, p.margins.inside, 127.0, 26.0),
|
||||
];
|
||||
for (k, (icon, tip, v, x, y)) in fields.into_iter().enumerate() {
|
||||
let ir = sub(b, x, y + 2.0, 17.0, 17.0);
|
||||
icons::paint(ui.painter(), ir, icon, t.icon);
|
||||
ui.interact(ir, ui.scope_id().with(("micon", k)), Sense::hover()).on_hover_ui(|ui| {
|
||||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, tip));
|
||||
});
|
||||
let nv = place(ui, sub(b, x + 20.0, y, 73.0, FIELD_H), |ui| {
|
||||
NumField::measure(tip, Some(v), u).width(73.0).spinner().range(0.0, 8000.0).show(ui)
|
||||
});
|
||||
if let Some(nv) = nv {
|
||||
set = Some(edited_margins(p.margins, linked, k, nv));
|
||||
}
|
||||
}
|
||||
let lr = sub(b, 99.0, 12.5, 22.0, FIELD_H);
|
||||
let clicked = place(ui, lr, |ui| {
|
||||
widgets::icon_toggle_sized(ui, if linked { "link" } else { "link-broken" }, linked, "Make all settings the same", vec2(22.0, 21.0))
|
||||
.clicked()
|
||||
});
|
||||
if clicked {
|
||||
set_temp_bool(ui, "margins_linked", !linked);
|
||||
}
|
||||
if let Some(m) = set {
|
||||
let params = match parent_target {
|
||||
Some((pi, qi)) => json!({"margins": m, "parent": pi, "pages": [qi]}),
|
||||
None => json!({"margins": m}),
|
||||
};
|
||||
let _ = app.run("layout.marginsAndColumns", params);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn document_sections(app: &mut DesignApp, ui: &mut Ui) {
|
||||
let u = units(app);
|
||||
let Some(st) = app.session.active() else { return };
|
||||
@@ -234,59 +312,8 @@ pub(super) fn document_sections(app: &mut DesignApp, ui: &mut Ui) {
|
||||
if changed {
|
||||
let _ = app.run("layout.documentSetup", json!({"facingPages": facing}));
|
||||
}
|
||||
// Margins.
|
||||
section(ui, crate::i18n::tr(&app.ui.language, "Margins"), true);
|
||||
margin_section(app, ui);
|
||||
let cur = crate::canvas::current_page(app).unwrap_or(0);
|
||||
// While parent spreads are edited the fields belong to the parent page in view; they used to
|
||||
// show (and change) the document pages instead, so nothing changed on the parent.
|
||||
let parent_target = crate::canvas::current_parent_page(app);
|
||||
let shown = match parent_target {
|
||||
Some((pi, qi)) => d.parents.get(pi).and_then(|s| s.pages.get(qi)).cloned(),
|
||||
None => d.page(cur).cloned(),
|
||||
};
|
||||
if let Some(p) = shown {
|
||||
let linked = temp_bool(ui, "margins_linked", true);
|
||||
let b = block(ui, 2.0 * FIELD_H + 5.0);
|
||||
let mut set: Option<designcraft_doc::Margins> = None;
|
||||
let fields = [
|
||||
("margin-top", "Top", p.margins.top, 0.0, 0.0),
|
||||
("margin-bottom", "Bottom", p.margins.bottom, 0.0, 26.0),
|
||||
("margin-outside", if d.settings.facing_pages { "Outside" } else { "Right" }, p.margins.outside, 127.0, 0.0),
|
||||
("margin-inside", if d.settings.facing_pages { "Inside" } else { "Left" }, p.margins.inside, 127.0, 26.0),
|
||||
];
|
||||
for (k, (icon, tip, v, x, y)) in fields.into_iter().enumerate() {
|
||||
let ir = sub(b, x, y + 2.0, 17.0, 17.0);
|
||||
icons::paint(ui.painter(), ir, icon, t.icon);
|
||||
ui.interact(ir, ui.scope_id().with(("micon", k)), Sense::hover()).on_hover_ui(|ui| {
|
||||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, tip));
|
||||
});
|
||||
let nv = place(ui, sub(b, x + 20.0, y, 73.0, FIELD_H), |ui| {
|
||||
NumField::measure(tip, Some(v), u).width(73.0).spinner().range(0.0, 8000.0).show(ui)
|
||||
});
|
||||
if let Some(nv) = nv {
|
||||
set = Some(edited_margins(p.margins, linked, k, nv));
|
||||
}
|
||||
}
|
||||
let lr = sub(b, 99.0, 12.5, 22.0, FIELD_H);
|
||||
let clicked = place(ui, lr, |ui| {
|
||||
widgets::icon_toggle_sized(ui, if linked { "link" } else { "link-broken" }, linked, "Make all settings the same", vec2(22.0, 21.0))
|
||||
.clicked()
|
||||
});
|
||||
if clicked {
|
||||
set_temp_bool(ui, "margins_linked", !linked);
|
||||
}
|
||||
if let Some(m) = set {
|
||||
let params = match parent_target {
|
||||
// Both pages of a facing parent spread, so the margins show wherever the parent is used.
|
||||
Some((pi, _)) => {
|
||||
let n = app.session.active().and_then(|st| st.doc.parents.get(pi)).map_or(1, |sp| sp.pages.len());
|
||||
json!({"margins": m, "parent": pi, "pages": (0..n).collect::<Vec<_>>()})
|
||||
}
|
||||
None => json!({"margins": m}),
|
||||
};
|
||||
let _ = app.run("layout.marginsAndColumns", params);
|
||||
}
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
let fw = full_width(ui);
|
||||
if outline_button(ui, crate::i18n::tr(&app.ui.language, "Adjust Layout"), fw).clicked() {
|
||||
|
||||
@@ -226,6 +226,9 @@ pub struct UiState {
|
||||
pub story_editor_size: f32,
|
||||
/// View › Story Editor › Show/Hide Depth Ruler.
|
||||
pub story_editor_depth_ruler: bool,
|
||||
/// The Color Picker's component (its radio button, as `PickComp::key`: `h`, `s`, `b`, `l`,
|
||||
/// `la`, `lb`, `r`, `g`, `bl`), kept between openings and sessions, as Photoshop's picker is.
|
||||
pub color_picker_component: String,
|
||||
/// Preferences › General › Interface Language: supported codes are listed in `i18n::LANGUAGES`.
|
||||
pub language: String,
|
||||
/// Edit › Transparency Flattener Presets: "" (none), "high", "medium" or "low" for PDF export.
|
||||
@@ -399,6 +402,7 @@ impl Default for UiState {
|
||||
show_full_menus: false,
|
||||
story_editor_size: 14.0,
|
||||
story_editor_depth_ruler: true,
|
||||
color_picker_component: "r".into(),
|
||||
scripts: vec![
|
||||
("Number the pages".into(), "# A page-number frame at the bottom of the current page.\nframe.create {\"rect\": [288, 740, 324, 760], \"content\": \"text\", \"text\": \"\"}\ntext.select {\"story\": \"$0.story\", \"anchor\": 0, \"focus\": 0}\ntext.insert {\"text\": \"\\ue000\", \"raw\": true}".into()),
|
||||
("Two-column grid".into(), "# Four text frames in a 2 × 2 grid.\nframe.grid {\"rect\": [36, 36, 576, 756], \"cols\": 2, \"rows\": 2, \"gutter\": 12}".into()),
|
||||
|
||||
@@ -49,7 +49,7 @@ so only enable it while you use it. Transport: `apps/designcraft/src/control_ser
|
||||
| `ui.dialog.set` / `ui.dialog.confirm` / `ui.dialog.cancel` | `{field, value}` | Fill and confirm the open dialog |
|
||||
| `ui.resize`, `ui.focus` | | Window control |
|
||||
| `ui.screenshot` | `{path?}` | PNG of the whole window |
|
||||
| `ui.render` | `{path?, page?, scale?, bleed?}` | Render a page headlessly (PNG; base64 if no path) |
|
||||
| `ui.render` | `{path?, page?, scale?, bleed?}` | Render a document page headlessly (PNG; base64 if no path); without a canvas, document mode defaults to page 0; parent editing mode requires an explicit 0-based `page` |
|
||||
| `app.open` / `app.save` / `app.export` | | Files |
|
||||
| `app.quit` | | Quit without asking about unsaved changes (the `app.quit` command, like ⌘Q, asks) |
|
||||
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# DesignCraft roadmap (detail)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (.indd and .indt open through indd-utils; text threads by hand; the gate waits on the Primary Text Frame page fixes) · **Target:** Adobe InDesign 2026 (21.6)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (independent parent page resizing, #1620; .indd and .indt open through indd-utils; text threads by hand; the gate waits on the Primary Text Frame page fixes) · **Target:** Adobe InDesign 2026 (21.6)
|
||||
|
||||
Forward-looking plan. The one-page summary is [ROADMAP.md](../ROADMAP.md); numbers are in
|
||||
[target-app-parity.md](target-app-parity.md); the work list is [gaps.md](gaps.md). Hours are
|
||||
@@ -17,7 +17,7 @@ but are next in line.
|
||||
|
||||
| Core workflow | Works end to end? | Evidence | Hours to pass |
|
||||
|---|---|---|---|
|
||||
| Set up a document (size, margins, columns, parent pages, page numbers, sections); save and reopen | yes | New Document, Document Setup, parent pages and overrides, Numbering & Section Options; `.designcraft` round-trip tests; recovery | 0 |
|
||||
| Set up a document (size, margins, columns, parent pages, page numbers, sections); save and reopen | yes | New Document, Document Setup, independently sized parent pages (Properties and Page Tool, #1620), parent overrides, Numbering & Section Options; `.designcraft` round-trip tests; recovery | 0 |
|
||||
| Flow a long story across frames and pages: thread, unthread, autoflow | **no** | threading by hand works (ports, loaded text cursor, `text.thread`/`text.unthread`/`text.removeFromThread`); Primary Text Frame autoflow adds pages users can't delete (#165) | 5–7 |
|
||||
| Format text with paragraph and character styles | partial (not blocking) | Paragraph/Character Style Options, Quick Apply, based-on, nested/GREP styles work; style edit/delete from the panel reported (#169); formatting set at a caret is discarded (#289) | 6–12 |
|
||||
| Place, crop and fit images; wrap text around them | yes | place gun, frame fitting and Fitting Options, content grabber, text wrap, Links panel | 0 |
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** patch (the Pen previews the curve while a smooth point is dragged and curves the rubber band after one, #1438; Table of Contents dialog: title field keeps its width, More Options toggles without losing hidden settings; Story panel; text Control panel Character/Paragraph modes; Properties ••• expands Character and Paragraph; live corners work; document views row: scrollbars, Alt pans while editing text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; paragraph style override feedback and clearing) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** patch (the Pen previews the curve while a smooth point is dragged and curves the rubber band after one, #1438; a Custom text variable defined with a number shows its digits, #2093; Table of Contents dialog: title field keeps its width, More Options toggles without losing hidden settings; Story panel; text Control panel Character/Paragraph modes; Properties ••• expands Character and Paragraph; live corners work; document views row: scrollbars, Alt pans while editing text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; paragraph style override feedback and clearing) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS)
|
||||
|
||||
Tools, handles, snapping, nudging, modifiers, shortcuts, panels, menus, context menus and feel,
|
||||
against InDesign 2026. The look was measured from the running app
|
||||
@@ -48,7 +48,7 @@ basics of editing fail for too many users on Windows and Linux.
|
||||
| Numeric fields: units, arithmetic, scrubbing | ✓ | 21 pt spinner fields with arithmetic | ✓ | |
|
||||
| Colour fields: hex, picker from any swatch field | ✓ | ✓ | ~ | hex entry and picker issues (#323, #190) |
|
||||
| Panels: Pages, Layers, Swatches, Color, Stroke, Character, Paragraph, Styles, Links, Effects, Text Wrap, Align, Pathfinder, Table, Index, TOC, Hyperlinks, Bookmarks, Articles, Tags, Notes, Track Changes, Object States, Buttons and Forms, Preflight, Data Merge, Library, Scripts, Glyphs, Info, Tabs, Story | ✓ | all present | ~ | Navigator, Trap Presets, Animation, Timing missing; pull-down panels can't scroll on Linux (#267) |
|
||||
| Dialogs: New Document, Document Setup, Preferences (all pages), Paragraph/Character/Object/Table/Cell Style Options, Export PDF, Print, Package, Find/Change | ✓ | present | ~ | generated command dialogs fill the long tail; depth varies |
|
||||
| Dialogs: New Document, Document Setup, Preferences (all pages), Paragraph/Character/Object/Table/Cell Style Options, Export PDF, Print, Package, Find/Change | ✓ | present | ~ | a Custom text variable whose text is typed as a number shows its digits (`variables.define` takes a string, a number or a boolean for `text` and refuses anything else; #2093); generated command dialogs fill the long tail; depth varies |
|
||||
| Workspaces | Essentials, Essentials Classic, Advanced, Book, Digital Publishing, Interactive for PDF, Printing and Proofing, Review, Typography, Touch | ✓ named workspaces (Essentials Classic: Control panel and iconic panel groups) + custom; Window › Workspace; no Review or Touch | ~ | tab bar overlaps menus in Book/Advanced (#182) |
|
||||
| Story Editor | ✓ | ✓ | ~ | depth ruler and View › Story Editor › Show/Hide Depth Ruler (`story_editor.rs` `depth_ruler_shows_and_hides_from_the_view_menu`); hiding or resizing the style-name column missing |
|
||||
| Undo granularity (a drag = one step) | ✓ | ✓ (Begin/Preview/Commit) | ✓ | |
|
||||
@@ -135,6 +135,7 @@ covers checking the sampled and redefined settings in the styles tutorial (2:45
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-02 | patch | Pen: while a smooth point's direction line is dragged out, the segment it shapes (from the previous anchor's out handle to the new anchor's in handle) shows live under the pointer; after a smooth point the rubber band to the pointer curves out of its direction line, a corner's stays straight (#1438, first item; the Option/Alt handle split is the other). Test: `pen_previews_the_curve_while_dragging_and_after_a_smooth_point` |
|
||||
| 2026-11-02 | patch | Text variables: a Custom variable defined with a number (a typed `2026` reaches `variables.define` as one) shows its digits instead of nothing (#2093, first item); `text` takes a string, a number or a boolean. Test: `variables::tests::a_custom_variable_defined_with_a_number_shows_its_digits` |
|
||||
| 2026-10-30 | minor | Dialogs: Table of Contents title field keeps its edit width; More Options toggles without losing hidden settings |
|
||||
| 2026-10-24 | minor | Panels row: Story panel (Optical Margin Alignment and its size) |
|
||||
| 2026-10-21 | minor | Chrome row: the text Control panel's A/¶ modes; Properties row: ••• expands Character and Paragraph in place |
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Installed as share/thumbnailers/ai.storyteller.designcraft.thumbnailer (package.sh). File managers
|
||||
# that read freedesktop thumbnailers (Nautilus, Thunar, Dolphin through KIO) run it for .designcraft
|
||||
# files: %i is the file, %o the PNG to write, %s the size in pixels. It writes the first-page preview
|
||||
# saved in the file; a file without one keeps the file type's icon.
|
||||
[Thumbnailer Entry]
|
||||
TryExec=designcraft-cli
|
||||
Exec=designcraft-cli thumbnail %i %o %s
|
||||
MimeType=application/x-designcraft;
|
||||
@@ -61,6 +61,7 @@ install -Dm755 "$BIN/designcraft-cli" "$STAGE/usr/bin/designcraft-cli"
|
||||
"${CROSS_COMPILE:-}strip" "$STAGE/usr/bin/designcraft" "$STAGE/usr/bin/designcraft-cli" 2>/dev/null || true
|
||||
install -Dm644 "$HERE/$APP_ID.desktop" "$STAGE/usr/share/applications/$APP_ID.desktop"
|
||||
install -Dm644 "$HERE/$APP_ID.mime.xml" "$STAGE/usr/share/mime/packages/$APP_ID.xml"
|
||||
install -Dm644 "$HERE/$APP_ID.thumbnailer" "$STAGE/usr/share/thumbnailers/$APP_ID.thumbnailer"
|
||||
mkdir -p "$STAGE/usr/share/metainfo"
|
||||
sed -e "s/@VERSION@/$VERSION/g" -e "s/@DATE@/$DESIGNCRAFT_BUILD_DATE/g" \
|
||||
"$HERE/$APP_ID.metainfo.xml.in" >"$STAGE/usr/share/metainfo/$APP_ID.metainfo.xml"
|
||||
@@ -142,6 +143,8 @@ if has appimage; then
|
||||
esac
|
||||
APPDIR="$WORK/DesignCraft.AppDir"
|
||||
cp -R "$STAGE" "$APPDIR"
|
||||
# Keep sctk-adwaita's MIT notice in the AppImage.
|
||||
install -Dm644 "$ROOT/NOTICE" "$APPDIR/usr/share/doc/designcraft/NOTICE"
|
||||
ln -s usr/bin/designcraft "$APPDIR/AppRun"
|
||||
cp "$HERE/$APP_ID.desktop" "$APPDIR/$APP_ID.desktop"
|
||||
cp "$ROOT/assets/app-icon/hicolor/256x256/apps/$APP_ID.png" "$APPDIR/$APP_ID.png"
|
||||
|
||||
Reference in new issue
Block a user