Merge branch 'print-color' into print-color-x1a

# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	xtask/src/pdf_deps.rs
This commit is contained in:
aiongg
2026-10-23 08:16:07 +00:00
155 changed files with 6908 additions and 1059 deletions
+6
View File
@@ -0,0 +1,6 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
reviews:
auto_review:
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
ignore_usernames:
- "dependabot[bot]"
+71
View File
@@ -0,0 +1,71 @@
# The CLAUDE.md quality gate (`cargo xtask ci`: fmt, clippy -D warnings, tests, assets, layers,
# wasm) on every pull request and every push to main. Today only path-filtered workflows run on
# pull requests, so most PRs land with no test run at all.
#
# Ported from wordcraft#800. Linux only; standard GitHub-hosted runner, no secrets.
name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
# Same craft-fonts commit as release.yml, so the font tests see what a release build embeds.
CRAFT_FONTS_REF: abb83316d96aa59c1cf64784289e378fe9fa5695
jobs:
gate:
name: cargo xtask ci
runs-on: ubuntu-24.04
timeout-minutes: 90
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: "0"
RUST_BACKTRACE: "1"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Check out craft-fonts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
persist-credentials: false
- name: Install Linux build dependencies
uses: storytold/craft-common-actions/actions/install-apt-packages@main
with:
packages: >-
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
- name: Install Rust (stable + clippy, rustfmt, wasm32)
run: |
rustup toolchain install stable --profile minimal --component clippy,rustfmt \
--target wasm32-unknown-unknown
rustup default stable
- uses: taiki-e/install-action@f7e5d7c961414b23f5b25b2da9294395d08513ad # v2.87.26
with:
tool: cargo-nextest@0.9.145
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Quality gate
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
run: cargo xtask ci
+3 -13
View File
@@ -18,16 +18,6 @@ concurrency:
cancel-in-progress: true
jobs:
eval:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- name: nix flake check --no-build
run: |
if [ -f flake.nix ]; then
nix flake check --no-build --all-systems
else
echo "No flake.nix present in this repository; skipping check."
fi
nix:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
+20
View File
@@ -0,0 +1,20 @@
name: SonarCloud
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
concurrency:
group: sonarcloud-${{ github.ref }}
cancel-in-progress: true
jobs:
sonarcloud:
# Tracked at @main so org-wide CI improvements propagate without PR churn.
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
secrets: inherit
+1
View File
@@ -26,6 +26,7 @@ People trust DesignCraft with their layouts; a crash loses their work. **This ou
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. Tools emit commands (Begin/Preview/Commit). UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The control channel and MCP reach all of them.
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd. The UI crate is swappable.
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math). A crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). One task id per commit (`M2.1: type tool caret navigation`).
+31 -6
View File
@@ -40,12 +40,12 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| `docs/images/page-4.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI |
| `docs/images/page-cover.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI |
| `docs/images/ui-spread.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI |
| `crates/render/tests/golden/drop-shadow-spread.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
| `crates/render/tests/golden/drop-shadow-text.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
| `crates/render/tests/golden/drop-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
| `crates/render/tests/golden/feather.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
| `crates/render/tests/golden/inner-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
| `crates/render/tests/golden/outer-glow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
| `crates/render/tests/golden/drop-shadow-spread.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
| `crates/render/tests/golden/drop-shadow-text.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
| `crates/render/tests/golden/drop-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
| `crates/render/tests/golden/feather.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
| `crates/render/tests/golden/inner-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
| `crates/render/tests/golden/outer-glow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
| `assets/app-icon/LICENSE.txt` | (licence text) | this project | — | Licence for the app icon |
| `assets/app-icon/designcraft-1024.png` | DesignCraft project owner (drawn in ArtCraft) | rendered from `assets/app-icon/designcraft.svg` by `packaging/icons.sh` | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | App icon: calico cat in a polka-dot scarf (see `assets/app-icon/README.md`) |
| `assets/app-icon/designcraft-macos-512.png` | DesignCraft project owner (drawn in ArtCraft) | rendered from `assets/app-icon/designcraft.svg` by `packaging/icons.sh` | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | App icon: calico cat in a polka-dot scarf (see `assets/app-icon/README.md`) |
@@ -69,3 +69,28 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| `docs/brand/LICENSE-brand.txt` | (licence text) | ArtCraft Team | — | Terms for the ArtCraft Marks in `docs/brand/` |
| `packaging/macos/dmg/background.svg` | @XusBadia | original work for this repository, derived from the DesignCraft app icon (`assets/app-icon/designcraft.svg`, referenced via `<image>`, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | DesignCraft macOS DMG window background for the Finder window; `packaging/macos/dmg/generate.py` renders it to `background.tiff` (2026-10-08) |
| `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") |
## Assets bundled by the PDF backend
These assets were already enabled by Hayro’s default font and CMap features. The pinned
backend keeps the same files; hashes and upstream licence notices are retained here.
| Crate / file | Author | Source | Licence | SHA-256 | Use |
| --- | --- | --- | --- | --- | --- |
| `hayro-interpret 0.7.0 / assets/FoxitDingbats.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitDingbats.pfb | BSD-3-Clause | `845c752392b6c914fb989c75a08b7792b88f542d2499042ef2889f8c814a16ed` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitFixed.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitFixed.pfb | BSD-3-Clause | `b6c8fe53f134b8b6d4578cd2d544df4cee9624c4efa8d51a560fb40ea296101b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitFixedBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitFixedBold.pfb | BSD-3-Clause | `f1b7159702973f54fc86254ea38bcf3712b2a736eb3a0995751e9f4bb45ad603` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitFixedBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitFixedBoldItalic.pfb | BSD-3-Clause | `8a000945843bd31add06aee63bf9fd41b7578b4f3242a4b7cd46349ad9d24d4d` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitFixedItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitFixedItalic.pfb | BSD-3-Clause | `5007faf8320fa1fcc08b8894b356ad976f60b992ba29ee5272578bbdebaf3876` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSans.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSans.pfb | BSD-3-Clause | `a02339e95d20db14cbdfc1c6baf359cd36a091d5434925c3a4040f60e1a1af9e` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSansBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSansBold.pfb | BSD-3-Clause | `7bdfa36de1d5757dcfaf8b8a4be95c6e41c40fa70dbed9d78bf979a7b9959054` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSansBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSansBoldItalic.pfb | BSD-3-Clause | `555ce45924338e2e4b32821910221ce85da691fd59a9723c4c971a7c5627fc8b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSansItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSansItalic.pfb | BSD-3-Clause | `7751cbefeda351708dae8c4a88db043411bc39ab2ae2097685da845d4d16f307` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSerif.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSerif.pfb | BSD-3-Clause | `4f57d2b9d884af8f907bf22df6019b52d86cbf6214fdbd02b1ae05472a543f35` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSerifBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSerifBold.pfb | BSD-3-Clause | `0bdf4b04e964139818d51eda03d566ba999fc3ba2421b1c6c51f9dc969022e80` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSerifBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSerifBoldItalic.pfb | BSD-3-Clause | `a406cac82583bf98175cb62c87ed5e95c45fbb34eece63a10b0a13e793cb2e10` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSerifItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSerifItalic.pfb | BSD-3-Clause | `610ae0687198045c4db5d1a6650fd5e92536631706382eb8b72e38df578d0ae9` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSymbol.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSymbol.pfb | BSD-3-Clause | `47967d055530e7357088a08403115425643ec2cdfd6201ba8af0fbd7116c1539` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/CGATS001Compat-v2-micro.icc` | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc | CC0-1.0 | `73e1ba37d2bad5bab2a964f40a9eed96209666efc067c3322626214bbef234a0` | Default CMYK colour conversion (pinned hayro-interpret) |
| `hayro-cmap 0.1.0 / assets/cmaps.brotli` | Adobe | https://crates.io/crates/hayro-cmap/0.1.0 | BSD-3-Clause | `ddf37af521d904d5646c2fc37b2b7e912dc1b56f0d210190b61f598154758e46` | Character-code mapping tables required to read CJK PDFs. Non-visual data, allowed by the PDF compatibility exception |
| `hayro-interpret 0.7.0 / src/font/generated/metrics.rs` | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/src/font/generated/metrics.rs | MIT OR Apache-2.0 | `2ec9e608573eda459de1e1d9bda3bb494315e36bee885acc88912a11fe0caa41` | Glyph widths and encodings for non-embedded standard fonts. Non-visual data, allowed by the PDF compatibility exception |
Generated
+37 -23
View File
@@ -686,6 +686,15 @@ dependencies = [
"libc",
]
[[package]]
name = "chrono"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"num-traits",
]
[[package]]
name = "clipboard-win"
version = "5.4.1"
@@ -957,7 +966,7 @@ dependencies = [
[[package]]
name = "designcraft"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"arboard",
"designcraft-engine",
@@ -979,7 +988,7 @@ dependencies = [
[[package]]
name = "designcraft-cli"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -995,7 +1004,7 @@ dependencies = [
[[package]]
name = "designcraft-color"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"craft-cms 0.1.0 (git+https://github.com/storytold/craft-libs?rev=e9546e191dd62f2bc1d61eca45dbdc2f1b3c1d8e)",
"kurbo",
@@ -1007,7 +1016,7 @@ dependencies = [
[[package]]
name = "designcraft-compose"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"designcraft-color",
"designcraft-doc",
@@ -1027,8 +1036,9 @@ dependencies = [
[[package]]
name = "designcraft-doc"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"chrono",
"designcraft-color",
"designcraft-geom",
"proptest",
@@ -1039,7 +1049,7 @@ dependencies = [
[[package]]
name = "designcraft-engine"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"caseless",
"craft-atomic",
@@ -1078,8 +1088,9 @@ dependencies = [
[[package]]
name = "designcraft-epub"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"chrono",
"designcraft-color",
"designcraft-doc",
"zip",
@@ -1087,7 +1098,7 @@ dependencies = [
[[package]]
name = "designcraft-fonts"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"core-text",
"harfrust 0.13.3",
@@ -1100,7 +1111,7 @@ dependencies = [
[[package]]
name = "designcraft-format"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"designcraft-doc",
"designcraft-images",
@@ -1112,7 +1123,7 @@ dependencies = [
[[package]]
name = "designcraft-geom"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"flo_curves",
"kurbo",
@@ -1125,7 +1136,7 @@ dependencies = [
[[package]]
name = "designcraft-idml"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1140,7 +1151,7 @@ dependencies = [
[[package]]
name = "designcraft-images"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"craft-cms 0.1.0 (git+https://github.com/storytold/craft-libs?rev=e9546e191dd62f2bc1d61eca45dbdc2f1b3c1d8e)",
"designcraft-color",
@@ -1155,7 +1166,7 @@ dependencies = [
[[package]]
name = "designcraft-mcp"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"designcraft-engine",
"designcraft-geom",
@@ -1168,8 +1179,9 @@ dependencies = [
[[package]]
name = "designcraft-pdf"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"chrono",
"craft-cms 0.1.0 (git+https://github.com/storytold/craft-libs?rev=e9546e191dd62f2bc1d61eca45dbdc2f1b3c1d8e)",
"craft-pdf",
"craft-prepress",
@@ -1192,8 +1204,9 @@ dependencies = [
[[package]]
name = "designcraft-pptx"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"chrono",
"designcraft-color",
"designcraft-compose",
"designcraft-doc",
@@ -1209,25 +1222,26 @@ dependencies = [
[[package]]
name = "designcraft-render"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"craft-pdf",
"designcraft-color",
"designcraft-compose",
"designcraft-doc",
"designcraft-fonts",
"designcraft-geom",
"designcraft-images",
"hayro",
"image",
"log",
"serde",
"thiserror 2.0.21",
"vello_common 0.2.0",
"vello_cpu 0.2.0",
]
[[package]]
name = "designcraft-textimport"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"designcraft-doc",
"quick-xml",
@@ -1236,7 +1250,7 @@ dependencies = [
[[package]]
name = "designcraft-tools"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1248,7 +1262,7 @@ dependencies = [
[[package]]
name = "designcraft-ui-egui"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"craft-ui",
"designcraft-color",
@@ -1275,7 +1289,7 @@ dependencies = [
[[package]]
name = "designcraft-web"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"designcraft-engine",
"designcraft-format",
@@ -5665,7 +5679,7 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winit"
version = "0.30.13"
source = "git+https://github.com/skylartaylor/winit.git?rev=3d51077fd1a966f84df9ef287839dc84fd5b4ca7#3d51077fd1a966f84df9ef287839dc84fd5b4ca7"
source = "git+https://github.com/storytold/winit.git?rev=3d51077fd1a966f84df9ef287839dc84fd5b4ca7#3d51077fd1a966f84df9ef287839dc84fd5b4ca7"
dependencies = [
"ahash",
"android-activity",
@@ -5839,7 +5853,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"serde_json",
]
+14 -8
View File
@@ -4,14 +4,14 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"]
[workspace.package]
version = "0.9.0"
version = "0.12.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
repository = "https://github.com/storytold/designcraft"
[workspace.dependencies]
# Shared writer; the immutable revision must be published before this consumer.
# Shared reader and writer; publish the immutable revision before this consumer.
craft-pdf = { git = "https://github.com/aiongg/craft-libs", rev = "d30598dcb4b389561c485b97d4a97a0c86bc36e4", version = "=0.1.0", default-features = false }
# Separations, ink coverage and the ink manager; same revision as craft-pdf and the krilla patch.
craft-prepress = { git = "https://github.com/aiongg/craft-libs", rev = "d30598dcb4b389561c485b97d4a97a0c86bc36e4", version = "=0.1.0", features = ["flatten"] }
@@ -41,6 +41,8 @@ thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
proptest = "1"
rayon = "1"
unicode-bidi = "0.3"
@@ -68,7 +70,6 @@ krilla-svg = "=0.8.1"
flo_curves = "0.8"
# QR codes (Object › Generate QR Code); MIT.
qrcodegen = "1.8"
hayro = "0.7"
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
@@ -104,11 +105,16 @@ todo = "warn"
too_many_arguments = "allow"
type_complexity = "allow"
# Line tables keep file and line in panics and backtraces; full debug info
# for every test binary and dependency would multiply the target dir size.
# The test profile inherits both settings.
[profile.dev.package."*"]
opt-level = 2
debug = false
[profile.dev]
opt-level = 1
debug = "line-tables-only"
[profile.release]
opt-level = 3
@@ -118,11 +124,11 @@ debug = 0
# Opt-in cancelable macOS termination on the pinned winit 0.30 line.
[patch.crates-io]
winit = { git = "https://github.com/skylartaylor/winit.git", rev = "3d51077fd1a966f84df9ef287839dc84fd5b4ca7" }
krilla = { git = "https://github.com/aiongg/craft-libs", rev = "d30598dcb4b389561c485b97d4a97a0c86bc36e4", version = "=0.8.2" }
winit = { git = "https://github.com/storytold/winit.git", rev = "3d51077fd1a966f84df9ef287839dc84fd5b4ca7" }
# The hayro crates from craft-libs' vendor/ directory (pdfcraft's patches plus craft-libs'; see its
# vendor/README.craft-reader.md), at the craft-pdf revision.
hayro = { git = "https://github.com/aiongg/craft-libs", rev = "d30598dcb4b389561c485b97d4a97a0c86bc36e4" }
hayro-interpret = { git = "https://github.com/aiongg/craft-libs", rev = "d30598dcb4b389561c485b97d4a97a0c86bc36e4" }
hayro-syntax = { git = "https://github.com/aiongg/craft-libs", rev = "d30598dcb4b389561c485b97d4a97a0c86bc36e4" }
hayro-jbig2 = { git = "https://github.com/aiongg/craft-libs", rev = "d30598dcb4b389561c485b97d4a97a0c86bc36e4" }
hayro = { git = "https://github.com/aiongg/craft-libs", rev = "d30598dcb4b389561c485b97d4a97a0c86bc36e4", version = "=0.7.1" }
hayro-interpret = { git = "https://github.com/aiongg/craft-libs", rev = "d30598dcb4b389561c485b97d4a97a0c86bc36e4", version = "=0.7.0" }
hayro-syntax = { git = "https://github.com/aiongg/craft-libs", rev = "d30598dcb4b389561c485b97d4a97a0c86bc36e4", version = "=0.7.2" }
hayro-jbig2 = { git = "https://github.com/aiongg/craft-libs", rev = "d30598dcb4b389561c485b97d4a97a0c86bc36e4", version = "=0.3.0" }
+26 -3
View File
@@ -81,12 +81,35 @@ list (author, source, license) is in ASSETS.md.
packages as OFL-<family>.txt. Builds without CRAFT_FONTS_DIR do not include them.
- All UI icons are drawn in code (crates/ui-egui/src/icons.rs) and are original work, MIT OR Apache-2.0.
- hayro PDF crates (placed PDFs): hayro, hayro-interpret, hayro-syntax and hayro-jbig2
(Apache-2.0 OR MIT) are pdfcraft's patched copies, fetched by git from its vendor/ directory at
the commit that Cargo.toml pins (https://github.com/storytold/pdfcraft, MIT OR Apache-2.0).
(Apache-2.0 OR MIT) are fetched from the pinned craft-libs source documented below.
hayro-interpret embeds PDFium's Foxit standard-14 fonts under PDFium's BSD-style license
(vendor/hayro-interpret/assets/LICENSE_FOXIT) and a CMYK profile under CC0-1.0.
Rust dependencies (vello_cpu, kurbo, skrifa, harfrust, image, egui, eframe, …) are permissively
licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io.
licensed (MIT/Apache-2.0/BSD/ISC/Zlib), fetched through crates.io or the pinned source below.
No Adobe icons, images or artwork are included.
PDF backend source
The patched hayro 0.7.1, hayro-interpret 0.7.0, hayro-syntax 0.7.2 and hayro-jbig2 0.3.0
are fetched from https://github.com/storytold/craft-libs at revision
89dcbb3060377a26c99a6d80caa16084366704c4. The four source snapshots were transferred
from https://github.com/storytold/pdfcraft at revision
7437bde378f43856eada40af25b983812e615e8a, then patched in craft-libs. Their upstream project is
https://github.com/LaurenzV/hayro, licensed MIT OR Apache-2.0. The snapshot retains each
crate's upstream licence files. The patch ledger is at
https://github.com/storytold/craft-libs/blob/89dcbb3060377a26c99a6d80caa16084366704c4/vendor/README.craft-reader.md.
Per-crate vendor/SOURCE.json files record transferred and local source hashes; asset
origins and unchanged per-file hashes are recorded separately in ASSETS.md.
Assets already bundled through Hayro's default features retain their licences:
- Foxit standard-14 substitutes: Copyright 2014 PDFium Authors. All rights reserved.
Original code copyright 2014 Foxit Software Inc. BSD-3-Clause; see
licenses/pdf-backend/hayro-interpret/assets/LICENSE_FOXIT.
- CGATS001Compat-v2-micro.icc: CC0-1.0, Clinton Ingram; see
licenses/pdf-backend/hayro-interpret/assets/CGATS_LICENSE.txt.
- Predefined CMaps: Copyright 1990-2023 Adobe. All rights reserved. BSD-3-Clause,
non-visual character-mapping data; see licenses/pdf-backend/hayro-cmap/LICENSE.txt.
Adobe and contributor names do not endorse or promote this product.
Per-file hashes and sources are recorded in ASSETS.md.
+7
View File
@@ -8,6 +8,10 @@
</p>
<p align="center">
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.designcraft.png" width="128" height="128" alt="DesignCraft icon: an engraved cat in a polka-dot scarf on a green tile">
</p>
<h1 align="center">DesignCraft</h1>
<p align="center">
@@ -25,6 +29,9 @@
<img alt="Runs on macOS, Windows, Linux and the web" src="https://img.shields.io/badge/runs%20on-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20Web-7bb51c?style=flat-square">
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-4d7a0a?style=flat-square">
<img alt="Agent-drivable over MCP" src="https://img.shields.io/badge/agents-MCP-7bb51c?style=flat-square">
<br>
<a href="https://sonarcloud.io/project/overview?id=storytold_designcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/designcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
</p>
<p align="center">
+23 -1
View File
@@ -33,7 +33,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Index: page references (topic from the selection or up to 4 levels, sort keys; current page, to end of story, next n paragraphs, suppressed, See / See also), Generate / Update Index (section headings, nested or run-in, page ranges merged, Index Title / Section Head / Level 1–4 styles); IDML export/import as InDesign topics, page references and topic cross-references (verified in InDesign 2026); `index.*` commands.
- Text clipboard keeps formatting (character/paragraph formats, footnotes, cross-references, index markers, tables) and falls back to plain text when the system clipboard changed; Paste without Formatting (⇧⌘V); Change Case (UPPERCASE, lowercase, Title Case, Sentence case — formatting kept); Type › Insert Special Character / White Space / Break Character submenus.
- Anchored objects: items flow in the text inline (on the baseline, with Y offset; lines grow to fit) or above the line (left/center/right, space before/after); paste copied items into text to anchor them; Anchored Object Options and Release (back onto the page where shown); rendered, exported to PDF and to/from IDML (InDesign anchored object settings; verified in InDesign 2026); copy/paste and threading carry them; `anchored.*` commands.
- Ruler guides: drag out of the rulers (page guides, or spread guides on the pasteboard), drag to move, drop on a ruler to delete; Layout › Create Guides (rows/columns with gutters, fit to margins or page); Delete All Guides on Spread; `guide.*` commands.
- Ruler guides: drag out of the rulers (page guides, or spread guides on the pasteboard), drag to move, drop on a ruler to delete; a dragged guide snaps along its axis to other guides, margins, columns, bleed, page and object edges and centers and the document grid (Shift: the ruler's ticks; Cmd suspends snapping); selected like objects (click, Shift-click, a marquee that touches no object, Edit › Select All Guides ⌥⌘G), moved together, nudged, deleted, and placed exactly with the X or Y field; Layout › Create Guides (rows/columns with gutters, fit to margins or page); Delete All Guides on Spread; `guide.*` commands.
- Links panel: every placed graphic with status (OK / Modified / Missing / Embedded, checked against the file on disk), page and effective PPI (low resolution flagged); Relink…, Go To, Update, Embed; `links.*` commands.
- Missing fonts: highlighted pink on screen (never in output), listed by Preflight; Type › Find/Replace Font (fonts in the document, missing first; replace in text, cells, footnotes and styles); `font.list` / `font.replace`.
- Missing glyphs, as in InDesign: a character the applied font lacks is drawn as that font's missing-glyph box (its .notdef; a drawn box when the font's is empty), on screen and in PDF (as a path, so PDF/A and PDF/UA exports take it), and Preflight lists the characters by font. Preferences › Composition › Draw Missing Glyphs from Fallback Fonts (`document.preferences {glyphFallback}`, stored with the document) draws them from other fonts instead; it is off in new documents and IDML imports, on in documents saved before it existed. A missing font's substitute always uses fallback fonts.
@@ -351,6 +351,18 @@ Newest first.
- **2026-10-20** · PDF/X-1a:2003 export (craft-libs#194): `file.exportPdf {standard: "x1a"}`, the Export PDF dialog's PDF/X-1a:2003 preset and standard. The PDF/X-4 print file is flattened in its inks by craft-prepress (no transparency; spot, process, overprint and live text kept; complex regions as DeviceN images at the Transparency Flattener preset's resolution), then written as PDF 1.4 with the X-1a identification and checked; `validate-pdf --standard x1a`. Soft drop shadows reach the flattener in their swatch's inks; other soft effects are still RGB images. No readiness percentage change.
- **2026-10-20** · Print color (craft-libs#190): CMYK print PDFs blend in DeviceCMYK through craft-pdf's print settings instead of byte rewrites, with the CMYK working space as the output intent in PDF/X-4 only; overprint (Overprint Fill, Stroke and Gap, overprinting text, 100% [Black]) is written as `/op` `/OP` `/OPM 1`; Knockout Group writes `/K true` instead of becoming an image; File › Print prints the PDF Export PDF writes. No readiness percentage change.
- **2026-10-20** · Spot gradients print on their plates. A gradient stop keeps the swatch it was given (Gradient panel drop, `swatch.create {gradient}`, `object.gradient`, IDML `StopColor`), following its renames and edits. PDF export shades one spot with its tints or [Paper] as a Separation, and a spot with process inks or a second spot as DeviceN; a process gradient to [Paper] prints in DeviceCMYK. Documents with plain-color stops export as before. No readiness percentage change.
- **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change.
- **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change.
- **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change.
- **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change.
- **2026-10-22** · Paragraph rules: Rule Above's offset is measured from the first line's baseline to the rule's bottom edge, as in InDesign. Rules sat one ascent too high and crossed the previous paragraph's text (reported from an imported INDD document). No readiness percentage change.
- **2026-10-21** · Ruler guides are selected like objects (#1282): a click with the Selection tools selects a guide (also where it crosses an object), Shift-click adds or removes one, a new guide from the rulers is selected, a marquee that touches no object selects the guides it crosses, and Edit › Select All Guides (⌥⌘G, `guide.selectAll`) selects every unlocked guide on the spread. Selected guides draw thicker in their layer's color, drag together (all of them shown moving), nudge with the arrow keys and go with Delete, each as one undo step (`guide.select`, `guide.deselect`, `guide.moveBy`, `guide.delete {}`). With one guide selected the Control bar's X (vertical guide) or Y (horizontal guide) field and Properties › Transform show where it is, as the rulers measure, and typing a value moves it there. No readiness percentage change.
- **2026-10-16** · Selection: drag a column guide to move its gutter, as in InDesign (#1096). Both guides of the gutter move together and keep its width, stop at the neighbouring guides and the margins, and snap to the document grid, guides and objects, or with Shift to the ruler's tick marks; Escape cancels and the drag is one undo step. Locked column guides (the default) are not picked. A document page takes the result as its own columns; on a parent page it passes to the pages that follow it. `layout.moveColumnGuide` does the same over the control channel and MCP. No readiness percentage change.
- **2026-10-16** · Layout: autoflow, inserted pages and new parents follow dragged column guides (#1096). Autoflow sets one frame per dragged column and counts the pages it adds from them; Insert Pages and New Parent keep the custom positions with the column count and gutter; a page that moves to the other side of the spine (inserted, deleted or moved pages, Duplicate Spread, a new parent's other page) has them mirrored across its margin box, as InDesign does; `document.inspect` reports each page's gutter, column rects and whether its columns are custom. No readiness percentage change.
- **2026-10-16** · Layout: Margins and Columns keeps dragged column guides unless the column count or gutter changes (#1096). The dialog sends every field on OK, so a margin-only change re-divided the columns evenly. Margin changes keep the positions; the columns follow them only while they fit the margin box. No readiness percentage change.
- **2026-10-16** · Lock Guides and Lock Column Guides are commands in View ▸ Grids & Guides (`view.lockGuides`, ⌥⌘;, and `view.lockColumnGuides`), so the control channel and MCP reach them. Lock Column Guides is an undo step, is saved with the document, on in new ones, and read from and written to IDML's `ColumnGuideLocked`, which export always wrote as locked (#1096). No readiness percentage change.
- **2026-10-16** · IDML: a page keeps column guides that were dragged off centre (InDesign's Custom columns). They were divided evenly on import, so the gutter sprang back to the middle; the imported positions now place the columns, with the canvas guides, snapping and text placement following them, and they are written back on export. Layout › Margins and Columns re-divides them evenly when the count or gutter changes, as InDesign's does. No readiness percentage change.
- **2026-10-16** · IDML: custom column positions are checked before they are used (#1096). A set must give two edges per column, ascending from the left margin to the right one; any other (a wrong count, unsorted or non-numeric values, a list longer than 216 columns allow, or positions left behind by a margin or page-size change) is the even split, in the canvas, on import and on export, instead of columns drawn outside the margins. No readiness percentage change.
- **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change.
- **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change.
- **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change.
@@ -358,6 +370,14 @@ Newest first.
- **2026-10-17** · Object strokes reject malformed types and unknown type fields before changing the document; command help lists every stroke kind and its required fields. No readiness percentage change.
- **2026-10-17** · Docs: documented a persistent source CLI install with Cargo, MCP-client PATH setup and bare-name server registration in `docs/mcp.md`. No readiness percentage change.
- **2026-10-16** · Selection: drag a column guide to move its gutter, as in InDesign (#1096). Both guides of the gutter move together and keep its width, stop at the neighbouring guides and the margins, and snap to the document grid, guides and objects, or with Shift to the ruler's tick marks; Escape cancels and the drag is one undo step. Locked column guides (the default) are not picked. A document page takes the result as its own columns; on a parent page it passes to the pages that follow it. `layout.moveColumnGuide` does the same over the control channel and MCP. No readiness percentage change.
- **2026-10-16** · Layout: Margins and Columns keeps dragged column guides unless the column count or gutter changes (#1096). The dialog sends every field on OK, so a margin-only change re-divided the columns evenly. Margin changes keep the positions; the columns follow them only while they fit the margin box. No readiness percentage change.
- **2026-10-16** · Layout: autoflow, inserted pages and new parents follow dragged column guides (#1096). Autoflow sets one frame per dragged column and counts the pages it adds from them; Insert Pages and New Parent keep the custom positions with the column count and gutter; `document.inspect` reports each page's gutter, column rects and whether its columns are custom. No readiness percentage change.
- **2026-10-16** · Layout: autoflow, inserted pages and new parents follow dragged column guides (#1096). Autoflow sets one frame per dragged column and counts the pages it adds from them; Insert Pages and New Parent keep the custom positions with the column count and gutter; a page that moves to the other side of the spine (inserted, deleted or moved pages, Duplicate Spread, a new parent's other page) has them mirrored across its margin box, as InDesign does; `document.inspect` reports each page's gutter, column rects and whether its columns are custom. No readiness percentage change.
- **2026-10-16** · IDML: custom column positions are checked before they are used (#1096). A set must give two edges per column, ascending from the left margin to the right one; any other (a wrong count, unsorted or non-numeric values, a list longer than 216 columns allow, or positions left behind by a margin or page-size change) is the even split, in the canvas, on import and on export, instead of columns drawn outside the margins. No readiness percentage change.
- **2026-10-16** · IDML: a page keeps column guides that were dragged off centre (InDesign's Custom columns). They were divided evenly on import, so the gutter sprang back to the middle; the imported positions now place the columns, with the canvas guides, snapping and text placement following them, and they are written back on export. Layout › Margins and Columns re-divides them evenly when the count or gutter changes, as InDesign's does. No readiness percentage change.
- **2026-10-16** · Lock Guides and Lock Column Guides are commands in View ▸ Grids & Guides (`view.lockGuides`, ⌥⌘;, and `view.lockColumnGuides`), so the control channel and MCP reach them. Lock Column Guides is saved with the document, on in new ones, and read from and written to IDML's `ColumnGuideLocked`, which export always wrote as locked (#1096). No readiness percentage change.
- **2026-10-16** · Lock Guides and Lock Column Guides are commands in View ▸ Grids & Guides (`view.lockGuides`, ⌥⌘;, and `view.lockColumnGuides`), so the control channel and MCP reach them. Lock Column Guides is an undo step, is saved with the document, on in new ones, and read from and written to IDML's `ColumnGuideLocked`, which export always wrote as locked (#1096). No readiness percentage change.
- **2026-10-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change.
- **2026-10-16** · Greek text hyphenates. The patterns were never compiled in, so Greek set unhyphenated however narrow the column; `"Greek"`, `"Greek: Greece"`, `el` and `el-GR` all resolve now and ανθρωπος breaks αν-θρω-πος. They are the monotonic patterns (hyph-el-monoton, Dimitrios Filippou, under MIT), which is what polytonic text hyphenates by too. Five of InDesign's ~40 hyphenation languages. No readiness percentage change.
- **2026-10-16** · A swatch applied to a placed greyscale or bitmap image colours it, as in InDesign: IDML's `<Image FillColor>` is read and written, greyscale pixels become a tint of the swatch on opaque white and a bitmap's white lets the frame show through, on screen and in PDF (spot swatches print on their plate). Colour images keep their colours. Applying the swatch from the Swatches panel comes next. No readiness percentage change.
@@ -641,3 +661,5 @@ Newest first.
The shared craft-installer UI preserves this revision's install options and provides persistent installation, repair and Windows Settings removal completion screens. Silent modes stay unattended. Earlier Windows acceptance is recorded in packaging/windows/README.md; normal CI and production-profile upgrade qualification against pinned current releases are in progress. No release or PR has been published.
- **2026-10-15** · Windows recovery folders validate the identity of the live locked file without reading it through a second handle. Missing or replaced locks disable writes and cleanup; regression tests cover retained exclusive locking and replacement-file protection. No readiness percentage change.
- PDF reader infrastructure: pinned shared parsing/rasterization, transformed page-box geometry and explicit failures for known incomplete layered-PDF exports. Application ownership and parity targets are unchanged.
+1
View File
@@ -0,0 +1 @@
mod cli;
+2
View File
@@ -395,6 +395,8 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
let ctx = ctx.clone();
let dialog = if request.purpose == "swatches" {
rfd::AsyncFileDialog::new().add_filter("Swatch Exchange (ASE)", &["ase"])
} else if request.purpose == "shortcuts" {
rfd::AsyncFileDialog::new().add_filter("Shortcut set (text)", &["txt"])
} else if request.purpose == "place" {
rfd::AsyncFileDialog::new().add_filter("Graphics", IMAGE_EXTS)
} else {
+22 -19
View File
@@ -90,27 +90,16 @@ fn matches(name: &str, target: &str) -> bool {
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
/// A clock far past chrono's year range still converts (the calendar repeats every 400 years), so
/// logging never panics and never silently resets to the epoch for a convertible instant.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = since.as_secs();
let (days, rem) = (secs / 86_400, secs % 86_400);
let (y, m, d) = civil_from_days(days);
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
}
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch).
fn civil_from_days(days: u64) -> (u64, u64, u64) {
let z = days + 719_468;
let era = z / 146_097;
let doe = z % 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + u64::from(m <= 2);
(y, m, d)
// Pre-1970 is clamped to the epoch by `unwrap_or_default` above. `as_secs` is `u64`; a value
// past `i64::MAX` seconds saturates rather than falling back to the epoch.
let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX);
let (y, t) = designcraft_engine::doc::time::utc_parts(secs, since.subsec_nanos());
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ"))
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
@@ -317,6 +306,20 @@ mod tests {
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_704_067_199_999)), "2023-12-31T23:59:59.999Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
// Past chrono's year range still converts via the 400-year cycle (the old code did too,
// through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows'
// SystemTime can't hold this instant, so it is only checked where it exists.
// `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output).
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
}
// Years past 9999 keep the plain zero-padded year (no `+`).
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
// 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857.
// Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
}
}
#[test]
+1
View File
@@ -187,6 +187,7 @@ fn open_filters(purpose: &str) -> &'static [OpenFilter] {
match purpose {
"swatches" => &[OpenFilter { name: "Swatch Exchange (ASE)", extensions: &["ase"] }],
"script" => &[OpenFilter { name: "Script", extensions: &["dcscript", "txt", "json"] }],
"shortcuts" => &[OpenFilter { name: "Shortcut set (text)", extensions: &["txt"] }],
"icc" => &[OpenFilter { name: "ICC profile", extensions: &["icc", "icm"] }],
"book" => &[OpenFilter { name: "Book", extensions: &["dcbook"] }],
"xml" => &[OpenFilter { name: "XML", extensions: &["xml"] }],
+49 -15
View File
@@ -27,6 +27,9 @@ pub struct NativeMenu {
shown_for: (String, bool),
/// File › Open Recent, rebuilt when the list of recent files changes.
recent: Option<RecentMenu>,
/// The commands with key equivalents and the shortcut each was given: the menu is rebuilt
/// when one changes, so a shortcut moved to another command leaves its old item.
keys: Vec<(String, Option<String>)>,
}
struct RecentMenu {
@@ -50,21 +53,28 @@ fn menu_title(app: &DesignApp, label: &str) -> String {
menus::native_label(designcraft_ui_egui::i18n::tr(&app.ui.language, label))
}
/// A command's default shortcut, as the in-window menus show it.
fn shortcut(id: &str) -> Option<&'static str> {
menus::ui_label(id).and_then(|(_, sc)| sc).or_else(|| designcraft_engine::find_command(id).and_then(|c| c.shortcut))
/// A command's shortcut in effect (the user's, else the default), as the in-window menus show it.
fn shortcut(app: &DesignApp, id: &str) -> Option<String> {
menus::shortcut_of(app, id)
}
impl NativeMenu {
pub fn install(app: &mut DesignApp, ctx: &egui::Context) -> Self {
// A click in the menu bar reaches the app with no input over the window, so nothing would
// draw the frame that runs the command until the pointer or a key comes back: wake egui.
// muda keeps the first handler for the life of the process, so this channel serves every
// menu bar built later (`build_bar`).
let (tx, events) = channel();
let repaint = ctx.clone();
MenuEvent::set_event_handler(Some(move |e: MenuEvent| {
let _ = tx.send(e);
repaint.request_repaint();
}));
Self::build_bar(app, events)
}
/// The menu bar for the current language and settings, fed by `events`.
fn build_bar(app: &mut DesignApp, events: Receiver<MenuEvent>) -> Self {
let menu = Menu::new();
let mut items = HashMap::new();
let mut counter = 0usize;
@@ -75,12 +85,24 @@ impl NativeMenu {
for entry in menus::MAC_APP_MENU {
let _ = match *entry {
AppMenuItem::Cmd { id, label, command } => {
let sc = shortcut(command).and_then(accel);
let sc = shortcut(app, command).as_deref().and_then(accel);
let item = MenuItem::with_id(id, menu_title(app, label), true, sc);
let appended = app_menu.append(&item);
items.insert(id.into(), (command.into(), Value::Null, Handle::Plain(item)));
appended
}
AppMenuItem::Language => {
let sub = Submenu::new(menu_title(app, "Language"), true);
for (code, name) in designcraft_ui_egui::i18n::LANGUAGES {
let id = format!("dc-language-{}", if code.is_empty() { "en" } else { code });
let params = serde_json::json!({ "lang": code });
let checked = menus::checked(app, "app.language", &params).unwrap_or(false);
let item = CheckMenuItem::with_id(id.clone(), menus::native_label(name), true, checked, None);
let _ = sub.append(&item);
items.insert(id, ("app.language".into(), params, Handle::Check(item)));
}
app_menu.append(&sub)
}
AppMenuItem::Sep => app_menu.append(&PredefinedMenuItem::separator()),
AppMenuItem::Services => app_menu.append(&PredefinedMenuItem::services(None)),
AppMenuItem::Hide => app_menu.append(&PredefinedMenuItem::hide(None)),
@@ -96,15 +118,19 @@ impl NativeMenu {
}
menu.init_for_nsapp();
app.native_menu = true;
app.native_shortcuts = items
.values()
.filter_map(|(id, p, _)| {
let sc = shortcut(id)?;
(p.is_null() && accel(sc).is_some()).then(|| id.clone())
})
.collect();
let keys: Vec<(String, Option<String>)> =
items.values().filter(|(_, p, _)| p.is_null()).map(|(id, _, _)| (id.clone(), shortcut(app, id))).collect();
app.native_shortcuts = keys.iter().filter(|(_, sc)| sc.as_deref().and_then(accel).is_some()).map(|(id, _)| id.clone()).collect();
let recent = recent.map(|submenu| RecentMenu { submenu, shown: Vec::new(), ids: Vec::new(), separator: None });
Self { _menu: menu, items, events, last_refresh: 0.0, shown_for: (app.ui.language.clone(), designcraft_ui_egui::cjk_features(app)), recent }
Self {
_menu: menu,
items,
events,
last_refresh: 0.0,
shown_for: (app.ui.language.clone(), designcraft_ui_egui::cjk_features(app)),
recent,
keys,
}
}
/// Put the current recent files at the top of File › Open Recent when they changed.
@@ -153,13 +179,21 @@ impl NativeMenu {
// back by Cancel): the system menu bar follows at once, like the panels (the new menu
// replaces the old one as the application's menu before that one is dropped).
if app.ui.language != self.shown_for.0 || designcraft_ui_egui::cjk_features(app) != self.shown_for.1 {
*self = Self::install(app, ctx);
let events = std::mem::replace(&mut self.events, channel().1);
*self = Self::build_bar(app, events);
}
let now = designcraft_ui_egui::now_ms();
if now - self.last_refresh < 250.0 {
return;
}
self.last_refresh = now;
// A shortcut set in the Keyboard Shortcuts dialog: macOS gives key equivalents to the
// menu bar before the window sees the key, so the bar must carry the new keys.
if self.keys.iter().any(|(id, sc)| shortcut(app, id) != *sc) {
let events = std::mem::replace(&mut self.events, channel().1);
*self = Self::build_bar(app, events);
return;
}
self.refresh_recent(app);
for (cmd, params, h) in self.items.values() {
let en = menus::native_menu_enabled(app, ctx, cmd);
@@ -199,10 +233,10 @@ fn build(
}
// Hidden CJK commands; `NativeMenu::poll` rebuilds the bar when Show CJK Features changes.
Item::Cmd { id, .. } if !menus::shown(app, id) => {}
Item::Cmd { label, id, params, shortcut } => {
Item::Cmd { label, id, params, .. } => {
*counter += 1;
let mid = format!("dc{counter}");
let sc = if params.is_null() { shortcut.and_then(accel) } else { None };
let sc = if params.is_null() { shortcut(app, id).as_deref().and_then(accel) } else { None };
let label = menu_title(app, label);
let handle = if menus::checked(app, id, params).is_some() {
let c = CheckMenuItem::with_id(mid.clone(), label, true, false, sc);
+3 -2
View File
@@ -1860,11 +1860,12 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
cur.band = Band { para: pi + 1, line0: ft.lines.len(), above: Some(above), tail: None };
}
// Rules and shading for the paragraph.
if let Some((fi, bl, asc, _)) = first_line_rect {
if let Some((fi, bl, _, _)) = first_line_rect {
let ft = &mut out.frames[fi];
if pp.rule_above.on {
let r = &pp.rule_above;
let y = bl - asc - r.offset;
// The offset runs from the first line's baseline up to the rule's bottom edge.
let y = bl - r.offset;
let col = ft
.lines
.iter()
+20
View File
@@ -7290,3 +7290,23 @@ fn greek_text_gets_greek_hyphenation() {
let el = hyphen::Lang::for_language("Greek").unwrap();
assert_eq!(hyphen::hyphen_points_in("ανθρωπος", &hyphen::Limits::default(), el), vec![2, 5]);
}
/// Rule Above's offset runs from the first line's baseline up to the rule's bottom edge, as in
/// InDesign: a rule set clear of the cap height sits between the rows, not across the row above
/// (an INDD project list whose rules crossed the previous paragraph's text).
#[test]
fn rule_above_offset_is_measured_from_the_baseline() {
use designcraft_doc::Rule;
let rule = Rule { on: true, weight: 1.5, offset: 10.0, ..Default::default() };
let para = ParaAttrs { rule_above: Some(rule), ..Default::default() };
let (d, sid, fid) = doc_with("Row one\nRow two", Rect::new(0.0, 0.0, 300.0, 200.0), para);
let cs = compose_story(&d, sid, &ComposeOptions::default());
let lines = all_lines(&cs);
let (first, second) = (lines[0], lines[1]);
let mut decos: Vec<Rect> = cs.frame(fid).unwrap().decos.iter().map(|dc| dc.rect).collect();
decos.sort_by(|a, b| a.y0.total_cmp(&b.y0));
let rule = decos[1];
assert!((rule.y1 - (second.baseline - 10.0)).abs() < 1e-6, "{rule:?}, baseline {}", second.baseline);
assert!((rule.height() - 1.5).abs() < 1e-6);
assert!(rule.y0 > first.baseline, "the rule stays below the row above: {rule:?}, its baseline {}", first.baseline);
}
+1
View File
@@ -14,6 +14,7 @@ designcraft-color = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
chrono = { workspace = true }
[dev-dependencies]
proptest = { workspace = true }
+81 -2
View File
@@ -343,6 +343,8 @@ impl Document {
let mut pages: Vec<(Page, Vec<Arc<Item>>)> = Vec::new();
let mut parity: Vec<bool> = Vec::new();
let mut kept: Vec<(usize, Arc<Spread>)> = Vec::new();
// Whether each shuffled page was a left page, to mirror its dragged columns if it moves.
let mut was_left: BTreeMap<PageId, bool> = BTreeMap::new();
let mut abs = 0;
for sp in self.spreads.drain(..) {
if !sp.allow_shuffle && sp.pages.len() > 1 {
@@ -350,8 +352,9 @@ impl Document {
kept.push((pages.len(), sp));
continue;
}
for _ in &sp.pages {
for p in &sp.pages {
parity.push(odd.get(abs).copied().unwrap_or(abs % 2 == 0));
was_left.insert(p.id, p.side == PageSide::Left);
abs += 1;
}
let mut sp = Arc::unwrap_or_clone(sp);
@@ -445,6 +448,19 @@ impl Document {
out.insert(at, sp);
}
self.spreads = out;
// A page that crosses the spine keeps its inside and outside margins, so its margin box
// mirrors; its dragged column guides mirror with it, as in InDesign.
let crossed = |p: &Page| was_left.get(&p.id).is_some_and(|&l| l != (p.side == PageSide::Left)) && p.columns.positions.is_some();
for sp in &mut self.spreads {
if !sp.pages.iter().any(crossed) {
continue;
}
for p in &mut Arc::make_mut(sp).pages {
if crossed(&*p) {
p.columns = p.columns.mirrored(p.margin_rect().width());
}
}
}
// Pages can change sides, and so parent pages.
self.sync_parent_margins();
}
@@ -466,6 +482,10 @@ impl Document {
if let Some(t) = &template {
p.width = t.width;
p.height = t.height;
// Dragged column guides too: the same width and margins keep them valid.
// They are taken as for this single page; repaginating mirrors them if the
// page lands on a left page.
p.columns.positions = p.columns_from(t).positions;
}
ids.push(p.id);
p
@@ -675,7 +695,7 @@ impl Document {
if page.local_margins {
continue;
}
let Some(src) = self.parent_page(page).filter(|q| !q.same_margins(page)).map(|q| (q.margins, q.columns.clone())) else {
let Some(src) = self.parent_page(page).filter(|q| !q.same_margins(page)).map(|q| (q.margins, page.columns_from(q))) else {
continue;
};
if let Some(page) = Arc::make_mut(&mut self.spreads[si]).pages.get_mut(pi) {
@@ -879,6 +899,65 @@ mod tests {
assert!(d.delete_pages(&[0, 1]).is_err());
}
/// Pages inserted after a page with dragged column guides keep them, as they keep its column
/// count and gutter; pages that follow a parent take the parent's.
#[test]
fn inserted_pages_keep_dragged_columns() {
let dragged = Columns { count: 2, gutter: 12.0, positions: Some(vec![0.0, 100.0, 130.0, 540.0]) };
let widths = |p: &Page| p.column_rects().iter().map(|r| r.width()).collect::<Vec<_>>();
let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, ..Default::default() });
let pg = &mut Arc::make_mut(&mut d.spreads[0]).pages[0];
(pg.columns, pg.local_margins) = (dragged.clone(), true);
d.insert_pages(Some(0), 2, None).unwrap();
for i in 1..3 {
let p = d.page(i).unwrap();
assert_eq!(p.columns, dragged, "page {i}");
assert_eq!(widths(p), vec![100.0, 410.0], "page {i}");
}
let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, ..Default::default() });
Arc::make_mut(&mut d.parents[0]).pages[0].columns = dragged.clone();
d.sync_parent_margins();
d.insert_pages(Some(0), 1, d.parents.first().map(|p| p.id)).unwrap();
assert_eq!(d.page(1).map(widths), Some(vec![100.0, 410.0]));
}
/// A right page whose gutter was dragged toward the spine keeps it there when inserting a
/// page before it makes it a left page: its dragged columns mirror across the margin box.
#[test]
fn dragged_columns_mirror_when_a_page_changes_side() {
let mut d = Document::new(&NewDocument { pages: 1, facing_pages: true, ..Default::default() });
let pg = &mut Arc::make_mut(&mut d.spreads[0]).pages[0];
assert_eq!(pg.side, PageSide::Right);
let w = pg.margin_rect().width();
(pg.columns, pg.local_margins) = (Columns { count: 2, gutter: 12.0, positions: Some(vec![0.0, 100.0, 112.0, w]) }, true);
let id = pg.id;
// How far the gutter's spine-side edge is from the margin on the spine side.
let from_spine = |p: &Page| {
let (m, c) = (p.margin_rect(), p.column_rects());
match p.side {
PageSide::Left => m.x1 - c[1].x0,
_ => c[0].x1 - m.x0,
}
};
assert!((from_spine(d.page(0).unwrap()) - 100.0).abs() < 1e-9);
d.insert_pages(None, 1, None).unwrap();
let moved = d.page(1).unwrap();
assert_eq!((moved.id, moved.side), (id, PageSide::Left));
assert!((from_spine(moved) - 100.0).abs() < 1e-9, "{:?}", moved.column_rects());
let widths: Vec<f64> = moved.column_rects().iter().map(|r| r.width()).collect();
assert!((widths[0] - (w - 112.0)).abs() < 1e-9 && (widths[1] - 100.0).abs() < 1e-9, "{widths:?}");
// The inserted page is a right page like the one it copies: no mirroring.
assert_eq!(d.page(0).unwrap().columns.positions, Some(vec![0.0, 100.0, 112.0, w]));
// Moving it back to the right undoes the mirror.
d.delete_pages(&[0]).unwrap();
let back = d.page(0).unwrap();
assert_eq!(back.side, PageSide::Right);
assert!((from_spine(back) - 100.0).abs() < 1e-9, "{:?}", back.column_rects());
}
#[test]
fn items_travel_with_pages() {
let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() });
+10
View File
@@ -35,6 +35,7 @@ pub mod story;
pub mod styles;
mod swatch_refs;
pub mod table;
pub mod time;
pub mod vars;
pub mod xref;
@@ -398,11 +399,19 @@ pub struct DocSettings {
/// Lock Zero Point: dragging from the ruler corner leaves it where it is.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub zero_point_locked: bool,
/// View › Grids & Guides › Lock Column Guides (IDML `ColumnGuideLocked`): column guides can't
/// be dragged. On in new documents, as in InDesign.
#[serde(skip_serializing_if = "is_true")]
pub column_guides_locked: bool,
/// The fill and stroke new shapes are drawn with: what the Tools panel's proxy shows and sets
/// with nothing selected (InDesign's document defaults).
pub new_object: NewObjectColors,
}
fn is_true(b: &bool) -> bool {
*b
}
fn is_origin(p: &[f64; 2]) -> bool {
p[0] == 0.0 && p[1] == 0.0
}
@@ -460,6 +469,7 @@ impl Default for DocSettings {
ruler_origin: RulerOrigin::Spread,
zero_point: [0.0, 0.0],
zero_point_locked: false,
column_guides_locked: true,
new_object: NewObjectColors::default(),
}
}
+214 -5
View File
@@ -42,6 +42,86 @@ impl Default for Columns {
}
}
/// The most columns a page can have, as in InDesign.
pub const MAX_COLUMNS: u32 = 216;
/// How far, in points, the first and last custom column positions may sit from the margin box's
/// edges: room for float noise in files and page arithmetic, far less than any dragged guide.
pub const COLUMN_POSITION_TOLERANCE: f64 = 0.01;
/// Whether `positions` are usable custom column positions for `count` columns across a margin box
/// `margin_width` wide, in IDML's `ColumnsPositions` form: each column's left and right edge as an
/// offset from the margin box's left edge, so `2 × count` finite numbers, ascending across the
/// whole list, the first at 0 and the last at `margin_width` (within
/// [`COLUMN_POSITION_TOLERANCE`]).
pub fn valid_column_positions(positions: &[f64], count: u32, margin_width: f64) -> bool {
(1..=MAX_COLUMNS).contains(&count)
&& positions.len() == 2 * count as usize
&& margin_width.is_finite()
&& positions.iter().all(|v| v.is_finite())
&& positions.windows(2).all(|w| matches!(w, [a, b] if a <= b))
&& positions.first().is_some_and(|v| v.abs() <= COLUMN_POSITION_TOLERANCE)
&& positions.last().is_some_and(|v| (v - margin_width).abs() <= COLUMN_POSITION_TOLERANCE)
}
impl Columns {
/// The custom positions when they are valid for a margin box `margin_width` wide (see
/// [`valid_column_positions`]). Anything else means the columns are evenly spaced: a stored set
/// that no longer fits (a page resized after the drag, a hostile file) is not drawn.
pub fn valid_positions(&self, margin_width: f64) -> Option<&[f64]> {
let positions = self.positions.as_deref()?;
valid_column_positions(positions, self.count, margin_width).then_some(positions)
}
/// Every column's left and right edge as offsets from the margin box's left edge: the valid
/// custom positions, else the even split. This is what IDML writes as `ColumnsPositions`.
pub fn edges(&self, margin_width: f64) -> Vec<f64> {
let w = if margin_width.is_finite() { margin_width.max(0.0) } else { 0.0 };
match self.valid_positions(w) {
Some(p) => p.iter().map(|&v| inside(v, w)).collect(),
None => column_rects(Rect::new(0.0, 0.0, w, 0.0), self.count.max(1), self.gutter).iter().flat_map(|r| [r.x0, r.x1]).collect(),
}
}
/// The column edges (as [`Columns::edges`]) with gutter `gutter` (0 is the one between the first
/// two columns) moved so its left edge sits at `new_left`, an offset from the margin box's left
/// edge. Both of the gutter's guides move, so it keeps its width, and it stops at its
/// neighbouring gutters or the margins: no column gets a negative width. `None` when there is
/// no such gutter, `new_left` is not finite, or the result would not be valid.
pub fn with_gutter_moved(&self, margin_width: f64, gutter: usize, new_left: f64) -> Option<Vec<f64>> {
if !new_left.is_finite() {
return None;
}
let mut pos = self.edges(margin_width);
let i = gutter.checked_mul(2)?.checked_add(1)?;
let &[lo, left, right, hi] = pos.get(i - 1..i.checked_add(3)?)? else {
return None;
};
let width = right - left;
let at = new_left.min(hi - width).max(lo);
let [l, r] = pos.get_mut(i..i + 2)? else {
return None;
};
*l = at;
*r = (at + width).min(hi);
valid_column_positions(&pos, self.count, margin_width).then_some(pos)
}
/// These columns reflected across a margin box `margin_width` wide, as on the facing page
/// across the spine: each custom position `p` becomes `margin_width - p`, in reverse order so
/// they stay ascending. Count and gutter stay; positions that are not valid (see
/// [`Columns::valid_positions`]) become `None`, the even split, which mirrors onto itself.
pub fn mirrored(&self, margin_width: f64) -> Columns {
let positions = self.valid_positions(margin_width).map(|p| p.iter().rev().map(|&v| inside(margin_width - v, margin_width)).collect());
Columns { count: self.count, gutter: self.gutter, positions }
}
}
/// `v` limited to `0..=w` without `clamp`, which panics when `w` is NaN.
fn inside(v: f64, w: f64) -> f64 {
v.max(0.0).min(w)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Orientation {
@@ -233,9 +313,10 @@ impl Page {
pub fn bounds(&self) -> Rect {
Rect::new(self.x, 0.0, self.x + self.width, self.height)
}
/// Same margins and columns as `other`.
/// Same margins and columns as `other`, with `other`'s columns as it would take them from this
/// page: mirrored when one is a left page and the other isn't.
pub fn same_margins(&self, other: &Page) -> bool {
self.margins == other.margins && self.columns == other.columns
self.margins == other.margins && other.columns_from(self) == other.columns
}
/// Left and right margins resolved for the page side (inside is at the spine).
pub fn left_right_margins(&self) -> (f64, f64) {
@@ -244,23 +325,39 @@ impl Page {
_ => (self.margins.inside, self.margins.outside),
}
}
/// `src`'s columns as they fall on this page: the same when both pages put their inside
/// margin on the same physical side, mirrored across `src`'s margin box when one is a left
/// page and the other is not, as InDesign mirrors dragged column guides across the spine.
pub fn columns_from(&self, src: &Page) -> Columns {
if (self.side == PageSide::Left) == (src.side == PageSide::Left) {
src.columns.clone()
} else {
src.columns.mirrored(src.margin_rect().width())
}
}
/// The live area inside the margins.
pub fn margin_rect(&self) -> Rect {
let (l, r) = self.left_right_margins();
let b = self.bounds();
Rect::new(b.x0 + l, b.y0 + self.margins.top, (b.x1 - r).max(b.x0 + l), (b.y1 - self.margins.bottom).max(b.y0 + self.margins.top))
}
/// Column rects within the margins.
/// Column rects within the margins: the custom positions when the column guides have been
/// dragged and still fit the margin box (see [`valid_column_positions`]), otherwise an even
/// split.
pub fn column_rects(&self) -> Vec<Rect> {
let m = self.margin_rect();
column_rects(m, self.columns.count.max(1), self.columns.gutter)
let w = m.width();
match self.columns.valid_positions(w) {
Some(p) => p.as_chunks::<2>().0.iter().map(|&[a, b]| Rect::new(m.x0 + inside(a, w), m.y0, m.x0 + inside(b, w), m.y1)).collect(),
None => column_rects(m, self.columns.count.max(1), self.columns.gutter),
}
}
}
/// Split `area` into `n` equal columns separated by `gutter`.
pub fn column_rects(area: Rect, n: u32, gutter: f64) -> Vec<Rect> {
// The public allocator also receives native/imported options, outside command validation.
let n = n.clamp(1, 216) as usize;
let n = n.clamp(1, MAX_COLUMNS) as usize;
let w = ((area.width() - gutter * (n as f64 - 1.0)) / n as f64).max(0.0);
(0..n).map(|i| Rect::new(area.x0 + i as f64 * (w + gutter), area.y0, area.x0 + i as f64 * (w + gutter) + w, area.y1)).collect()
}
@@ -450,6 +547,118 @@ mod tests {
assert!((cols[1].x0 - cols[0].x1 - 12.0).abs() < 1e-9);
}
/// Dragged column guides place the columns; a set that cannot mean anything falls back to the
/// even split rather than producing nothing or a column outside the margins (#1096).
#[test]
fn custom_column_positions_place_the_columns() {
let mut p = page(PageSide::Single, 0.0);
let m = p.margin_rect();
let w = m.width();
let even = p.column_rects();
p.columns.positions = Some(vec![0.0, 100.0, 130.0, w]);
assert_eq!(p.column_rects(), vec![Rect::new(m.x0, m.y0, m.x0 + 100.0, m.y1), Rect::new(m.x0 + 130.0, m.y0, m.x1, m.y1)]);
// Float noise at the edges is absorbed and kept inside the box.
p.columns.positions = Some(vec![-0.001, 100.0, 130.0, w + 0.001]);
assert_eq!((p.column_rects()[0].x0, p.column_rects()[1].x1), (m.x0, m.x1));
let huge: Vec<f64> = (0..10_000).map(f64::from).collect();
let unusable = [
vec![],
vec![10.0],
vec![0.0, w], // one column's worth for two columns
vec![0.0, 100.0, 130.0, 200.0, 230.0, w], // three columns' worth
vec![0.0, 100.0, 90.0, w], // descending across pairs
vec![0.0, 130.0, 100.0, w], // an inverted gutter
vec![0.0, f64::NAN, 130.0, w],
vec![0.0, 100.0, f64::INFINITY, w],
vec![5.0, 100.0, 130.0, w], // not from the left margin
vec![0.0, 100.0, 130.0, w + 500.0], // a page narrowed after the drag
vec![0.0, 100.0, 130.0, w - 50.0], // a page widened after the drag
huge,
];
for bad in unusable {
p.columns.positions = Some(bad.clone());
assert_eq!(p.column_rects(), even, "{:?}", bad.iter().take(6).collect::<Vec<_>>());
assert_eq!(p.columns.edges(w), even.iter().flat_map(|r| [r.x0 - m.x0, r.x1 - m.x0]).collect::<Vec<_>>());
}
p.columns.positions = None;
assert_eq!(p.column_rects(), even);
// A count outside 1..=216 has no valid positions, however many are given.
for count in [0, 217, u32::MAX] {
let c = Columns { count, gutter: 0.0, positions: Some(vec![0.0, w]) };
assert!(c.valid_positions(w).is_none(), "{count}");
}
}
/// Moving a gutter moves both its guides, keeps its width and stops at the neighbouring gutters
/// and the margins; the result is valid custom positions.
#[test]
fn with_gutter_moved_moves_a_gutter_pair_and_clamps() {
// Three columns across 450 with gutter 15: 0 140 155 295 310 450.
let c = Columns { count: 3, gutter: 15.0, positions: None };
let even = c.edges(450.0);
assert_eq!(even, vec![0.0, 140.0, 155.0, 295.0, 310.0, 450.0]);
assert_eq!(c.with_gutter_moved(450.0, 0, 100.0), Some(vec![0.0, 100.0, 115.0, 295.0, 310.0, 450.0]));
// Past the left margin: the first column closes to nothing.
assert_eq!(c.with_gutter_moved(450.0, 0, -50.0), Some(vec![0.0, 0.0, 15.0, 295.0, 310.0, 450.0]));
// Past the next gutter: the middle column closes to nothing, the next gutter stays put.
assert_eq!(c.with_gutter_moved(450.0, 0, 400.0), Some(vec![0.0, 280.0, 295.0, 295.0, 310.0, 450.0]));
// The last gutter stops at the right margin.
assert_eq!(c.with_gutter_moved(450.0, 1, 1000.0), Some(vec![0.0, 140.0, 155.0, 435.0, 450.0, 450.0]));
// It starts from custom positions already there, and its result is accepted as custom.
let moved = Columns { positions: c.with_gutter_moved(450.0, 0, 100.0), ..c.clone() };
assert!(moved.valid_positions(450.0).is_some());
assert_eq!(moved.with_gutter_moved(450.0, 1, 200.0), Some(vec![0.0, 100.0, 115.0, 200.0, 215.0, 450.0]));
// No such gutter, or no position to move it to.
assert_eq!(c.with_gutter_moved(450.0, 2, 100.0), None);
assert_eq!(c.with_gutter_moved(450.0, usize::MAX, 100.0), None);
assert_eq!(c.with_gutter_moved(450.0, 0, f64::NAN), None);
assert_eq!(Columns { count: 1, gutter: 12.0, positions: None }.with_gutter_moved(450.0, 0, 100.0), None);
assert_eq!(c.with_gutter_moved(f64::NAN, 0, 100.0), None);
}
/// Mirroring reflects dragged columns across the margin box and keeps them ascending; an
/// invalid set becomes the even split.
#[test]
fn mirrored_columns_reflect_across_the_margin_box() {
let two = Columns { count: 2, gutter: 12.0, positions: Some(vec![0.0, 100.0, 130.0, 540.0]) };
assert_eq!(two.mirrored(540.0), Columns { positions: Some(vec![0.0, 410.0, 440.0, 540.0]), ..two.clone() });
assert_eq!(two.mirrored(540.0).mirrored(540.0), two);
let three = Columns { count: 3, gutter: 10.0, positions: Some(vec![0.0, 50.0, 60.0, 200.0, 210.0, 300.0]) };
let m = three.mirrored(300.0);
assert_eq!(m.positions, Some(vec![0.0, 90.0, 100.0, 240.0, 250.0, 300.0]));
assert!(m.valid_positions(300.0).is_some());
// Positions that do not fit the margin box, or none at all, mirror to the even split.
assert_eq!(two.mirrored(600.0).positions, None);
assert_eq!(two.mirrored(f64::NAN).positions, None);
let unsorted = Columns { positions: Some(vec![0.0, 130.0, 100.0, 540.0]), ..two.clone() };
assert_eq!(unsorted.mirrored(540.0).positions, None);
let even = Columns { positions: None, ..two.clone() };
assert_eq!(even.mirrored(540.0), even);
// A left page takes a right page's columns mirrored, a page on the same side as they are.
let mut r = page(PageSide::Right, 612.0);
r.columns = Columns { count: 2, gutter: 12.0, positions: Some(vec![0.0, 100.0, 130.0, 522.0]) };
let l = page(PageSide::Left, 0.0);
assert_eq!(l.columns_from(&r).positions, Some(vec![0.0, 392.0, 422.0, 522.0]));
assert_eq!(page(PageSide::Right, 0.0).columns_from(&r), r.columns);
// A left page holding them mirrored has the same margins as that right page (a one-page
// parent in a facing document); holding them unmirrored, it doesn't.
let mut l = page(PageSide::Left, 0.0);
l.columns = l.columns_from(&r);
assert!(r.same_margins(&l));
l.columns = r.columns.clone();
assert!(!r.same_margins(&l));
}
#[test]
fn spread_spine_and_hit() {
let s = Spread {
+14
View File
@@ -2,9 +2,19 @@
use serde::{Deserialize, Serialize};
use crate::edit::SpreadRef;
use crate::ids::{ItemId, StoryId};
use crate::table::CellRange;
/// A selected ruler guide: index `index` in the guides of page `page` of spread `spread`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GuideSel {
pub spread: SpreadRef,
pub page: usize,
pub index: usize,
}
/// A table cell: the table (in the selection's story) and the owning cell's grid position.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -83,6 +93,10 @@ pub struct Selection {
/// The page selected with the Page tool (absolute index).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub page: Option<usize>,
/// Selected ruler guides. Never together with items, text or cells: selecting one clears
/// the other.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub guides: Vec<GuideSel>,
}
impl Selection {
+74
View File
@@ -0,0 +1,74 @@
//! Unix time → proleptic Gregorian calendar, exact for every `i64`.
//!
//! chrono is the only calendar logic. `NaiveDate` covers about ±262143 years, but clocks and files
//! can hold instants past that, so these extend its range: the Gregorian calendar repeats every
//! 400 years (146_097 days), so the instant is moved into chrono's range by whole cycles, chrono
//! gives the month, day and time of day, and the cycles are added back to the year (at most about
//! 2.5e16, so it fits an `i64`). Callers own only their output format and any year clamp.
use chrono::{Datelike, NaiveDate, NaiveDateTime, NaiveTime};
/// Days since 1970-01-01 → (proleptic Gregorian year, a chrono date with the same month and day;
/// its own year is not the real one).
pub fn date_parts(days: i64) -> (i64, NaiveDate) {
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
// 1970-01-01 is CE day 719_163; one cycle on from it is well inside chrono's range.
let d = NaiveDate::from_num_days_from_ce_opt(rest as i32 + 719_163).unwrap_or_default();
(i64::from(d.year()) + cycles * 400, d)
}
/// Unix seconds (+ nanos, clamped to 999_999_999) → (proleptic Gregorian year, a chrono date and
/// time with the same month, day and time of day; its own year is not the real one).
pub fn utc_parts(secs: i64, nanos: u32) -> (i64, NaiveDateTime) {
let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
let (y, d) = date_parts(days);
let t = NaiveTime::from_num_seconds_from_midnight_opt(sod as u32, nanos.min(999_999_999)).unwrap_or_default();
(y, d.and_time(t))
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Timelike;
fn ymdhms(secs: i64) -> (i64, u32, u32, u32, u32, u32) {
let (y, t) = utc_parts(secs, 0);
(y, t.month(), t.day(), t.hour(), t.minute(), t.second())
}
#[test]
fn epoch_and_ordinary_dates() {
assert_eq!(ymdhms(0), (1970, 1, 1, 0, 0, 0));
assert_eq!(ymdhms(951_782_400), (2000, 2, 29, 0, 0, 0));
assert_eq!(ymdhms(1_791_504_000 + 3_723), (2026, 10, 9, 1, 2, 3));
}
#[test]
fn before_1970() {
assert_eq!(ymdhms(-1), (1969, 12, 31, 23, 59, 59));
assert_eq!(ymdhms(-86_400), (1969, 12, 31, 0, 0, 0));
assert_eq!(ymdhms(-10_000_000_000_000), (-314918, 8, 13, 6, 13, 20));
}
#[test]
fn year_9999_rolls_into_10000() {
assert_eq!(ymdhms(253_402_300_799), (9999, 12, 31, 23, 59, 59));
assert_eq!(ymdhms(253_402_300_800), (10000, 1, 1, 0, 0, 0));
}
#[test]
fn i64_extremes_stay_exact() {
assert_eq!(ymdhms(i64::MAX), (292277026596, 12, 4, 15, 30, 7));
assert_eq!(ymdhms(i64::MIN), (-292277022657, 1, 27, 8, 29, 52));
// Days (not seconds) at the extremes: whole cycles still fit, so the year can't overflow.
let ((hi, _), (lo, _)) = (date_parts(i64::MAX), date_parts(i64::MIN));
assert!(hi > 25_000_000_000_000_000 && hi < i64::MAX && lo < -25_000_000_000_000_000 && lo > i64::MIN);
}
#[test]
fn nanos_past_a_second_are_clamped() {
assert_eq!(utc_parts(0, 999_999_999).1.nanosecond(), 999_999_999);
assert_eq!(utc_parts(0, 1_500_000_000).1.nanosecond(), 999_999_999);
assert_eq!(utc_parts(0, u32::MAX).1.second(), 0);
}
}
+48 -18
View File
@@ -126,28 +126,34 @@ pub fn now() -> i64 {
}
}
/// (year, month 1–12, day 1–31) from days since 1970-01-01 (H. Hinnant's public-domain algorithm).
/// (year, month 1–12, day 1–31) from days since 1970-01-01, exact for every `days`
/// (see [`crate::time::date_parts`]).
fn civil(days: i64) -> (i64, u32, u32) {
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
(yoe + era * 400 + i64::from(m <= 2), m, d)
use chrono::Datelike;
let (y, d) = crate::time::date_parts(days);
(y, d.month(), d.day())
}
/// Days since 1970-01-01 of a civil date (inverse of [`civil`]).
/// Days since 1970-01-01 of a civil date (inverse of [`civil`]), exact for any year.
///
/// The Gregorian calendar repeats every 400 years (146_097 days): chrono converts the year within
/// its cycle and whole cycles are added back as days (an i128 so the extremes can't overflow),
/// which matches the proleptic Gregorian count the old hand-rolled code gave. 1970-01-01 is
/// `num_days_from_ce` 719_163.
///
/// A day past the end of its month carries into the next one, as before chrono (`2019-02-30`
/// is 2019-03-02: `parse_iso_date` accepts any day 1..=31): chrono places the 1st of the month
/// and the rest of the days are added on. Only a month outside 1..=12 reads as 0.
fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = y.div_euclid(400);
let yoe = y.rem_euclid(400);
let mp = i64::from(if m > 2 { m - 3 } else { m + 9 });
let doy = (153 * mp + 2) / 5 + i64::from(d) - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
let (cycles, year) = (i128::from(y.div_euclid(400)), y.rem_euclid(400));
i32::try_from(year)
.ok()
.and_then(|year| chrono::NaiveDate::from_ymd_opt(year, m, 1))
.and_then(|first| {
let days = i128::from(chrono::Datelike::num_days_from_ce(&first)) - 719_163 + cycles * 146_097 + i128::from(d) - 1;
i64::try_from(days).ok()
})
.unwrap_or(0)
}
/// Unix seconds of an ISO 8601 timestamp such as `2019-12-16T09:48:02+01:00` (seconds, the
@@ -294,6 +300,30 @@ mod tests {
assert_eq!(format_date(951_782_400, "yyyy-MM-dd"), "2000-02-29");
}
#[test]
fn dates_beyond_chrono_range() {
// Past `NaiveDate`'s ±262143 the 400-year cycles carry the year; the clock can reach this,
// so keep the proleptic Gregorian date the old formula gave rather than the epoch, on both
// sides of the epoch. Values from the pre-chrono formula, through the public `format_date`.
assert_eq!(format_date(10_000_000_000_000, "yyyy-MM-dd HH:mm"), "318857-05-20 17:46");
assert_eq!(format_date(-8_334_659_289_600, "yyyy-MM-dd"), "-262145-03-01");
// `days_from_civil` is exact and inverts `civil` past the range, both sides.
for (y, m, d, days) in
[(262_144i64, 1u32, 1u32, 95_026_602i64), (300_000, 6, 15, 108_853_388), (-262_145, 3, 1, -96_465_964), (-300_000, 2, 29, -110_292_219)]
{
assert_eq!(days_from_civil(y, m, d), days, "{y}-{m}-{d}");
assert_eq!(civil(days), (y, m, d), "{days}");
}
// The extremes must not overflow or panic (i128 intermediates).
let _ = (civil(i64::MAX), civil(i64::MIN), days_from_civil(i64::MAX, 12, 31), days_from_civil(i64::MIN, 1, 1));
// A day past the end of its month carries over, as before chrono, instead of reading as 1970.
assert_eq!(days_from_civil(2019, 2, 30), days_from_civil(2019, 3, 2));
assert_eq!(days_from_civil(2024, 2, 30), days_from_civil(2024, 3, 1));
assert_eq!(days_from_civil(2019, 4, 31), days_from_civil(2019, 5, 1));
assert_eq!(parse_iso_date("2019-02-30"), Some(1_551_484_800));
assert_eq!(parse_iso_date("2019-02-30T09:48:02Z"), parse_iso_date("2019-03-02T09:48:02Z"));
}
#[test]
fn iso_dates() {
assert_eq!(parse_iso_date("2026-10-01T14:05:00Z"), Some(1_790_863_500));
+1 -1
View File
@@ -109,7 +109,7 @@ fn position_param(p: &Value, base: &AnchorPosition) -> Result<AnchorPosition> {
}
/// Text frames can't be anchored yet (their stories need frames on a spread).
fn check_item(it: &Item) -> Result<()> {
pub(crate) fn check_item(it: &Item) -> Result<()> {
let mut ok = true;
it.walk(&mut |i| ok &= !matches!(i.content, Content::Text(_)));
if ok { Ok(()) } else { Err(bad("anchored.insert", "text frames can't be anchored yet")) }
+1 -1
View File
@@ -436,7 +436,7 @@ fn assign_page(dst: &mut Document, abs: usize, from: &Page) {
p.guides = from.guides.clone();
p.parent = from.parent;
p.margins = from.margins;
p.columns = from.columns.clone();
p.columns = p.columns_from(from);
p.local_margins = from.local_margins;
p.width = from.width;
p.height = from.height;
+135 -27
View File
@@ -43,24 +43,64 @@ fn copy_text(s: &mut Session) -> Result<Option<String>> {
Ok(Some(text))
}
/// Plain text for the system clipboard after copying objects: the copied text frames' stories,
/// or U+FFFC (object replacement character) when none has text. Paste reads it back as "the
/// copied objects", not as text.
pub fn copied_objects_text(s: &Session) -> String {
let Some(clip) = &s.clipboard else { return '\u{FFFC}'.to_string() };
let mut stories = Vec::new();
for it in clip.spreads.first().map(|sp| sp.items.as_slice()).unwrap_or_default() {
it.walk(&mut |i| {
if let Some(tf) = i.text_frame()
&& !stories.contains(&tf.story)
{
stories.push(tf.story);
}
});
}
let text = stories.iter().filter_map(|id| clip.story(*id)).map(plain).filter(|t| !t.is_empty()).collect::<Vec<_>>().join("\n");
if text.is_empty() { '\u{FFFC}'.to_string() } else { text }
}
/// `text` (the system clipboard's) is what the last copy put there: objects were copied last and
/// it is their placeholder, so Paste stands for the copied objects.
fn is_objects_placeholder(s: &Session, text: &str) -> bool {
s.text_clipboard.is_none() && s.clipboard.is_some() && text.replace("\r\n", "\n") == copied_objects_text(s).replace("\r\n", "\n")
}
/// Paste text at the insertion point: the formatted copy when the system clipboard still holds
/// what was copied here (or `formatted` is forced), else plain text in the insertion format.
/// With `formatted`, objects copied last paste as anchored objects when `text` is absent or their
/// placeholder; other text was copied in another app since. Objects that can't be anchored (text
/// frames, into a table cell) paste as their placeholder text.
fn paste_text(s: &mut Session, text: Option<String>, formatted: bool) -> Result<Value> {
let t = s.doc()?.selection.text.ok_or_else(|| super::bad("edit.paste", "no insertion point"))?;
let clip = s.text_clipboard.clone();
// Items copied last (no text copied since) paste into the text as anchored objects.
if formatted
&& clip.is_none()
let own = formatted && clip.is_none() && s.clipboard.is_some() && text.as_deref().is_none_or(|x| is_objects_placeholder(s, x));
if own
&& t.cell.is_none()
&& let Some(items) = s.clipboard.clone()
{
let list: Vec<designcraft_doc::Item> = items.spreads.first().map(|sp| sp.items.iter().map(|i| (**i).clone()).collect()).unwrap_or_default();
if !list.is_empty() && list.iter().all(|i| !i.is_text_frame()) {
if !list.is_empty() && list.iter().all(|i| super::anchored::check_item(i).is_ok()) {
return s.edit(|d, sel| {
let st = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
let r = t.range();
st.delete(r.start.min(st.len())..r.end.min(st.len()));
let end = super::anchored::anchor_items(d, t.story, r.start, list.clone(), &Default::default())?;
// Fresh ids, story and asset copies for every paste, as on the page.
let mut stories = std::collections::HashMap::new();
let fresh: Vec<designcraft_doc::Item> = list
.iter()
.map(|i| {
let mut i = i.clone();
super::object::renumber(d, &items, &mut i, &mut stories);
if d.layer(i.layer).is_none() {
i.layer = d.default_layer();
}
i
})
.collect();
let end = super::anchored::anchor_items(d, t.story, r.start, fresh, &Default::default())?;
sel.text = Some(designcraft_doc::TextSel { anchor: end, focus: end, ..t });
Ok(json!({"pos": end, "anchored": list.len()}))
});
@@ -80,6 +120,8 @@ fn paste_text(s: &mut Session, text: Option<String>, formatted: bool) -> Result<
Ok(json!({"pos": end, "formatted": true}))
});
}
// U+FFFC stands for objects with no text.
let text = if own { Some(copied_objects_text(s)).filter(|m| m != "\u{FFFC}") } else { text };
let text = text.or_else(|| clip.map(|(_, p)| p)).ok_or_else(|| super::bad("edit.paste", "clipboard is empty"))?;
let text = outside_text(&text)?;
s.execute("text.insert", &json!({"text": text, "raw": true}))
@@ -256,28 +298,39 @@ pub fn specs() -> Vec<CommandSpec> {
st.bump_revision();
ok()
}),
cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{ids?} — delete selected items (or the selected text)", has_doc, |s, p| {
if s.doc()?.selection.text.is_some() && p.get("ids").is_none() {
return super::text::delete_selection(s);
}
let ids = super::targets(s, p)?;
// With a placed graphic itself selected (Direct Selection), only the graphic goes: its
// frame stays, empty and selected (as in InDesign). Other items are deleted.
let content = s.doc()?.selection.content && p.get("ids").is_none();
s.edit(|d, sel| {
let mut kept = Vec::new();
for id in &ids {
if let Some(it) = d.item_mut(*id).filter(|it| content && matches!(it.content, designcraft_doc::Content::Graphic(_))) {
it.content = designcraft_doc::Content::Unassigned;
kept.push(*id);
} else {
let _ = d.remove_item(*id);
}
cmd!(
"edit.clear",
"Clear",
["Edit"],
Some("Delete"),
"{ids?} — delete selected items (or the selected text or ruler guides)",
has_doc,
|s, p| {
if s.doc()?.selection.text.is_some() && p.get("ids").is_none() {
return super::text::delete_selection(s);
}
*sel = Selection::items(kept);
Ok(json!({"deleted": ids.len()}))
})
}),
if p.get("ids").is_none() && s.doc()?.selection.items.is_empty() && !s.doc()?.selection.guides.is_empty() {
return s.execute("guide.delete", &json!({}));
}
let ids = super::targets(s, p)?;
// With a placed graphic itself selected (Direct Selection), only the graphic goes: its
// frame stays, empty and selected (as in InDesign). Other items are deleted.
let content = s.doc()?.selection.content && p.get("ids").is_none();
s.edit(|d, sel| {
let mut kept = Vec::new();
for id in &ids {
if let Some(it) = d.item_mut(*id).filter(|it| content && matches!(it.content, designcraft_doc::Content::Graphic(_))) {
it.content = designcraft_doc::Content::Unassigned;
kept.push(*id);
} else {
let _ = d.remove_item(*id);
}
}
*sel = Selection::items(kept);
Ok(json!({"deleted": ids.len()}))
})
}
),
cmd!(noundo "edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{} → {text} when text is selected (with formatting, footnotes, markers)", has_selection_or_text, |s, _| {
if let Some(text) = copy_text(s)? {
return Ok(json!({"text": text}));
@@ -316,7 +369,8 @@ pub fn specs() -> Vec<CommandSpec> {
if s.doc()?.selection.text.is_some() {
return paste_text(s, text.map(str::to_string), true);
}
if let Some(text) = text {
// The placeholder of an object copy pastes the copied objects.
if let Some(text) = text.filter(|t| !is_objects_placeholder(s, t)) {
// What our own text copy put on the clipboard keeps its formatting.
let slice = s.text_clipboard.clone().filter(|(_, plain)| text.replace("\r\n", "\n") == *plain).map(|(slice, _)| slice);
return paste_text_frame(s, p, text, slice.as_deref());
@@ -527,6 +581,60 @@ mod text_clipboard_tests {
s.doc().unwrap().doc.check().unwrap();
}
/// Copying a rectangle, then text in another app, pasted the rectangle into the text (#212).
#[test]
fn paste_at_a_caret_prefers_other_apps_text_to_copied_objects() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let story = frame(&mut s, "ab");
let rect = s.execute("frame.create", &json!({"rect": [72, 400, 200, 480]})).unwrap()["id"].clone();
s.execute("selection.set", &json!({"ids": [rect]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
let marker = copied_objects_text(&s);
s.execute("text.select", &json!({"story": story.0, "anchor": 2, "focus": 2})).unwrap();
let r = s.execute("edit.paste", &json!({"text": "From elsewhere"})).unwrap();
assert!(r.get("anchored").is_none(), "{r}");
assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "abFrom elsewhere");
// What the object copy put on the system clipboard still pastes the objects.
assert_eq!(s.execute("edit.paste", &json!({"text": marker})).unwrap()["anchored"], 1);
// With no insertion point too: the rectangle, not a text frame holding the marker.
s.execute("edit.deselectAll", &json!({})).unwrap();
let r = s.execute("edit.paste", &json!({"text": marker})).unwrap();
let id = ItemId(r["ids"][0].as_u64().unwrap());
assert!(s.doc().unwrap().doc.item(id).unwrap().text_frame().is_none());
s.doc().unwrap().doc.check().unwrap();
// Paste without Formatting inserts the text given; empty text is no text.
s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 0})).unwrap();
assert!(s.execute("edit.pasteWithoutFormatting", &json!({"text": ""})).is_err());
}
/// Text frames, and groups holding one, can't be anchored: at a caret their copy pastes as text.
#[test]
fn copied_text_frames_paste_at_a_caret_as_their_text() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let target = frame(&mut s, "ab");
let copy_and_paste = |s: &mut Session, ids: Value, text: Value| {
s.execute("selection.set", &json!({"ids": ids})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("text.select", &json!({"story": target.0, "anchor": 0, "focus": 0})).unwrap();
s.execute("edit.paste", &json!({"text": text})).unwrap()
};
let chapter = s.execute("frame.create", &json!({"rect": [72, 400, 300, 480], "content": "text", "text": "Chapter 1"})).unwrap()["id"].clone();
// The same text copied in another app, and the system clipboard read back as absent.
let r = copy_and_paste(&mut s, json!([chapter]), json!("Chapter 1"));
assert!(r.get("anchored").is_none(), "{r}");
s.execute("edit.paste", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.story(target).unwrap().text, "Chapter 1Chapter 1ab");
// A group with a text frame inside, its own placeholder on the system clipboard.
let rect = s.execute("frame.create", &json!({"rect": [72, 500, 200, 560]})).unwrap()["id"].clone();
let group = s.execute("object.group", &json!({"ids": [chapter, rect]})).unwrap()["id"].clone();
let r = copy_and_paste(&mut s, json!([group]), Value::Null);
assert!(r.get("anchored").is_none(), "{r}");
assert_eq!(s.doc().unwrap().doc.story(target).unwrap().text, "Chapter 1Chapter 1Chapter 1ab");
s.doc().unwrap().doc.check().unwrap();
}
#[test]
fn outside_text_never_pastes_markers() {
let mut s = Session::new();
+6 -5
View File
@@ -744,11 +744,11 @@ fn wrap_stand_in(it: &designcraft_doc::Item, depth: usize) -> designcraft_doc::I
}
/// A copy of `d` where placed PDFs with Object Layer Options hidden layers are 300 ppi images.
fn rasterize_layered(d: &designcraft_doc::Document) -> (Option<designcraft_doc::Document>, usize) {
fn rasterize_layered(d: &designcraft_doc::Document) -> Result<(Option<designcraft_doc::Document>, usize)> {
let ids: Vec<designcraft_doc::ItemId> =
d.all_items().into_iter().filter(|id| d.item(*id).is_some_and(|it| !it.pdf_hidden_layers.is_empty() && it.graphic().is_some())).collect();
if ids.is_empty() {
return (None, 0);
return Ok((None, 0));
}
let mut out = d.clone();
let mut n = 0;
@@ -763,7 +763,8 @@ fn rasterize_layered(d: &designcraft_doc::Document) -> (Option<designcraft_doc::
let [l, t, r, b] = shown.unwrap_or([0.0, 0.0, 1.0, 1.0]);
let page_side = (g.size.0 / (r - l)).max(g.size.1 / (b - t));
let side = (page_side * 300.0 / 72.0).clamp(64.0, 8000.0) as u32;
let Some(png) = designcraft_render::pdf_page_png(&a.data, a.page as usize, side, &hidden, shown) else { continue };
let png = designcraft_render::pdf_page_png_checked(&a.data, a.page as usize, side, &hidden, shown)
.map_err(|e| EngineError::Other(format!("{}: {e}", a.name)))?;
let px = designcraft_render::image_size(&png);
let aid = designcraft_doc::AssetId(out.alloc());
out.assets.insert(
@@ -782,7 +783,7 @@ fn rasterize_layered(d: &designcraft_doc::Document) -> (Option<designcraft_doc::
}
n += 1;
}
(Some(out), n)
Ok((Some(out), n))
}
/// Object number of the first page of a PDF (its page tree's first kid).
@@ -829,7 +830,7 @@ pub(crate) fn pdf_export(s: &Session, p: &Value) -> Result<designcraft_pdf::Expo
let flattened = if x1a { None } else { ppi.map(|ppi| flatten(&st.doc, &s.cache, ppi)) };
// Placed PDFs with hidden layers go out as images showing just their visible layers.
let base: &designcraft_doc::Document = flattened.as_ref().map_or(&st.doc, |f| &f.0);
let (layered, layer_count) = rasterize_layered(base);
let (layered, layer_count) = rasterize_layered(base)?;
let base2: &designcraft_doc::Document = layered.as_ref().unwrap_or(base);
let shadows = if x1a { shadow_masks(base2, &s.cache, f64::from(opts.flatten_dpi)) } else { Default::default() };
let (fx_doc, fx_count) = rasterize_effects(base2, &s.cache, &shadows);
+8 -1
View File
@@ -629,6 +629,9 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
})
.transpose()?;
// Image Import Options: which page of a PDF (1-based).
if designcraft_render::is_pdf(&bytes) {
designcraft_render::check_pdf_input_size(bytes.len()).map_err(|e| bad("file.place", e.to_string()))?;
}
let pdf_page = match p.get("pdfPage").and_then(Value::as_u64) {
Some(n) if designcraft_render::is_pdf(&bytes) => {
let count = designcraft_render::pdf_page_count(&bytes).unwrap_or(1);
@@ -676,7 +679,11 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.active()?;
st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| matches!(it.content, Content::Unassigned | Content::Graphic(_))))
});
let spread = super::spread_param(p, "file.place")?;
// `"spread": null` counts as omitted, like the other placement parameters.
let spread = match p.get("spread") {
Some(Value::Null) => designcraft_doc::SpreadRef::Doc(0),
_ => super::spread_param(p, "file.place")?,
};
let lid = s.doc()?.active_layer;
let px = p.get("x").and_then(Value::as_f64);
let py = p.get("y").and_then(Value::as_f64);
+564 -27
View File
@@ -1,18 +1,28 @@
//! Ruler guides: add (dragged from the rulers), move, delete, list; Layout › Create Guides.
//! Ruler guides: add (dragged from the rulers), select, move, delete, list; Layout › Create
//! Guides.
//!
//! Guides are stored on their page in spread coordinates; a spread guide (dropped on the
//! pasteboard) lives on the spread's first page with `spread: true` and crosses the pasteboard.
//! Selected guides are [`Selection::guides`], addressed as (spread, page, index): the commands
//! that delete guides renumber it, and [`keep_selection_valid`] drops what another command made
//! stale.
use designcraft_doc::{Document, Guide, Orientation, SpreadRef};
use std::collections::{HashMap, HashSet};
use designcraft_doc::{Document, Guide, GuideSel, Orientation, Selection, SpreadRef};
use designcraft_geom::Rect;
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, str_param};
use crate::{Result, Session};
use super::{CommandSpec, bad, bool_or, cmd, has_doc, str_param};
use crate::{DocState, Result, Session};
const MAX_GUIDE_DIVISIONS: u64 = 1000;
const MAX_GUIDES_CREATED: usize = 10_000;
const MAX_GUIDE_GUTTER: f64 = 1_000_000.0;
/// The farthest a guide may be from the spread origin, in points, either way.
const MAX_GUIDE_POSITION: f64 = 1_000_000.0;
/// The most guides one `guides` list may name.
const MAX_GUIDES_NAMED: usize = 10_000;
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -21,34 +31,96 @@ pub fn specs() -> Vec<CommandSpec> {
"New Guide",
[],
None,
"{orientation: horizontal|vertical, position (spread coordinate), spread?, page? (index in the spread; default: the page under `at` or the position), at? (the other coordinate), spreadGuide?: bool, liquid?: bool (a liquid guide, as the Page tool draws from the rulers)} or {orientation: both, point: [x, y], spread?, page?, spreadGuide?: bool, liquid?: bool} → {page, index}; `both` adds a horizontal guide through y and a vertical guide through x as one undo step → {guides: [{page, index}, {page, index}]}",
"{orientation: horizontal|vertical, position (spread coordinate, at most 1000000 pt either way), spread?, page? (index in the spread; default: the page under `at` or the position), at? (the other coordinate), spreadGuide?: bool, liquid?: bool (a liquid guide, as the Page tool draws from the rulers)} or {orientation: both, point: [x, y], spread?, page?, spreadGuide?: bool, liquid?: bool} → {page, index}; `both` adds a horizontal guide through y and a vertical guide through x as one undo step → {guides: [{page, index}, {page, index}]}. The new guides become the selection; objects are deselected",
has_doc,
add
),
cmd!("guide.move", "Move Guide", [], None, "{spread?, page, index, position}", has_doc, move_guide),
cmd!("guide.delete", "Delete Guide", [], None, "{spread?, page, index}", has_doc, delete),
cmd!("guide.move", "Move Guide", [], None, "{spread?, page, index, position (at most 1000000 pt either way)}", has_doc, move_guide),
cmd!(
"guide.moveBy",
"Move Guides",
[],
None,
"{dx?, dy?, guides?: [{spread?, page, index}] (default: the selected guides)} — vertical guides move by dx, horizontal ones by dy, as one undo step",
has_doc,
move_by
),
cmd!(
"guide.delete",
"Delete Guide",
[],
None,
"{spread?, page, index}, or {} for the selected guides; an address without page or index is an error",
has_doc,
delete
),
cmd!("guide.deleteAll", "Delete All Guides on Spread", ["View", "Grids & Guides"], None, "{spread?}", has_doc, |s, p| {
let r = super::spread_param(p, "guide.deleteAll")?;
s.edit(|d, _| {
s.edit(|d, sel| {
let sp = d.spread_mut(r).ok_or_else(|| bad("guide.deleteAll", "no such spread"))?;
let mut n = 0;
for pg in &mut sp.pages {
n += pg.guides.len();
pg.guides.clear();
}
sel.guides.retain(|g| g.spread != r);
Ok(json!({"deleted": n}))
})
}),
cmd!(query "guide.list", "Guides", [], None, "{spread?} → [{page, index, orientation, position, spread}]", has_doc, |s, p| {
let d = &s.doc()?.doc;
let sp = d.spread(super::spread_param(p, "guide.list")?).ok_or_else(|| bad("guide.list", "no such spread"))?;
let mut out = Vec::new();
for (pi, pg) in sp.pages.iter().enumerate() {
for (i, g) in pg.guides.iter().enumerate() {
out.push(json!({"page": pi, "index": i, "orientation": g.orientation, "position": g.position, "spread": g.spread}));
cmd!(
"view.lockColumnGuides",
"Lock Column Guides",
["View", "Grids & Guides"],
None,
"{on?: bool (default: toggle)} → {locked} — locked column guides can't be dragged; saved with the document (IDML ColumnGuideLocked), on in new documents",
has_doc,
lock_column_guides
),
cmd!(
query "guide.list",
"Guides",
[],
None,
"{spread?} → [{page, index, orientation, position, spread, selected}]",
has_doc,
|s, p| {
let st = s.doc()?;
let r = super::spread_param(p, "guide.list")?;
let sp = st.doc.spread(r).ok_or_else(|| bad("guide.list", "no such spread"))?;
let picked: HashSet<GuideSel> = st.selection.guides.iter().copied().collect();
let mut out = Vec::new();
for (pi, pg) in sp.pages.iter().enumerate() {
for (i, g) in pg.guides.iter().enumerate() {
let selected = picked.contains(&GuideSel { spread: r, page: pi, index: i });
out.push(json!({"page": pi, "index": i, "orientation": g.orientation, "position": g.position, "spread": g.spread, "selected": selected}));
}
}
Ok(Value::Array(out))
}
Ok(Value::Array(out))
),
cmd!(
noundo "guide.select",
"Select Guide",
[],
None,
"{spread?, page, index, guides?: [{spread?, page, index}] (several, in place of page and index), add?: bool (keep the guides already selected), toggle?: bool (deselect a selected guide instead: Shift-click)} — selects ruler guides and deselects objects → {selected: n}",
has_doc,
select
),
cmd!(
noundo "guide.selectAll",
"Select All Guides",
["Edit"],
Some("Cmd+Alt+G"),
"{spread?} — every guide on the spread that is not locked or on a locked or hidden layer → {selected: n}",
has_doc,
select_all
),
cmd!(noundo "guide.deselect", "Deselect Guides", [], None, "{}", has_doc, |s, _| {
let st = s.doc_mut()?;
st.selection.guides.clear();
st.bump_revision();
super::ok()
}),
cmd!(
"layout.createGuides",
@@ -62,6 +134,18 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
fn lock_column_guides(s: &mut Session, p: &Value) -> Result<Value> {
let cur = s.doc()?.doc.settings.column_guides_locked;
let on = p.get("on").and_then(Value::as_bool).unwrap_or(!cur);
if on == cur {
return Ok(json!({"locked": cur}));
}
s.edit(|d, _| {
d.settings.column_guides_locked = on;
Ok(json!({"locked": on}))
})
}
fn checked_usize(value: &Value, key: &str, command: &str) -> Result<usize> {
let n = value.as_u64().ok_or_else(|| bad(command, format!("`{key}` must be a non-negative integer")))?;
usize::try_from(n).map_err(|_| bad(command, format!("`{key}` is too large for this platform")))
@@ -80,6 +164,14 @@ fn finite_number(p: &Value, key: &str, command: &str) -> Result<Option<f64>> {
Ok(Some(number))
}
/// `position` as a guide position: finite and within [`MAX_GUIDE_POSITION`] of the origin.
fn guide_position(position: f64, command: &str) -> Result<f64> {
if !position.is_finite() || position.abs() > MAX_GUIDE_POSITION {
return Err(bad(command, format!("a guide position must be between -{MAX_GUIDE_POSITION} and {MAX_GUIDE_POSITION}")));
}
Ok(position)
}
fn orientation(p: &Value) -> Result<Orientation> {
match str_param(p, "orientation") {
Some("horizontal") => Ok(Orientation::Horizontal),
@@ -115,6 +207,9 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
};
vec![(o, pos, x)]
};
for (_, pos, _) in &guides {
guide_position(*pos, "guide.add")?;
}
let d = &s.doc()?.doc;
let placed: Vec<(Orientation, f64, usize)> = guides
.into_iter()
@@ -128,11 +223,13 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
})
.collect();
let layer = s.doc()?.active_layer;
s.edit(|d, _| {
s.edit(|d, sel| {
let sp = d.spread_mut(r).ok_or_else(|| bad("guide.add", "no such spread"))?;
let mut out = Vec::new();
let mut added = Vec::new();
for (o, pos, pi) in placed {
let pg = sp.pages.get_mut(pi).ok_or_else(|| bad("guide.add", "no such page"))?;
let index = pg.guides.len();
pg.guides.push(Guide {
orientation: o,
position: pos,
@@ -141,8 +238,11 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
layer: Some(layer),
liquid: liquid && !spread_guide,
});
out.push(json!({"page": pi, "index": pg.guides.len() - 1}));
out.push(json!({"page": pi, "index": index}));
added.push(GuideSel { spread: r, page: pi, index });
}
// A new guide is selected, as in InDesign; nothing else stays selected.
*sel = Selection { guides: added, ..Default::default() };
match <[Value; 1]>::try_from(out) {
Ok([one]) => Ok(one),
Err(out) => Ok(json!({"guides": out})),
@@ -168,6 +268,7 @@ fn check_editable(d: &Document, r: SpreadRef, pi: usize, i: usize, command: &str
fn move_guide(s: &mut Session, p: &Value) -> Result<Value> {
let (r, pi, i) = guide_ref(p, "guide.move")?;
let pos = finite_number(p, "position", "guide.move")?.ok_or_else(|| bad("guide.move", "missing position"))?;
let pos = guide_position(pos, "guide.move")?;
s.edit(|d, _| {
check_editable(d, r, pi, i, "guide.move")?;
let g = d
@@ -181,18 +282,209 @@ fn move_guide(s: &mut Session, p: &Value) -> Result<Value> {
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let (r, pi, i) = guide_ref(p, "guide.delete")?;
s.edit(|d, _| {
check_editable(d, r, pi, i, "guide.delete")?;
let pg = d.spread_mut(r).and_then(|sp| sp.pages.get_mut(pi)).ok_or_else(|| bad("guide.delete", "no such page"))?;
if i >= pg.guides.len() {
return Err(bad("guide.delete", "no such guide"));
// An address names one guide: a partial one (only `spread`, say) is an error, not the selection.
let targets = if ["spread", "page", "index"].iter().all(|k| p.get(k).is_none()) {
s.doc()?.selection.guides.clone()
} else {
let (spread, page, index) = guide_ref(p, "guide.delete")?;
vec![GuideSel { spread, page, index }]
};
if targets.is_empty() {
return Err(bad("guide.delete", "no guide selected"));
}
s.edit(|d, sel| {
for g in &targets {
check_editable(d, g.spread, g.page, g.index, "guide.delete")?;
}
pg.guides.remove(i);
Ok(Value::Null)
let deleted = remove_guides(d, sel, &targets, "guide.delete")?;
Ok(json!({"deleted": deleted}))
})
}
/// Removes `targets` (existing guides; repeats count once) and renumbers the guide selection to
/// match. Returns how many guides it removed.
fn remove_guides(d: &mut Document, sel: &mut Selection, targets: &[GuideSel], command: &str) -> Result<usize> {
let gone: HashSet<GuideSel> = targets.iter().copied().collect();
// Per page, the removed indices in ascending order.
let mut by_page: HashMap<(SpreadRef, usize), Vec<usize>> = HashMap::new();
for g in &gone {
by_page.entry((g.spread, g.page)).or_default().push(g.index);
}
for ((spread, page), indices) in &mut by_page {
indices.sort_unstable();
let pg = d.spread_mut(*spread).and_then(|sp| sp.pages.get_mut(*page)).ok_or_else(|| bad(command, "no such page"))?;
for &i in indices.iter().rev() {
if i >= pg.guides.len() {
return Err(bad(command, "no such guide"));
}
pg.guides.remove(i);
}
}
sel.guides = sel
.guides
.iter()
.filter(|g| !gone.contains(g))
.map(|g| {
let below = by_page.get(&(g.spread, g.page)).map_or(0, |indices| indices.partition_point(|&i| i < g.index));
GuideSel { index: g.index.saturating_sub(below), ..*g }
})
.collect();
Ok(gone.len())
}
/// The guides a `guides: [{spread?, page, index}]` list names, if the params have one.
fn guides_param(p: &Value, command: &str) -> Result<Option<Vec<GuideSel>>> {
let Some(list) = p.get("guides") else { return Ok(None) };
let list = list.as_array().ok_or_else(|| bad(command, "`guides` must be a list of {spread?, page, index}"))?;
if list.len() > MAX_GUIDES_NAMED {
return Err(bad(command, format!("`guides` may name at most {MAX_GUIDES_NAMED} guides")));
}
list.iter()
.map(|g| {
let (spread, page, index) = guide_ref(g, command)?;
Ok(GuideSel { spread, page, index })
})
.collect::<Result<Vec<_>>>()
.map(Some)
}
/// The guide `g` addresses, if there is one.
fn guide_of(d: &Document, g: GuideSel) -> Option<&Guide> {
d.spread(g.spread)?.pages.get(g.page)?.guides.get(g.index)
}
/// A guide that can be selected: it exists, and neither it nor its layer is locked or hidden.
pub(crate) fn selectable(d: &Document, g: GuideSel) -> bool {
guide_of(d, g).is_some_and(|guide| guide.editable_in(d))
}
fn select(s: &mut Session, p: &Value) -> Result<Value> {
let named = match guides_param(p, "guide.select")? {
Some(list) => list,
None => {
let (spread, page, index) = guide_ref(p, "guide.select")?;
vec![GuideSel { spread, page, index }]
}
};
let (add, toggle) = (bool_or(p, "add", false), bool_or(p, "toggle", false));
let st = s.doc_mut()?;
for g in &named {
check_editable(&st.doc, g.spread, g.page, g.index, "guide.select")?;
}
let mut guides = if add || toggle { st.selection.guides.clone() } else { Vec::new() };
let mut have: HashSet<GuideSel> = guides.iter().copied().collect();
let mut seen = HashSet::new();
let mut dropped = HashSet::new();
for g in named.into_iter().filter(|g| seen.insert(*g)) {
if !have.insert(g) {
if toggle {
dropped.insert(g);
}
} else {
guides.push(g);
}
}
guides.retain(|g| !dropped.contains(g));
let n = guides.len();
st.selection = Selection { guides, ..Default::default() };
st.bump_revision();
Ok(json!({"selected": n}))
}
fn select_all(s: &mut Session, p: &Value) -> Result<Value> {
let r = super::spread_param(p, "guide.selectAll")?;
let st = s.doc_mut()?;
let sp = st.doc.spread(r).ok_or_else(|| bad("guide.selectAll", "no such spread"))?;
let guides: Vec<GuideSel> = sp
.pages
.iter()
.enumerate()
.flat_map(|(page, pg)| (0..pg.guides.len()).map(move |index| GuideSel { spread: r, page, index }))
.filter(|g| selectable(&st.doc, *g))
.collect();
let n = guides.len();
st.selection = Selection { guides, ..Default::default() };
st.bump_revision();
Ok(json!({"selected": n}))
}
fn move_by(s: &mut Session, p: &Value) -> Result<Value> {
let dx = finite_number(p, "dx", "guide.moveBy")?.unwrap_or(0.0);
let dy = finite_number(p, "dy", "guide.moveBy")?.unwrap_or(0.0);
let mut targets = match guides_param(p, "guide.moveBy")? {
Some(list) => list,
None => s.doc()?.selection.guides.clone(),
};
let mut seen = HashSet::new();
targets.retain(|g| seen.insert(*g));
if targets.is_empty() {
return Err(bad("guide.moveBy", "no guide selected"));
}
s.edit(|d, _| {
for g in &targets {
check_editable(d, g.spread, g.page, g.index, "guide.moveBy")?;
}
for g in &targets {
let guide = d
.spread_mut(g.spread)
.and_then(|sp| sp.pages.get_mut(g.page))
.and_then(|pg| pg.guides.get_mut(g.index))
.ok_or_else(|| bad("guide.moveBy", "no such guide"))?;
let to = guide.position
+ match guide.orientation {
Orientation::Vertical => dx,
Orientation::Horizontal => dy,
};
guide.position = guide_position(to, "guide.moveBy").map_err(|_| bad("guide.moveBy", "the guide would move out of range"))?;
}
Ok(json!({"moved": targets.len()}))
})
}
/// After a command: guides stay selected only while nothing else is (objects, text, cells, a
/// page), and only those that are still there and can be selected. A command that changed
/// which guides a page holds without keeping the guide selection up to date clears it, since
/// its (page, index) addresses may name other guides now. `before` is the document and
/// selection before the command, when it ran on the same document.
pub(crate) fn keep_selection_valid(st: &mut DocState, before: Option<(&Document, &Selection)>, id: &str) {
let sel = &mut st.selection;
if sel.guides.is_empty() {
return;
}
if !sel.items.is_empty() || sel.text.is_some() || sel.cells.is_some() || sel.page.is_some() {
sel.guides.clear();
return;
}
let kept_up = id.starts_with("guide.") || matches!(id, "edit.undo" | "edit.redo") || before.is_some_and(|(_, old)| old.guides != sel.guides);
if !kept_up
&& let Some((old, _)) = before
&& !std::ptr::eq(old, st.doc.as_ref())
&& !same_guide_addresses(old, &st.doc)
{
sel.guides.clear();
return;
}
let mut seen = HashSet::new();
sel.guides.retain(|g| selectable(&st.doc, *g) && seen.insert(*g));
}
/// Every (spread, page, index) of `old` still names a guide of the same orientation on the same
/// page in `new`: guides were only added at the end of their pages, or moved.
fn same_guide_addresses(old: &Document, new: &Document) -> bool {
let same = |a: &[std::sync::Arc<designcraft_doc::Spread>], b: &[std::sync::Arc<designcraft_doc::Spread>]| {
a.len() == b.len()
&& a.iter().zip(b).all(|(a, b)| {
a.pages.len() == b.pages.len()
&& a.pages.iter().zip(&b.pages).all(|(a, b)| {
a.id == b.id
&& a.guides.len() <= b.guides.len()
&& a.guides.iter().zip(&b.guides).all(|(a, b)| a.orientation == b.orientation)
})
})
};
same(&old.spreads, &new.spreads) && same(&old.parents, &new.parents)
}
fn guide_divisions(p: &Value, key: &str) -> Result<u64> {
let Some(value) = p.get(key) else { return Ok(0) };
let count = value.as_u64().ok_or_else(|| bad("layout.createGuides", format!("`{key}` must be an integer")))?;
@@ -273,7 +565,11 @@ fn create_guides(s: &mut Session, p: &Value) -> Result<Value> {
.filter(|count| *count <= MAX_GUIDES_CREATED)
.ok_or_else(|| bad("layout.createGuides", format!("a single command may create at most {MAX_GUIDES_CREATED} guides")))?;
let layer = s.doc()?.active_layer;
s.edit(|d, _| {
s.edit(|d, sel| {
if remove {
// The cleared pages' guide addresses name the new guides now.
sel.guides.retain(|g| g.spread != r || only.is_some_and(|o| o != g.page));
}
let sp = d.spread_mut(r).ok_or_else(|| bad("layout.createGuides", "no such spread"))?;
let mut n = 0usize;
for (pi, pg) in sp.pages.iter_mut().enumerate() {
@@ -315,6 +611,39 @@ fn create_guides(s: &mut Session, p: &Value) -> Result<Value> {
mod tests {
use super::*;
#[test]
fn lock_column_guides_toggles_sets_and_undoes() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let locked = |s: &Session| s.doc().unwrap().doc.settings.column_guides_locked;
let undo_steps = |s: &Session| s.doc().unwrap().history.undo.len();
assert!(locked(&s), "locked in new documents");
let before = undo_steps(&s);
assert_eq!(s.execute("view.lockColumnGuides", &json!({})).unwrap()["locked"], false);
assert!(!locked(&s));
assert_eq!(undo_steps(&s), before + 1, "one undo step per toggle");
s.execute("edit.undo", &json!({})).unwrap();
assert!(locked(&s), "undo restores the lock");
s.execute("edit.redo", &json!({})).unwrap();
assert!(!locked(&s), "redo unlocks again");
// Setting the current value changes nothing and adds no undo step.
let steps = undo_steps(&s);
assert_eq!(s.execute("view.lockColumnGuides", &json!({"on": false})).unwrap()["locked"], false);
assert_eq!(undo_steps(&s), steps);
assert_eq!(s.execute("view.lockColumnGuides", &json!({"on": true})).unwrap()["locked"], true);
assert!(locked(&s));
assert_eq!(s.execute("view.lockColumnGuides", &json!({})).unwrap()["locked"], false);
// Saved with the document; files without the setting read as locked.
let json = serde_json::to_string(&*s.doc().unwrap().doc).unwrap();
assert!(json.contains("\"columnGuidesLocked\":false"), "{json}");
let back: Document = serde_json::from_str(&json).unwrap();
assert!(!back.settings.column_guides_locked);
let old = json.replace("\"columnGuidesLocked\":false,", "").replace(",\"columnGuidesLocked\":false", "");
assert!(!old.contains("columnGuidesLocked"));
let back: Document = serde_json::from_str(&old).unwrap();
assert!(back.settings.column_guides_locked);
}
#[test]
fn guides_add_move_delete_and_grid() {
let mut s = Session::new();
@@ -390,6 +719,214 @@ mod tests {
}
}
#[cfg(test)]
mod selection_tests {
use serde_json::{Value, json};
use crate::Session;
/// A page with vertical guides at 100, 200, 300 and a horizontal one at 400.
fn guides() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
for x in [100.0, 200.0, 300.0] {
s.execute("guide.add", &json!({"orientation": "vertical", "position": x})).unwrap();
}
s.execute("guide.add", &json!({"orientation": "horizontal", "position": 400.0})).unwrap();
s
}
fn selected(s: &Session) -> Vec<usize> {
s.doc().unwrap().selection.guides.iter().map(|g| g.index).collect()
}
fn positions(s: &mut Session) -> Vec<f64> {
let l = s.execute("guide.list", &json!({})).unwrap();
l.as_array().unwrap().iter().map(|g| g["position"].as_f64().unwrap()).collect()
}
#[test]
fn selecting_guides_clears_objects_and_the_other_way_round() {
let mut s = guides();
let id = s.execute("frame.create", &json!({"rect": [36, 36, 100, 100]})).unwrap()["id"].clone();
s.execute("selection.set", &json!({"ids": [id]})).unwrap();
s.execute("guide.select", &json!({"page": 0, "index": 1})).unwrap();
assert!(s.doc().unwrap().selection.items.is_empty());
assert_eq!(selected(&s), [1]);
// Shift-click adds, Shift-click again removes.
s.execute("guide.select", &json!({"page": 0, "index": 3, "toggle": true})).unwrap();
assert_eq!(selected(&s), [1, 3]);
s.execute("guide.select", &json!({"page": 0, "index": 1, "toggle": true})).unwrap();
assert_eq!(selected(&s), [3]);
let r = s.execute("guide.select", &json!({"guides": [{"page": 0, "index": 0}, {"page": 0, "index": 2}], "add": true})).unwrap();
assert_eq!(r["selected"], 3);
let l = s.execute("guide.list", &json!({})).unwrap();
assert_eq!(l.as_array().unwrap().iter().map(|g| g["selected"] == true).collect::<Vec<_>>(), [true, false, true, true]);
// Selecting an object (Shift-click too) deselects the guides.
s.execute("selection.toggle", &json!({"id": id})).unwrap();
assert!(selected(&s).is_empty());
s.execute("guide.select", &json!({"page": 0, "index": 0})).unwrap();
s.execute("edit.deselectAll", &json!({})).unwrap();
assert!(selected(&s).is_empty());
s.execute("guide.selectAll", &json!({})).unwrap();
s.execute("guide.deselect", &json!({})).unwrap();
assert!(selected(&s).is_empty());
// No such guide.
assert!(s.execute("guide.select", &json!({"page": 0, "index": 9})).is_err());
assert!(s.execute("guide.select", &json!({"page": 3, "index": 0})).is_err());
assert!(s.execute("guide.select", &json!({"guides": "all"})).is_err());
}
#[test]
fn select_all_guides_skips_locked_ones() {
let mut s = guides();
let layer = s.execute("layer.new", &json!({"name": "Grid"})).unwrap()["id"].as_u64().unwrap();
s.execute("layer.activate", &json!({"id": layer})).unwrap();
s.execute("guide.add", &json!({"orientation": "vertical", "position": 500.0})).unwrap();
s.execute("layer.set", &json!({"id": layer, "locked": true})).unwrap();
assert_eq!(s.execute("guide.selectAll", &json!({})).unwrap()["selected"], 4);
assert_eq!(selected(&s), [0, 1, 2, 3]);
assert!(s.execute("guide.select", &json!({"page": 0, "index": 4})).is_err());
// Locking the layer of a selected guide deselects it.
s.execute("layer.set", &json!({"id": layer, "locked": false})).unwrap();
s.execute("guide.selectAll", &json!({})).unwrap();
s.execute("layer.set", &json!({"id": layer, "locked": true})).unwrap();
assert_eq!(selected(&s), [0, 1, 2, 3]);
let spec = crate::cmd::find_command("guide.selectAll").unwrap();
assert_eq!((spec.menu, spec.shortcut), (&["Edit"][..], Some("Cmd+Alt+G")));
}
#[test]
fn selected_guides_move_and_delete_as_one_undo_step() {
let mut s = guides();
s.execute("guide.select", &json!({"guides": [{"page": 0, "index": 0}, {"page": 0, "index": 2}, {"page": 0, "index": 3}]})).unwrap();
// Vertical guides move by dx, horizontal ones by dy.
s.execute("guide.moveBy", &json!({"dx": 10.0, "dy": -5.0})).unwrap();
assert_eq!(positions(&mut s), [110.0, 200.0, 310.0, 395.0]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(positions(&mut s), [100.0, 200.0, 300.0, 400.0]);
assert_eq!(selected(&s), [0, 2, 3]);
// Delete (edit.clear, as the Delete key) takes all three; the guide left is the one at 200.
assert_eq!(s.execute("edit.clear", &json!({})).unwrap()["deleted"], 3);
assert_eq!(positions(&mut s), [200.0]);
assert!(selected(&s).is_empty());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(positions(&mut s), [100.0, 200.0, 300.0, 400.0]);
assert_eq!(selected(&s), [0, 2, 3]);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(positions(&mut s), [200.0]);
assert!(s.execute("guide.moveBy", &json!({"dx": 1.0})).is_err(), "nothing selected");
// A guide stays within 1000000 pt of the origin.
assert!(s.execute("guide.moveBy", &json!({"dx": f64::MAX, "guides": [{"page": 0, "index": 0}]})).is_err());
assert!(s.execute("guide.moveBy", &json!({"dx": 1e6, "guides": [{"page": 0, "index": 0}]})).is_err());
assert_eq!(positions(&mut s), [200.0]);
s.execute("guide.moveBy", &json!({"dx": -1e6 - 200.0, "guides": [{"page": 0, "index": 0}]})).unwrap();
assert_eq!(positions(&mut s), [-1e6]);
assert!(s.execute("guide.move", &json!({"page": 0, "index": 0, "position": 1e6 + 1.0})).is_err());
assert!(s.execute("guide.add", &json!({"orientation": "vertical", "position": -2e6})).is_err());
assert!(s.execute("guide.add", &json!({"orientation": "both", "point": [0.0, 1e300]})).is_err());
assert_eq!(positions(&mut s), [-1e6]);
}
#[test]
fn the_guide_selection_follows_deletes_and_drops_stale_guides() {
let mut s = guides();
s.execute("guide.select", &json!({"guides": [{"page": 0, "index": 2}, {"page": 0, "index": 3}]})).unwrap();
// Deleting a guide before them renumbers them.
s.execute("guide.delete", &json!({"page": 0, "index": 0})).unwrap();
assert_eq!(selected(&s), [1, 2]);
assert_eq!(positions(&mut s), [200.0, 300.0, 400.0]);
// Deleting a selected one deselects it.
s.execute("guide.delete", &json!({"page": 0, "index": 1})).unwrap();
assert_eq!(selected(&s), [1]);
// Undo puts back the selection that went with the document.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(selected(&s), [1, 2]);
// A new guide is the selection.
s.execute("guide.add", &json!({"orientation": "vertical", "position": 50.0})).unwrap();
assert_eq!(selected(&s), [3]);
// Undoing the add to before the first delete: those addresses held other guides then.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(selected(&s), [2, 3]);
// Create Guides replacing the page's guides: their addresses name new guides, so the
// selection goes.
s.execute("layout.createGuides", &json!({"columns": 2, "removeExisting": true})).unwrap();
assert!(selected(&s).is_empty());
s.execute("edit.undo", &json!({})).unwrap();
s.execute("guide.select", &json!({"page": 0, "index": 0})).unwrap();
s.execute("layout.createGuides", &json!({"columns": 2, "columnGutter": 0})).unwrap();
assert_eq!(selected(&s), [0], "guides only added: the addresses still hold");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("guide.selectAll", &json!({})).unwrap();
s.execute("guide.deleteAll", &json!({})).unwrap();
assert!(selected(&s).is_empty());
// Deleting the page goes with it.
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 2})).unwrap();
s.execute("guide.add", &json!({"spread": 1, "orientation": "vertical", "position": 100.0})).unwrap();
s.execute("guide.selectAll", &json!({"spread": 1})).unwrap();
assert_eq!(selected(&s), [0]);
s.execute("layout.pages.delete", &json!({"pages": [1]})).unwrap();
assert!(selected(&s).is_empty());
let _: Value = s.execute("guide.list", &json!({})).unwrap();
}
}
#[cfg(test)]
mod new_guide_tests {
use designcraft_doc::GuideSel;
use serde_json::json;
use crate::Session;
fn selected(s: &Session) -> Vec<GuideSel> {
s.doc().unwrap().selection.guides.clone()
}
#[test]
fn a_new_guide_is_the_selection() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 2})).unwrap();
let id = s.execute("frame.create", &json!({"rect": [36, 36, 100, 100]})).unwrap()["id"].clone();
s.execute("selection.set", &json!({"ids": [id]})).unwrap();
s.execute("guide.add", &json!({"orientation": "vertical", "position": 100.0})).unwrap();
let spread = s.doc().unwrap().selection.guides[0].spread;
assert_eq!(selected(&s), [GuideSel { spread, page: 0, index: 0 }]);
assert!(s.doc().unwrap().selection.items.is_empty(), "objects are deselected");
// Both ways (Cmd-drag from the ruler corner): the two new guides.
s.execute("guide.add", &json!({"orientation": "both", "point": [50.0, 60.0], "spreadGuide": true})).unwrap();
assert_eq!(selected(&s), [GuideSel { spread, page: 0, index: 1 }, GuideSel { spread, page: 0, index: 2 }]);
// Undo gives back the selection from before.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(selected(&s), [GuideSel { spread, page: 0, index: 0 }]);
}
#[test]
fn delete_takes_a_whole_address_or_the_selection() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 3})).unwrap();
for spread in [0, 1] {
s.execute("guide.add", &json!({"spread": spread, "orientation": "vertical", "position": 100.0})).unwrap();
}
s.execute("guide.select", &json!({"spread": 1, "page": 0, "index": 0})).unwrap();
// A spread without page and index is not "the selection".
assert!(s.execute("guide.delete", &json!({"spread": 0})).is_err());
assert!(s.execute("guide.delete", &json!({"spread": 1, "page": 0})).is_err());
assert_eq!(selected(&s).len(), 1);
// A selection naming a guide twice, apart, deletes it once.
s.execute("guide.add", &json!({"spread": 1, "orientation": "vertical", "position": 200.0})).unwrap();
s.execute("guide.add", &json!({"spread": 1, "orientation": "vertical", "position": 300.0})).unwrap();
let spread = selected(&s)[0].spread;
let at = |index| GuideSel { spread, page: 0, index };
s.doc_mut().unwrap().selection.guides = vec![at(0), at(2), at(0)];
assert_eq!(s.execute("guide.delete", &json!({})).unwrap()["deleted"], 2);
let left = s.execute("guide.list", &json!({"spread": 1})).unwrap();
assert_eq!(left.as_array().unwrap().iter().map(|g| g["position"].as_f64().unwrap()).collect::<Vec<_>>(), [200.0]);
assert_eq!(s.execute("guide.list", &json!({"spread": 0})).unwrap().as_array().unwrap().len(), 1);
}
}
#[cfg(test)]
mod layer_tests {
use serde_json::json;
+3 -1
View File
@@ -85,7 +85,9 @@ fn inspect(s: &mut Session, _p: &Value) -> Result<Value> {
let abs = d.first_page_of_spread(si) + pi;
json!({"index": abs, "name": d.page_name(abs), "bounds": [p.x, 0.0, p.x + p.width, p.height], "side": p.side,
"parent": p.parent.and_then(|pid| d.parents.iter().find(|x| x.id == pid)).and_then(|x| x.parent.as_ref()).map(|i| i.label()),
"margins": p.margins, "columns": p.columns.count})
"margins": p.margins, "columns": p.columns.count, "gutter": p.columns.gutter,
"customColumns": p.columns.valid_positions(p.margin_rect().width()).is_some(),
"columnRects": p.column_rects().iter().map(|r| [r.x0, r.y0, r.x1, r.y1]).collect::<Vec<_>>()})
}).collect::<Vec<_>>(),
"items": sp.items.iter().map(|i| item_json(i)).collect::<Vec<_>>(),
})
+451 -9
View File
@@ -88,6 +88,7 @@ fn dist(r: designcraft_geom::Rect, x: f64) -> f64 {
/// Apply the `margins`, `columns` and `gutter` params of `layout.marginsAndColumns` to one page.
fn set_margins_columns(pg: &mut designcraft_doc::Page, p: &Value) {
let width_before = pg.margin_rect().width();
match p.get("margins") {
Some(Value::Number(n)) => pg.margins = Margins::uniform(n.as_f64().unwrap_or(36.0)),
Some(v @ Value::Object(_)) => {
@@ -97,14 +98,62 @@ fn set_margins_columns(pg: &mut designcraft_doc::Page, p: &Value) {
}
_ => {}
}
scale_columns(pg, width_before);
// A new count or gutter re-divides the columns evenly: dragged column positions are what
// the new value replaces. The dialog always sends both, so an unchanged value keeps them.
if let Some(c) = p.get("columns").and_then(Value::as_u64) {
pg.columns.count = (c as u32).clamp(1, 216);
let c = u32::try_from(c).unwrap_or(u32::MAX).clamp(1, 216);
if c != pg.columns.count {
pg.columns.count = c;
pg.columns.positions = None;
}
}
if let Some(g) = p.get("gutter").and_then(Value::as_f64) {
pg.columns.gutter = g.max(0.0);
if let Some(g) = p.get("gutter").and_then(Value::as_f64).filter(|g| g.is_finite()) {
let g = g.max(0.0);
if (g - pg.columns.gutter).abs() > GUTTER_TOLERANCE {
pg.columns.gutter = g;
pg.columns.positions = None;
}
}
}
/// Dragged column positions are measured across the margin box. When the box was
/// `width_before` wide and now has another width, they scale with it, as InDesign's do; a set
/// that doesn't scale into a valid one goes back to the even split rather than stay hidden.
fn scale_columns(pg: &mut designcraft_doc::Page, width_before: f64) {
let width = pg.margin_rect().width();
if (width - width_before).abs() <= GUTTER_TOLERANCE {
return;
}
let Some(positions) = pg.columns.positions.as_mut() else { return };
let factor = width / width_before;
if factor.is_finite() && factor > 0.0 {
positions.iter_mut().for_each(|p| *p *= factor);
// The ends sit exactly on the margins whatever the rounding.
if let Some(first) = positions.first_mut() {
*first = 0.0;
}
if let Some(last) = positions.last_mut() {
*last = width;
}
}
if pg.columns.valid_positions(width).is_none() {
pg.columns.positions = None;
}
}
/// Gives `pg` the page width `width`, scaling its dragged columns with the margin box.
fn set_page_width(pg: &mut designcraft_doc::Page, width: f64) {
let before = pg.margin_rect().width();
pg.width = width;
scale_columns(pg, before);
}
/// How far a gutter may differ from the page's and still count as unchanged, in points. The
/// dialog shows the gutter in the ruler unit, rounded (inches to four decimals: up to 0.0036 pt
/// off), so an OK that leaves the field alone sends back a slightly different number.
const GUTTER_TOLERANCE: f64 = 0.01;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -197,8 +246,9 @@ pub fn specs() -> Vec<CommandSpec> {
let nsp = std::sync::Arc::make_mut(&mut d.spreads[nsi]);
let pg = &mut nsp.pages[npi];
let dx = pg.x - src.x;
(pg.width, pg.height, pg.margins, pg.columns, pg.liquid) =
(src.width, src.height, src.margins, src.columns.clone(), src.liquid);
// A copy on the other side of the spine has its dragged columns mirrored.
let columns = pg.columns_from(src);
(pg.width, pg.height, pg.margins, pg.columns, pg.liquid) = (src.width, src.height, src.margins, columns, src.liquid);
pg.guides = src.guides.clone();
for g in pg.guides.iter_mut().filter(|g| g.orientation == designcraft_doc::Orientation::Vertical) {
g.position += dx;
@@ -252,8 +302,20 @@ pub fn specs() -> Vec<CommandSpec> {
let name = str_param(p, "name").unwrap_or("Parent").to_string();
s.edit(|d, _| {
let prefix = str_param(p, "prefix").map(str::to_string).unwrap_or_else(|| d.next_parent_prefix());
let m = d.page(0).map(|p| (p.columns.count, p.columns.gutter, p.margins)).unwrap_or((1, 12.0, Margins::uniform(36.0)));
let id = d.add_parent(&prefix, &name, m.0, m.1, m.2);
let src = d.page(0).cloned();
let (columns, margins) =
src.as_ref().map_or_else(|| (designcraft_doc::Columns::default(), Margins::uniform(36.0)), |p| (p.columns.clone(), p.margins));
let id = d.add_parent(&prefix, &name, columns.count, columns.gutter, margins);
// Dragged column guides carry over to the parent pages they still fit, mirrored
// on the parent page across the spine from the first page.
if let (Some(src), Some(sp)) = (&src, d.parents.iter_mut().find(|x| x.id == id)) {
for pg in &mut std::sync::Arc::make_mut(sp).pages {
let columns = pg.columns_from(src);
if columns.valid_positions(pg.margin_rect().width()).is_some() {
pg.columns.positions = columns.positions;
}
}
}
Ok(json!({"id": id.0, "prefix": prefix}))
})
}),
@@ -368,6 +430,15 @@ pub fn specs() -> Vec<CommandSpec> {
})
}
),
cmd!(
"layout.moveColumnGuide",
"Move Column Guide",
[],
None,
"{spread?, parent? (a parent's prefix, label or index: its spread instead of `spread`), page (index in the spread), gutter (0 = between the first two columns), position (the gutter's new left edge, spread x)} → {positions (each column's left and right edge from the margin box's left edge), left (where the gutter's left edge went, spread x)} — both guides of the gutter move, keeping its width, and stop at the neighbouring guides and the margins; a document page takes the result as its own columns, a parent page passes it to the pages that follow it; refused while column guides are locked",
column_guides_unlocked,
move_column_guide
),
cmd!(
"layout.documentSetup",
"Document Setup…",
@@ -477,7 +548,7 @@ pub fn specs() -> Vec<CommandSpec> {
for sp in d.spreads.iter_mut().chain(d.parents.iter_mut()) {
let sp = std::sync::Arc::make_mut(sp);
for pg in &mut sp.pages {
pg.width = d.settings.page_width;
set_page_width(pg, d.settings.page_width);
pg.height = d.settings.page_height;
}
sp.relayout();
@@ -808,6 +879,52 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// Column guides can be dragged: a document is open and View › Grids & Guides › Lock Column
/// Guides is off.
fn column_guides_unlocked(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
match s.active() {
Some(st) if st.doc.settings.column_guides_locked => Err("column guides are locked (View › Grids & Guides › Lock Column Guides)".into()),
_ => Ok(()),
}
}
/// A column guide dragged with the Selection tool: gutter `gutter` of a page moves to `position`.
fn move_column_guide(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "layout.moveColumnGuide";
let index = |key: &str| -> Result<usize> {
let v = p.get(key).ok_or_else(|| bad(CMD, format!("missing `{key}`")))?;
v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(CMD, format!("`{key}` must be a non-negative integer")))
};
let (page, gutter) = (index("page")?, index("gutter")?);
let position =
p.get("position").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad(CMD, "`position` must be a finite number"))?;
let r = match p.get("parent") {
None | Some(Value::Null) => super::spread_param(p, CMD)?,
Some(v) => designcraft_doc::SpreadRef::Parent(parent_spread_index(&s.doc()?.doc, v).ok_or_else(|| bad(CMD, "no such parent"))?),
};
s.edit(|d, _| {
if d.settings.column_guides_locked {
return Err(bad(CMD, "column guides are locked"));
}
let pg = d.spread_mut(r).ok_or_else(|| bad(CMD, "no such spread"))?.pages.get_mut(page).ok_or_else(|| bad(CMD, "no such page"))?;
let m = pg.margin_rect();
let count = pg.columns.count;
if gutter.checked_add(1).is_none_or(|g| g >= count as usize) {
return Err(bad(CMD, format!("the page has {} gutters", count.saturating_sub(1))));
}
let positions =
pg.columns.with_gutter_moved(m.width(), gutter, position - m.x0).ok_or_else(|| bad(CMD, "the columns cannot take that position"))?;
let left = positions.get(gutter * 2 + 1).map_or(position, |v| m.x0 + v);
pg.columns.positions = Some(positions.clone());
match r {
designcraft_doc::SpreadRef::Doc(_) => pg.local_margins = true,
designcraft_doc::SpreadRef::Parent(_) => d.sync_parent_margins(),
}
Ok(json!({"positions": positions, "left": left}))
})
}
/// A parent spread named by prefix, label (`A-Parent`) or index.
fn parent_spread_index(d: &designcraft_doc::Document, v: &Value) -> Option<usize> {
match v {
@@ -957,7 +1074,7 @@ pub(crate) fn resize_pages_from(d: &mut designcraft_doc::Document, pages: &[usiz
let dw = w.map_or(0.0, |w| w.max(1.0) - pg.width);
let dh = h.map_or(0.0, |h| h.max(1.0) - pg.height);
if let Some(w) = w {
pg.width = w.max(1.0);
set_page_width(pg, w.max(1.0));
}
if let Some(h) = h {
pg.height = h.max(1.0);
@@ -1650,6 +1767,191 @@ mod adjust_tests {
// An unknown parent is an error, not a silent no-op.
assert!(s.execute("layout.marginsAndColumns", &json!({"parent": 9, "margins": 10})).is_err());
}
/// Margins and Columns re-divides the columns: a new count or gutter replaces column guides
/// that were dragged (an imported Custom columns page), rather than being ignored by them.
#[test]
fn a_new_column_count_replaces_dragged_column_positions() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap();
// There is no command for dragging a column guide yet, so set what an import would leave.
let drag = |s: &mut Session| {
s.edit(|d, _| {
let pg = &mut std::sync::Arc::make_mut(&mut d.spreads[0]).pages[0];
pg.columns = designcraft_doc::Columns { count: 2, gutter: 12.0, positions: Some(vec![0.0, 100.0, 130.0, 540.0]) };
Ok(())
})
.unwrap();
};
drag(&mut s);
let dragged = s.doc().unwrap().doc.page(0).unwrap().column_rects();
assert_eq!(dragged.len(), 2);
assert!((dragged[0].x1 - dragged[0].x0 - 100.0).abs() < 1e-6, "the dragged gutter is in use: {dragged:?}");
// A new count: evenly divided again, and the stale positions are gone.
s.execute("layout.marginsAndColumns", &json!({"columns": 3})).unwrap();
let pg = s.doc().unwrap().doc.page(0).unwrap().clone();
assert_eq!((pg.columns.count, pg.columns.positions.clone()), (3, None));
assert_eq!(pg.column_rects().len(), 3);
// So does a new gutter on its own.
drag(&mut s);
s.execute("layout.marginsAndColumns", &json!({"gutter": 24})).unwrap();
let pg = s.doc().unwrap().doc.page(0).unwrap().clone();
assert_eq!(pg.columns.positions, None);
assert!((pg.column_rects()[1].x0 - pg.column_rects()[0].x1 - 24.0).abs() < 1e-6);
// Margins that keep the margin box's width leave them alone; a new width scales them.
drag(&mut s);
s.execute("layout.marginsAndColumns", &json!({"margins": {"top": 72, "bottom": 72, "inside": 36, "outside": 36}})).unwrap();
assert_eq!(s.doc().unwrap().doc.page(0).unwrap().columns.positions, Some(vec![0.0, 100.0, 130.0, 540.0]));
s.execute("layout.marginsAndColumns", &json!({"margins": 72})).unwrap();
assert_scaled(s.doc().unwrap().doc.page(0).unwrap().columns.positions.clone(), 468.0);
}
/// The params the Margins and Columns dialog sends on OK: every margin, the count and the
/// gutter, whether or not the user changed them.
fn dialog_params(margin: f64, columns: u32, gutter: f64) -> serde_json::Value {
json!({"margins": {"top": margin, "bottom": margin, "inside": margin, "outside": margin},
"columns": columns, "gutter": gutter, "adjustLayout": false})
}
const DRAGGED: [f64; 4] = [0.0, 100.0, 130.0, 540.0];
fn dragged() -> designcraft_doc::Columns {
designcraft_doc::Columns { count: 2, gutter: 12.0, positions: Some(DRAGGED.to_vec()) }
}
/// `positions` are [`DRAGGED`] scaled from its 540 pt margin box to one `width` wide.
#[track_caller]
fn assert_scaled(positions: Option<Vec<f64>>, width: f64) {
let want: Vec<f64> = DRAGGED.iter().map(|p| p * width / 540.0).collect();
let got = positions.unwrap_or_default();
assert!(got.len() == want.len() && got.iter().zip(&want).all(|(g, w)| (g - w).abs() < 1e-6), "{got:?} ≠ {want:?}");
}
/// An OK in the dialog keeps dragged columns unless the count or gutter it sends differs.
#[test]
fn the_dialog_keeps_dragged_columns_unless_the_count_or_gutter_changes() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap();
let drag = |s: &mut Session| {
s.edit(|d, _| {
std::sync::Arc::make_mut(&mut d.spreads[0]).pages[0].columns = dragged();
Ok(())
})
.unwrap();
};
let positions = |s: &Session| s.doc().unwrap().doc.page(0).unwrap().columns.positions.clone();
// The same count and gutter (the gutter as an inch field rounds it) keep them in use.
drag(&mut s);
s.execute("layout.marginsAndColumns", &dialog_params(36.0, 2, 12.0)).unwrap();
assert_eq!(positions(&s), Some(DRAGGED.to_vec()));
s.execute("layout.marginsAndColumns", &dialog_params(36.0, 2, 0.1667 * 72.0)).unwrap();
assert_eq!(positions(&s), Some(DRAGGED.to_vec()));
let rects = s.doc().unwrap().doc.page(0).unwrap().column_rects();
assert!((rects[0].x1 - rects[0].x0 - 100.0).abs() < 1e-6, "the dragged columns are in use: {rects:?}");
// Top and bottom margins leave the margin box's width alone, and the columns with it.
let mut taller = dialog_params(36.0, 2, 12.0);
taller["margins"]["top"] = json!(72.0);
taller["margins"]["bottom"] = json!(72.0);
s.execute("layout.marginsAndColumns", &taller).unwrap();
assert_eq!(positions(&s), Some(DRAGGED.to_vec()));
// A new width scales them with the margin box, and the old width scales them back.
s.execute("layout.marginsAndColumns", &dialog_params(72.0, 2, 12.0)).unwrap();
assert_scaled(positions(&s), 468.0);
let rects = s.doc().unwrap().doc.page(0).unwrap().column_rects();
assert!((rects[0].x1 - rects[0].x0 - 100.0 * 468.0 / 540.0).abs() < 1e-6, "the scaled columns are in use: {rects:?}");
s.execute("layout.marginsAndColumns", &dialog_params(36.0, 2, 12.0)).unwrap();
assert_scaled(positions(&s), 540.0);
// A new count resets them.
drag(&mut s);
s.execute("layout.marginsAndColumns", &dialog_params(36.0, 3, 12.0)).unwrap();
let pg = s.doc().unwrap().doc.page(0).unwrap().clone();
assert_eq!((pg.columns.count, pg.columns.positions), (3, None));
// So does a new gutter.
drag(&mut s);
s.execute("layout.marginsAndColumns", &dialog_params(36.0, 2, 18.0)).unwrap();
let pg = s.doc().unwrap().doc.page(0).unwrap().clone();
assert_eq!((pg.columns.gutter, pg.columns.positions), (18.0, None));
}
/// A page of another width (Page Size, Document Setup) scales its dragged columns.
#[test]
fn a_new_page_width_scales_dragged_columns() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap();
let drag = |s: &mut Session| {
s.edit(|d, _| {
// A dragged page's columns are its own (`local_margins`), as the drag will make them.
let pg = &mut std::sync::Arc::make_mut(&mut d.spreads[0]).pages[0];
pg.columns = dragged();
pg.local_margins = true;
Ok(())
})
.unwrap();
};
let positions = |s: &Session| s.doc().unwrap().doc.page(0).unwrap().columns.positions.clone();
drag(&mut s);
s.execute("layout.pageSize", &json!({"pages": [1], "height": 900})).unwrap();
assert_eq!(positions(&s), Some(DRAGGED.to_vec()), "a new height keeps them");
s.execute("layout.pageSize", &json!({"pages": [1], "width": 700})).unwrap();
assert_scaled(positions(&s), 628.0);
s.execute("layout.pageSize", &json!({"pages": [1], "width": 612})).unwrap();
assert_scaled(positions(&s), 540.0);
drag(&mut s);
s.execute("layout.documentSetup", &json!({"width": 700})).unwrap();
assert_scaled(positions(&s), 628.0);
}
/// Parent pages follow the same rule, and the document pages that follow them see it.
#[test]
fn the_dialog_keeps_a_parents_dragged_columns_unless_the_count_or_gutter_changes() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap();
let drag = |s: &mut Session| {
s.edit(|d, _| {
std::sync::Arc::make_mut(&mut d.parents[0]).pages[0].columns = dragged();
d.sync_parent_margins();
Ok(())
})
.unwrap();
};
let positions = |s: &Session| {
let d = &s.doc().unwrap().doc;
(d.parents[0].pages[0].columns.positions.clone(), d.page(0).unwrap().columns.positions.clone())
};
let on_parent = |m: f64, c: u32, g: f64| {
let mut p = dialog_params(m, c, g);
p["parent"] = json!(0);
p
};
drag(&mut s);
assert_eq!(positions(&s), (Some(DRAGGED.to_vec()), Some(DRAGGED.to_vec())));
s.execute("layout.marginsAndColumns", &on_parent(36.0, 2, 12.0)).unwrap();
assert_eq!(positions(&s), (Some(DRAGGED.to_vec()), Some(DRAGGED.to_vec())));
s.execute("layout.marginsAndColumns", &on_parent(48.0, 2, 12.0)).unwrap();
let (parent, follower) = positions(&s);
assert_scaled(parent, 516.0);
assert_scaled(follower, 516.0);
drag(&mut s);
s.execute("layout.marginsAndColumns", &on_parent(36.0, 3, 12.0)).unwrap();
assert_eq!(positions(&s), (None, None));
drag(&mut s);
s.execute("layout.marginsAndColumns", &on_parent(36.0, 2, 24.0)).unwrap();
assert_eq!(positions(&s), (None, None));
}
}
#[cfg(test)]
@@ -1948,6 +2250,146 @@ mod parent_tests {
assert_eq!(r, json!({"on": false, "parent": null}));
assert!(!s.doc().unwrap().editing_parents);
}
/// A new parent takes the first page's columns, dragged column guides included: the right
/// parent page as the first (right) page has them, the left one mirrored across the spine.
/// `document.inspect` reports where those columns are.
#[test]
fn a_new_parent_keeps_dragged_columns() {
let mut s = crate::Session::new();
s.execute("file.new", &json!({"pages": 1})).unwrap();
let dragged = designcraft_doc::Columns { count: 2, gutter: 12.0, positions: Some(vec![0.0, 100.0, 130.0, 540.0]) };
// There is no command for dragging a column guide yet, so set what an import would leave.
s.edit(|d, _| {
let pg = &mut std::sync::Arc::make_mut(&mut d.spreads[0]).pages[0];
(pg.columns, pg.local_margins) = (dragged.clone(), true);
Ok(())
})
.unwrap();
let id = s.execute("layout.parents.new", &json!({"prefix": "B"})).unwrap()["id"].as_u64().unwrap();
let d = &s.doc().unwrap().doc;
let parent = d.parents.iter().find(|p| p.id.0 == id).unwrap();
assert_eq!(parent.pages.len(), 2);
let side = |s: designcraft_doc::PageSide| parent.pages.iter().find(|p| p.side == s).unwrap();
let (left, right) = (side(designcraft_doc::PageSide::Left), side(designcraft_doc::PageSide::Right));
assert_eq!(right.columns, dragged);
assert_eq!(right.column_rects().iter().map(|r| r.width()).collect::<Vec<_>>(), vec![100.0, 410.0]);
assert_eq!(left.columns.positions, Some(vec![0.0, 410.0, 440.0, 540.0]));
assert_eq!(left.column_rects().iter().map(|r| r.width()).collect::<Vec<_>>(), vec![410.0, 100.0]);
let inspect = s.execute("document.inspect", &json!({})).unwrap();
let page = &inspect["spreads"][0]["pages"][0];
assert_eq!((page["columns"].clone(), page["customColumns"].clone()), (json!(2), json!(true)), "{page}");
assert_eq!(page["columnRects"][0], json!([36.0, 36.0, 136.0, 756.0]), "{page}");
}
fn columns_of(s: &crate::Session, abs: usize) -> (Option<Vec<f64>>, bool) {
let pg = s.doc().unwrap().doc.page(abs).unwrap().clone();
(pg.columns.positions, pg.local_margins)
}
/// Three columns across a 540 pt margin box (36 pt margins), column guides unlocked.
fn three_columns(pages: u64) -> crate::Session {
let mut s = crate::Session::new();
s.execute("file.new", &json!({"pages": pages})).unwrap();
s.execute("layout.marginsAndColumns", &json!({"parent": "A", "columns": 3, "gutter": 12})).unwrap();
s.execute("view.lockColumnGuides", &json!({"on": false})).unwrap();
s
}
/// Dragging a column guide moves its gutter's two guides together, stops at the neighbouring
/// gutter and the margin, makes a following page's columns its own, and undoes in one step.
#[test]
fn move_column_guide_moves_the_gutter_pair_within_its_neighbours() {
let mut s = three_columns(1);
assert_eq!(columns_of(&s, 0), (None, false), "the page follows A");
let mv =
|s: &mut crate::Session, gutter: u64, x: f64| s.execute("layout.moveColumnGuide", &json!({"page": 0, "gutter": gutter, "position": x}));
let r = mv(&mut s, 0, 236.0).unwrap();
assert_eq!(r, json!({"positions": [0.0, 200.0, 212.0, 356.0, 368.0, 540.0], "left": 236.0}));
assert_eq!(columns_of(&s, 0), (Some(vec![0.0, 200.0, 212.0, 356.0, 368.0, 540.0]), true));
let inspect = s.execute("document.inspect", &json!({})).unwrap();
assert_eq!(inspect["spreads"][0]["pages"][0]["columnRects"][0], json!([36.0, 36.0, 236.0, 756.0]));
// Past the next gutter: it stops there, and the middle column has no width.
assert_eq!(mv(&mut s, 0, 1000.0).unwrap()["left"], json!(36.0 + 344.0));
// Past the right margin: the last gutter stops 12 pt (its width) inside it.
assert_eq!(mv(&mut s, 1, 1e12).unwrap()["positions"], json!([0.0, 344.0, 356.0, 528.0, 540.0, 540.0]));
// Past the left margin.
assert_eq!(mv(&mut s, 0, -1e12).unwrap()["positions"], json!([0.0, 0.0, 12.0, 528.0, 540.0, 540.0]));
for _ in 0..3 {
s.execute("edit.undo", &json!({})).unwrap();
}
assert_eq!(columns_of(&s, 0), (Some(vec![0.0, 200.0, 212.0, 356.0, 368.0, 540.0]), true));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(columns_of(&s, 0), (None, false), "one undo step per drag");
}
/// On a parent page the drag changes the parent, and the document pages that follow it get
/// the new columns; a page with columns of its own keeps them.
#[test]
fn move_column_guide_on_a_parent_reaches_the_pages_that_follow_it() {
let mut s = three_columns(3);
// Page 1, the left page of spread 1, gets columns of its own.
let x = s.doc().unwrap().doc.spreads[1].pages[0].margin_rect().x0 + 300.0;
s.execute("layout.moveColumnGuide", &json!({"spread": 1, "page": 0, "gutter": 1, "position": x})).unwrap();
let own = columns_of(&s, 1);
assert!(own.1 && own.0.is_some());
let d = &s.doc().unwrap().doc;
// A's right page: the one page 0 (a right page) and page 2 follow.
let x0 = d.parents[0].pages[1].margin_rect().x0;
s.execute("layout.moveColumnGuide", &json!({"parent": "A", "page": 1, "gutter": 0, "position": x0 + 100.0})).unwrap();
let moved = vec![0.0, 100.0, 112.0, 356.0, 368.0, 540.0];
assert_eq!(s.doc().unwrap().doc.parents[0].pages[1].columns.positions, Some(moved.clone()));
assert_eq!(columns_of(&s, 0), (Some(moved.clone()), false));
assert_eq!(columns_of(&s, 2), (Some(moved), false));
assert_eq!(columns_of(&s, 1), own, "page 1's columns are its own");
// The same parent page by spread reference.
let spread = json!({"kind": "parent", "index": 0});
s.execute("layout.moveColumnGuide", &json!({"spread": spread, "page": 1, "gutter": 0, "position": x0 + 50.0})).unwrap();
assert_eq!(columns_of(&s, 0).0.unwrap()[1], 50.0);
}
/// A drag's preview in one document is put back there when another document became active.
#[test]
fn a_column_guide_preview_is_cancelled_in_its_own_document() {
let mut s = three_columns(1);
let uid = s.doc().unwrap().uid;
s.begin_interaction("Move Column Guide").unwrap();
s.preview("layout.moveColumnGuide", json!({"page": 0, "gutter": 0, "position": 236})).unwrap();
s.execute("file.new", &json!({})).unwrap();
s.cancel_interaction_of(uid);
s.set_active(0);
assert!(s.doc().unwrap().interaction.is_none());
assert_eq!(columns_of(&s, 0), (None, false));
}
#[test]
fn move_column_guide_refuses_while_locked_and_hostile_params() {
let mut s = three_columns(1);
s.execute("view.lockColumnGuides", &json!({"on": true})).unwrap();
assert!(s.execute("layout.moveColumnGuide", &json!({"page": 0, "gutter": 0, "position": 200})).is_err());
assert_eq!(columns_of(&s, 0), (None, false));
s.execute("view.lockColumnGuides", &json!({"on": false})).unwrap();
for p in [
json!({"page": 0, "gutter": 0}),
json!({"page": 0, "gutter": 0, "position": f64::NAN}),
json!({"page": 0, "gutter": 0, "position": "200"}),
json!({"page": 0, "gutter": 2, "position": 200}),
json!({"page": 0, "gutter": u64::MAX, "position": 200}),
json!({"page": 0, "gutter": -1, "position": 200}),
json!({"page": 0, "gutter": 0.5, "position": 200}),
json!({"page": 9, "gutter": 0, "position": 200}),
json!({"page": u64::MAX, "gutter": 0, "position": 200}),
json!({"gutter": 0, "position": 200}),
json!({"spread": 99, "page": 0, "gutter": 0, "position": 200}),
json!({"parent": "Z", "page": 0, "gutter": 0, "position": 200}),
json!({"page": 0, "gutter": 0, "position": f64::MAX}),
] {
let r = s.execute("layout.moveColumnGuide", &p);
// A huge but finite position is clamped to the margin; everything else is refused.
assert!(r.is_err() || p["position"] == json!(f64::MAX), "{p}: {r:?}");
}
}
}
#[cfg(test)]
+3
View File
@@ -398,6 +398,9 @@ fn reimport(d: &mut Document, sid: StoryId, path: &str, bytes: &[u8], before: Fi
fn reload(path: impl AsRef<Path>) -> Result<(Vec<u8>, (u32, u32))> {
let path = path.as_ref();
let b = crate::io::read_file(path).map_err(EngineError::Other)?;
if designcraft_render::is_pdf(&b) {
designcraft_render::check_pdf_input_size(b.len()).map_err(|e| bad("links", format!("{}: {e}", path.display())))?;
}
let px = designcraft_render::image_size(&b).ok_or_else(|| bad("links", format!("{}: unsupported or corrupt image", path.display())))?;
Ok((b, px))
}
+2 -1
View File
@@ -17,10 +17,11 @@ mod endnotes;
mod export;
mod file;
pub(crate) mod find;
pub use edit::copied_objects_text;
pub use find::same_find_domain;
pub(crate) mod flex;
mod fonts;
mod guides;
pub(crate) mod guides;
mod index;
mod inspect;
mod interactive;
+2 -1
View File
@@ -1074,7 +1074,8 @@ fn adopt_or_dangle(dst: &mut Document, src: &Document, id: AssetId) -> AssetId {
dst.adopt_asset(src, id).unwrap_or_else(|| AssetId(dst.alloc()))
}
fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut HashMap<StoryId, StoryId>) {
/// Give `it` (an item of `src`) fresh ids from `dst`, with copies of its stories and the assets it uses.
pub(crate) fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut HashMap<StoryId, StoryId>) {
it.id = ItemId(dst.alloc());
let my_id = it.id;
if let Some(m) = it.media.as_mut()
+38 -1
View File
@@ -277,7 +277,7 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s
if added >= max_pages {
return Err(bad(cmd, format!("autoflow stopped after {max_pages} pages")));
}
let per_page = if vertical { 1 } else { d.page(page).map_or(1, |p| p.columns.count.max(1) as usize) };
let per_page = if vertical { 1 } else { d.page(page).map_or(1, |p| p.column_rects().len().max(1)) };
let pages = want.div_ceil(per_page).clamp(1, max_pages - added);
let parent = d.page_loc(page).and_then(|(si, pi)| d.spreads[si].pages[pi].parent);
new_pages.extend(d.insert_pages(Some(page), pages, parent)?);
@@ -511,6 +511,43 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// Autoflow follows dragged column guides: one frame per dragged column, on the first page and
/// on every page it adds.
#[test]
fn autoflow_sets_frames_on_dragged_columns() {
let dir = std::env::temp_dir().join(format!("dc-place-dragged-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("long.txt");
std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 40].join("\n")).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({"preset": "Letter", "pages": 1, "columns": 2, "gutter": 18, "margins": 54})).unwrap();
// The margin box is 504 pt wide; the gutter is dragged left. There is no command for
// dragging a column guide yet, so set what an import would leave, on the parent.
let dragged = designcraft_doc::Columns { count: 2, gutter: 18.0, positions: Some(vec![0.0, 150.0, 170.0, 504.0]) };
s.edit(|d, _| {
for pg in &mut std::sync::Arc::make_mut(&mut d.parents[0]).pages {
pg.columns = dragged.clone();
}
d.sync_parent_margins();
Ok(())
})
.unwrap();
let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "page": 1, "autoflow": true})).unwrap();
assert_eq!(r["overset"], false, "{r}");
let d = &s.doc().unwrap().doc;
assert!(d.page_count() >= 3, "{r}");
let frames = &d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames;
assert!(frames.len() > 2 * (d.page_count() - 1), "{} frames", frames.len());
for (i, f) in frames.iter().enumerate() {
let page = d.page_of_item(*f).unwrap();
assert_eq!(page, i / 2, "frame {i}");
let m = d.page(page).unwrap().margin_rect();
let (x0, x1) = [(0.0, 150.0), (170.0, 504.0)][i % 2];
assert_eq!(d.item(*f).unwrap().bounds(), Rect::new(m.x0 + x0, m.y0, m.x0 + x1, m.y1), "frame {i}");
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn autoflow_keeps_the_frame_the_user_drew() {
let dir = std::env::temp_dir().join(format!("dc-place-drawn-{}", std::process::id()));
+11
View File
@@ -571,6 +571,7 @@ impl Session {
Ok(r) => r,
Err(e) => {
// What it did before it failed can be undone too.
self.keep_guide_selection_valid(before.as_ref(), id);
self.record_undo_step(spec, before, nested);
return Err(e);
}
@@ -613,6 +614,7 @@ impl Session {
}
}
}
self.keep_guide_selection_valid(before.as_ref(), id);
self.record_undo_step(spec, before, nested);
if spec.journal {
self.journal.push((id.to_string(), params.clone()));
@@ -621,6 +623,15 @@ impl Session {
Ok(r)
}
/// Drops selected guides the command `id` made stale, against the document as it will be
/// recorded: after the passes that run with the command (smart reflow, live captions, autosize).
fn keep_guide_selection_valid(&mut self, before: Option<&(u64, Arc<Document>, Selection)>, id: &str) {
if let Some(st) = self.active_mut() {
let old = before.filter(|(uid, _, _)| *uid == st.uid).map(|(_, d, sel)| (d.as_ref(), sel));
cmd::guides::keep_selection_valid(st, old, id);
}
}
/// Record undo if the document changed (and we're not previewing an interaction), with the
/// selection from before the command. A command run by an undoable one leaves the step to it:
/// one command is one undo step.
+2 -1
View File
@@ -294,8 +294,9 @@ fn cmd_move_does_not_snap() {
fn cmd_resize_still_snaps_to_a_guide() {
use designcraft_tools::{PointerEvent, PointerKind};
let mut s = session();
s.execute("frame.create", &json!({"rect": [100.0, 100.0, 200.0, 180.0], "content": "unassigned"})).unwrap();
// The new guide is selected: the frame created after it is the selection.
s.execute("guide.add", &json!({"orientation": "vertical", "position": 250.0, "page": 0})).unwrap();
s.execute("frame.create", &json!({"rect": [100.0, 100.0, 200.0, 180.0], "content": "unassigned"})).unwrap();
s.set_tool("selection");
let v = ViewInfo::at_zoom(1.0);
let b0 = s.doc().unwrap().doc.spreads[0].items[0].bounds();
+14
View File
@@ -331,4 +331,18 @@ impl Session {
}
}
}
/// Cancel the interaction of the document with `uid`, active or not: a gesture that began in
/// one document and lost it to another tab ends there, leaving the document as it began.
pub fn cancel_interaction_of(&mut self, uid: u64) {
if self.active().is_some_and(|st| st.uid == uid) {
self.cancel_interaction();
} else if let Some(st) = self.docs.iter_mut().find(|st| st.uid == uid)
&& let Some(it) = st.interaction.take()
{
st.doc = it.doc;
st.selection = it.selection;
st.revision = it.revision;
}
}
}
@@ -1,5 +1,3 @@
#![cfg(not(target_arch = "wasm32"))]
use designcraft_engine::Session;
use serde_json::json;
use std::path::Path;
@@ -1,5 +1,4 @@
//! Browser Print must reject native spooler operations without trapping the editor.
#![cfg(target_arch = "wasm32")]
use designcraft_engine::Session;
use serde_json::json;
@@ -1,5 +1,3 @@
#![cfg(not(target_arch = "wasm32"))]
use designcraft_engine::Session;
use serde_json::json;
+23
View File
@@ -0,0 +1,23 @@
#[cfg(not(target_arch = "wasm32"))]
mod book_safety;
#[cfg(target_arch = "wasm32")]
mod browser_print;
mod character_style_from_selection;
mod close_retention;
#[cfg(not(target_arch = "wasm32"))]
mod collection_state_atomicity;
mod combined_close_glyph;
mod distribute_locks;
mod export_page_input;
mod insert_pages_bounds;
mod param_keys;
mod pdf_backend;
mod preflight_resolution;
mod primary_frame_columns;
#[cfg(not(target_arch = "wasm32"))]
mod save_state_atomicity;
mod stroke_tint_range;
mod text_decorations;
mod text_insert_memory;
mod text_thread_regressions;
mod thread_color_safety;
+349
View File
@@ -0,0 +1,349 @@
//! Synthetic placement/export regressions for PDF backend updates.
use serde_json::json;
use designcraft_engine::Session;
use designcraft_engine::cmd::{base64_decode, base64_encode};
fn pdf(objects: &[String]) -> Vec<u8> {
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = vec![0];
for (i, object) in objects.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
}
let xref = bytes.len();
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f\r\n", offsets.len()).as_bytes());
for offset in offsets.iter().skip(1) {
bytes.extend_from_slice(format!("{offset:010} 00000 n\r\n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", offsets.len()).as_bytes());
bytes
}
fn layered_pdf(second_name: &str) -> Vec<u8> {
layered_pdf_with_box(second_name, "")
}
fn layered_pdf_with_box(second_name: &str, page_box: &str) -> Vec<u8> {
let content = "/OC /a BDC 0 0 1 rg 10 10 35 80 re f EMC /OC /b BDC 1 0 0 rg 55 10 35 80 re f EMC";
pdf(&[
"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R] /D << /Order [5 0 R 6 0 R] >> >> >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] {page_box} /Resources << /Properties << /a 5 0 R /b 6 0 R >> >> /Contents 4 0 R >>"
),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
"<< /Type /OCG /Name (Blue) >>".into(),
format!("<< /Type /OCG /Name ({second_name}) >>"),
])
}
fn rendered(bytes: &[u8]) -> designcraft_render::Rendered {
let pm = designcraft_render::render_pdf_page(bytes, 0, 100).expect("render synthetic PDF");
designcraft_render::Rendered {
width: pm.width().into(),
height: pm.height().into(),
pixels: pm.data().iter().flat_map(|p| [p.r, p.g, p.b, p.a]).collect(),
}
}
// Optional local evidence output; no binary fixtures are checked in.
fn evidence(name: &str, bytes: &[u8]) {
if let Some(dir) = std::env::var_os("CRAFT_PDF_EVIDENCE_DIR") {
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(std::path::Path::new(&dir).join(name), bytes).unwrap();
}
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn placed_pdf_preserves_transparent_background_and_layer_choices() {
let bytes = layered_pdf("Red");
let img = rendered(&bytes);
assert_eq!(img.pixel(2, 2), [0, 0, 0, 0]);
assert_eq!(img.pixel(25, 50), [0, 0, 255, 255]);
assert_eq!(img.pixel(75, 50), [255, 0, 0, 255]);
let hidden = designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).unwrap();
let img = rendered(&hidden);
assert_eq!(img.pixel(75, 50), [0, 0, 0, 0]);
assert_eq!(img.pixel(25, 50), [0, 0, 255, 255]);
evidence("layers-hidden.png", &img.to_png());
evidence("layers-source.pdf", &bytes);
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn duplicate_layer_names_keep_name_based_hiding() {
let bytes = layered_pdf("Blue");
assert_eq!(designcraft_render::pdf_layers(&bytes).len(), 2);
let hidden = designcraft_render::pdf_hide_layers(&bytes, &["Blue".into()]).unwrap();
let img = rendered(&hidden);
assert_eq!(img.pixel(25, 50), [0, 0, 0, 0]);
assert_eq!(img.pixel(75, 50), [0, 0, 0, 0]);
// A second render of the original must not inherit the hidden configuration.
assert_eq!(rendered(&bytes).pixel(25, 50), [0, 0, 255, 255]);
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn crop_box_and_rotation_keep_placed_dimensions() {
let bytes = pdf(&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 100] /CropBox [10 20 110 80] /Rotate 90 >>".into(),
]);
assert_eq!(designcraft_render::pdf_page_count(&bytes), Some(1));
assert_eq!(designcraft_render::pdf_page_size(&bytes, 0), Some((60.0, 100.0)));
let img = rendered(&bytes);
assert_eq!((img.width, img.height), (60, 100));
assert_eq!(img.pixel(20, 20), [0, 0, 0, 0]);
}
fn exported(hidden: bool) -> Vec<u8> {
let mut session = Session::new();
session.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap();
session.execute("edit.transparencyBlendSpace", &json!({"space": "rgb"})).unwrap();
session.execute("frame.create", &json!({"rect": [0, 0, 100, 100]})).unwrap();
session.execute("object.fill", &json!({"swatch": "[Black]"})).unwrap();
let placed = session
.execute(
"file.place",
&json!({
"base64": base64_encode(&layered_pdf("Red")), "name": "layers.pdf", "x": 0, "y": 0, "width": 100
}),
)
.unwrap();
if hidden {
session.execute("object.layerOptions", &json!({"ids": [placed["id"]], "hidden": ["Red"]})).unwrap();
}
let report = session.execute("file.exportPdf", &json!({"compress": false})).unwrap();
let bytes = base64_decode(report["base64"].as_str().unwrap());
if hidden {
assert!(report["warnings"].to_string().contains("hidden layers"));
}
bytes
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn hidden_layer_export_composites_over_underlying_artwork() {
let bytes = exported(true);
let img = rendered(&bytes);
let right = img.pixel(75, 50);
assert!(right[..3].iter().all(|c| *c < 45), "hidden area must show the black backdrop, not opaque white: {right:?}");
let left = img.pixel(25, 50);
assert!(left[2] > 180 && left[0] < 80, "visible blue layer: {left:?}");
evidence("hidden-export.pdf", &bytes);
evidence("hidden-export.png", &img.to_png());
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn ordinary_placed_pages_still_embed_as_vectors() {
use hayro_syntax::object::Name;
let bytes = exported(false);
let parsed = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
let subtypes: Vec<_> = parsed
.objects()
.into_iter()
.filter_map(|o| o.into_stream())
.filter_map(|s| s.dict().get::<Name>("Subtype").map(|n| n.as_str().to_owned()))
.collect();
assert!(subtypes.iter().any(|s| s == "Form"), "placed PDF form: {subtypes:?}");
assert!(!subtypes.iter().any(|s| s == "Image"), "no rasterization: {subtypes:?}");
let img = rendered(&bytes);
let right = img.pixel(75, 50);
assert!(right[0] > 180 && right[1] < 80, "visible red vector layer: {right:?}");
evidence("vector-export.pdf", &bytes);
evidence("vector-export.png", &img.to_png());
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn oversized_image_does_not_silently_disappear_from_layered_export() {
// Just above the backend's 2^28-pixel cap. The stream is one byte; no large
// fixture or large image allocation is needed to exercise the refusal.
let content = "/OC /a BDC 0 0 1 rg 10 10 35 80 re f EMC q 100 0 0 100 0 0 cm /Im Do Q";
let bytes = pdf(&[
"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [6 0 R] /D << /Order [6 0 R] >> >> >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << /Properties << /a 6 0 R >> /XObject << /Im 5 0 R >> >> /Contents 4 0 R >>".into(),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
"<< /Type /XObject /Subtype /Image /Width 32769 /Height 8192 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\nX\nendstream".into(),
"<< /Type /OCG /Name (Blue) >>".into(),
]);
let mut session = Session::new();
session.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap();
let placed = session
.execute(
"file.place",
&json!({
"base64": base64_encode(&bytes), "name": "oversized-scan.pdf", "x": 0, "y": 0, "width": 100
}),
)
.unwrap();
session.execute("object.layerOptions", &json!({"ids": [placed["id"]], "hidden": ["Blue"]})).unwrap();
let before = session.execute("file.serialize", &json!({})).unwrap();
let result = session.execute("file.exportPdf", &json!({}));
assert!(result.is_err(), "export must report the image omitted by the backend's resource limit");
assert_eq!(session.execute("file.serialize", &json!({})).unwrap(), before, "a failed export must leave the document unchanged");
let error = result.unwrap_err().to_string();
assert!(error.contains("oversized-scan.pdf") && error.contains("image"), "{error}");
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn failed_layer_rewrite_aborts_export_instead_of_revealing_hidden_content() {
// A stored layer choice can outlive relinking to a PDF with no layers.
let bytes = pdf(&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>".into(),
]);
assert!(designcraft_render::render_pdf_page(&bytes, 0, 100).is_some());
assert!(designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).is_none());
let mut session = Session::new();
session.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap();
let placed =
session.execute("file.place", &json!({"base64": base64_encode(&bytes), "name": "missing-layers.pdf", "x": 0, "y": 0, "width": 100})).unwrap();
session.execute("object.layerOptions", &json!({"ids": [placed["id"]], "hidden": ["Red"]})).unwrap();
let before = session.execute("file.serialize", &json!({})).unwrap();
let result = session.execute("file.exportPdf", &json!({}));
assert!(result.is_err(), "failed layer hiding must not export the original visible layers");
let error = result.unwrap_err().to_string();
assert!(error.contains("missing-layers.pdf") && error.contains("layer"), "{error}");
assert_eq!(session.execute("file.serialize", &json!({})).unwrap(), before);
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn placed_standard_font_text_renders_ink_on_a_transparent_background() {
let content = "BT /F1 18 Tf 10 40 Td (PDF text) Tj ET";
let bytes = pdf(&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>".into(),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
]);
let img = rendered(&bytes);
let ink = (40..65).flat_map(|y| (5..95).map(move |x| (x, y))).filter(|(x, y)| img.pixel(*x, *y)[3] > 100).count();
assert!(ink > 100, "standard-font glyphs must paint visible ink: {ink}");
assert_eq!(img.pixel(2, 2), [0, 0, 0, 0]);
assert_eq!(img.pixel(50, 80), [0, 0, 0, 0]);
assert!(designcraft_render::pdf_page_png_checked(&bytes, 0, 100, &[], None).is_ok());
evidence("standard-font-text.png", &img.to_png());
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn unavailable_placed_page_reports_the_page_and_recovery_action() {
let error = designcraft_render::pdf_page_png_checked(&layered_pdf("Red"), 2, 100, &["Red".into()], None).unwrap_err();
assert!(matches!(error, designcraft_render::PdfRasterError::MissingPage { page: 3 }));
assert!(error.to_string().contains("place the replacement PDF again"));
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn hidden_layers_work_with_an_xref_stream_and_compressed_catalog() {
// Reuse the tiny synthetic objects, but put the catalog in an object stream
// and index everything with a binary cross-reference stream.
let classic = String::from_utf8(layered_pdf("Red")).unwrap();
let objects: Vec<&str> = (1..=6).map(|i| classic.split_once(&format!("{i} 0 obj\n")).unwrap().1.split_once("\nendobj").unwrap().0).collect();
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = [0u32; 9];
for (i, object) in objects.iter().enumerate().skip(1) {
offsets[i + 1] = u32::try_from(bytes.len()).unwrap();
bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
}
offsets[7] = u32::try_from(bytes.len()).unwrap();
let catalog = format!("1 0 {}", objects[0]);
bytes.extend_from_slice(
format!("7 0 obj\n<< /Type /ObjStm /N 1 /First 4 /Length {} >>\nstream\n{catalog}\nendstream\nendobj\n", catalog.len()).as_bytes(),
);
offsets[8] = u32::try_from(bytes.len()).unwrap();
let mut entries = Vec::new();
for (i, offset) in offsets.iter().enumerate() {
let (kind, field, generation): (u8, u32, u16) = match i {
0 => (0, 0, 65535),
1 => (2, 7, 0),
_ => (1, *offset, 0),
};
entries.push(kind);
entries.extend_from_slice(&field.to_be_bytes());
entries.extend_from_slice(&generation.to_be_bytes());
}
bytes.extend_from_slice(format!("8 0 obj\n<< /Type /XRef /Size 9 /Root 1 0 R /W [1 4 2] /Length {} >>\nstream\n", entries.len()).as_bytes());
bytes.extend_from_slice(&entries);
bytes.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{}\n%%EOF\n", offsets[8]).as_bytes());
assert_eq!(rendered(&bytes).pixel(75, 50), [255, 0, 0, 255]);
let hidden = designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).unwrap();
let img = rendered(&hidden);
assert_eq!(img.pixel(75, 50), [0, 0, 0, 0]);
assert_eq!(img.pixel(25, 50), [0, 0, 255, 255]);
assert!(designcraft_render::pdf_page_png_checked(&bytes, 0, 100, &["Red".into()], None).is_ok());
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn trailing_whitespace_does_not_prevent_layer_hiding() {
let mut bytes = layered_pdf("Red");
bytes.extend_from_slice(&[b' '; 96]);
let hidden = designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).expect("trailing whitespace is accepted by the layer rewriter");
let img = rendered(&hidden);
assert_eq!(img.pixel(75, 50), [0, 0, 0, 0]);
assert_eq!(img.pixel(25, 50), [0, 0, 255, 255]);
assert!(designcraft_render::pdf_page_png_checked(&bytes, 0, 100, &["Red".into()], None).is_ok());
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn failed_content_decode_aborts_layered_export_without_changing_the_document() {
for filter in ["FlateDecode", "DCTDecode"] {
let bytes = pdf(&[
"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R] /D << /Order [5 0 R] >> >> >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << /Properties << /a 5 0 R >> >> /Contents 4 0 R >>".into(),
format!("<< /Filter /{filter} /Length 1 >>\nstream\nx\nendstream"),
"<< /Type /OCG /Name (Blue) >>".into(),
]);
let mut session = Session::new();
session.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap();
let placed = session
.execute("file.place", &json!({"base64": base64_encode(&bytes), "name": "broken-content.pdf", "x": 0, "y": 0, "width": 100}))
.unwrap();
session.execute("object.layerOptions", &json!({"ids": [placed["id"]], "hidden": ["Blue"]})).unwrap();
let before = session.execute("file.serialize", &json!({})).unwrap();
let error = session.execute("file.exportPdf", &json!({})).unwrap_err().to_string();
assert!(error.contains("broken-content.pdf") && error.contains("content"), "{filter}: {error}");
assert_eq!(session.execute("file.serialize", &json!({})).unwrap(), before, "{filter}");
}
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn hidden_layer_export_keeps_the_placed_trim_box() {
let bytes = layered_pdf_with_box("Red", "/TrimBox [0 0 50 100]");
let mut session = Session::new();
session.execute("file.new", &json!({"width": 50, "height": 100, "pages": 1})).unwrap();
session.execute("edit.transparencyBlendSpace", &json!({"space": "rgb"})).unwrap();
let placed = session
.execute(
"file.place",
&json!({"base64": base64_encode(&bytes), "name": "trimmed-layers.pdf", "pdfCrop": "trim", "x": 0, "y": 0, "width": 100}),
)
.unwrap();
session.execute("object.layerOptions", &json!({"ids": [placed["id"]], "hidden": ["Red"]})).unwrap();
let before = session.execute("file.serialize", &json!({})).unwrap();
let report = session.execute("file.exportPdf", &json!({"compress": false})).unwrap();
let exported = base64_decode(report["base64"].as_str().unwrap());
let img = rendered(&exported);
assert_eq!((img.width, img.height), (50, 100));
// Omitting the shown box squeezes the whole page into the trimmed graphic,
// moving the blue rectangle's right edge from x=45 to x=22.5.
let blue = img.pixel(35, 50);
assert!(blue[2] > 180 && blue[0] < 80, "the trim box keeps the visible layer at its placed coordinates: {blue:?}");
assert_eq!(session.execute("file.serialize", &json!({})).unwrap(), before);
evidence("trimmed-layer-export.pdf", &exported);
}
@@ -1,5 +1,3 @@
#![cfg(not(target_arch = "wasm32"))]
use std::path::Path;
use std::sync::Arc;
+1
View File
@@ -12,3 +12,4 @@ workspace = true
designcraft-doc = { workspace = true }
designcraft-color = { workspace = true }
zip = { workspace = true }
chrono = { workspace = true }
+69 -73
View File
@@ -123,19 +123,13 @@ fn font_file(k: usize, f: &EmbeddedFont) -> (String, &'static str) {
}
/// An ISO 8601 UTC timestamp (`2026-10-13T09:30:00Z`) for Unix seconds `t`.
/// A time outside chrono's year range still converts: the calendar repeats every 400 years, so the
/// instant is moved into range by whole cycles and only the year is carried (matches the old
/// `civil_from_days`), rather than resetting to the epoch.
fn iso8601(t: i64) -> String {
let (days, secs) = (t.div_euclid(86_400), t.rem_euclid(86_400));
// Civil date from days since 1970-01-01 (Howard Hinnant's algorithm).
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
format!("{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z", secs / 3600, secs % 3600 / 60, secs % 60)
let (y, dt) = designcraft_doc::time::utc_parts(t, 0);
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
format!("{y:04}-{}", dt.format("%m-%dT%H:%M:%SZ"))
}
/// The package's `dcterms:modified`: the given time, else the document's last save or creation.
@@ -193,8 +187,6 @@ pub struct FixedPage {
/// A pre-paginated (fixed-layout) EPUB: one XHTML page per document page.
pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], opts: &EpubOptions) -> Result<Vec<u8>, EpubError> {
let io = |e: std::io::Error| EpubError(e.to_string());
let zerr = |e: zip::result::ZipError| EpubError(e.to_string());
let title = opts.title.clone().unwrap_or_else(|| doc.title.clone());
let lang = language_tag(&opts.language);
let id = identifier(doc, opts, &title);
@@ -226,12 +218,41 @@ pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], opts: &EpubOptions
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:epub=\"http://www.idpf.org/2007/ops\" xml:lang=\"{lang}\">\n<head><meta charset=\"utf-8\"/><title>{}</title></head>\n<body><nav epub:type=\"page-list\" id=\"toc\"><h1>Pages</h1><ol>{nav_items}</ol></nav></body>\n</html>\n",
xml_text(&title)
);
let author = opts.author.as_deref().map(|a| format!("<dc:creator>{}</dc:creator>", xml_text(a))).unwrap_or_default();
let opf = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<package xmlns=\"http://www.idpf.org/2007/opf\" version=\"3.0\" unique-identifier=\"bookid\" xml:lang=\"{lang}\" prefix=\"rendition: http://www.idpf.org/vocab/rendition/#\">\n<metadata xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n<dc:identifier id=\"bookid\">{}</dc:identifier>\n<dc:title>{}</dc:title>\n<dc:language>{lang}</dc:language>{author}\n<meta property=\"dcterms:modified\">{modified}</meta>\n<meta property=\"rendition:layout\">pre-paginated</meta>\n<meta property=\"rendition:spread\">auto</meta>\n<meta name=\"original-resolution\" content=\"{vw}x{vh}\"/>\n<meta name=\"generator\" content=\"DesignCraft\"/>\n</metadata>\n<manifest>\n{manifest}</manifest>\n<spine>\n{spine}</spine>\n</package>\n",
xml_text(&id),
xml_text(&title)
let rendition = format!(
"\n<meta property=\"rendition:layout\">pre-paginated</meta>\n<meta property=\"rendition:spread\">auto</meta>\n<meta name=\"original-resolution\" content=\"{vw}x{vh}\"/>"
);
let head = PackageHead { lang, id: &id, title: &title, author: opts.author.as_deref(), modified: &modified };
let opf = package_opf(&head, " prefix=\"rendition: http://www.idpf.org/vocab/rendition/#\"", &rendition, &manifest, &spine);
let entries = files.iter().map(|(name, data)| (name.clone(), data.as_slice(), name.ends_with(".png")));
write_package(&opf, &nav, entries)
}
/// The package metadata both EPUB flavours share.
struct PackageHead<'a> {
lang: &'a str,
id: &'a str,
title: &'a str,
author: Option<&'a str>,
modified: &'a str,
}
/// The OPF package document. `prefix` goes on `<package>` (with its leading space), `meta` after
/// `dcterms:modified` (with its leading newline); `manifest` and `spine` are the item lines.
fn package_opf(h: &PackageHead, prefix: &str, meta: &str, manifest: &str, spine: &str) -> String {
let PackageHead { lang, id, title, author, modified } = h;
let author = author.map(|a| format!("<dc:creator>{}</dc:creator>", xml_text(a))).unwrap_or_default();
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<package xmlns=\"http://www.idpf.org/2007/opf\" version=\"3.0\" unique-identifier=\"bookid\" xml:lang=\"{lang}\"{prefix}>\n<metadata xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n<dc:identifier id=\"bookid\">{}</dc:identifier>\n<dc:title>{}</dc:title>\n<dc:language>{lang}</dc:language>{author}\n<meta property=\"dcterms:modified\">{modified}</meta>{meta}\n<meta name=\"generator\" content=\"DesignCraft\"/>\n</metadata>\n<manifest>\n{manifest}</manifest>\n<spine>\n{spine}</spine>\n</package>\n",
xml_text(id),
xml_text(title)
)
}
/// Zips an EPUB: the stored `mimetype`, `container.xml`, the OPF and nav, then `entries` in order
/// as (path, bytes, stored uncompressed).
fn write_package<'a>(opf: &str, nav: &str, entries: impl IntoIterator<Item = (String, &'a [u8], bool)>) -> Result<Vec<u8>, EpubError> {
let io = |e: std::io::Error| EpubError(e.to_string());
let zerr = |e: zip::result::ZipError| EpubError(e.to_string());
let container = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<container version=\"1.0\" xmlns=\"urn:oasis:names:tc:opendocument:xmlns:container\"><rootfiles><rootfile full-path=\"OEBPS/content.opf\" media-type=\"application/oebps-package+xml\"/></rootfiles></container>\n";
let mut buf = std::io::Cursor::new(Vec::new());
{
@@ -246,9 +267,8 @@ pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], opts: &EpubOptions
z.start_file(name, deflate).map_err(zerr)?;
z.write_all(data).map_err(io)?;
}
for (name, data) in &files {
let o = if name.ends_with(".png") { stored } else { deflate };
z.start_file(name.as_str(), o).map_err(zerr)?;
for (name, data, store) in entries {
z.start_file(name, if store { stored } else { deflate }).map_err(zerr)?;
z.write_all(data).map_err(io)?;
}
z.finish().map_err(zerr)?;
@@ -1366,8 +1386,6 @@ fn base64(data: &[u8]) -> String {
/// Export the document as a reflowable EPUB 3.
pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result<Vec<u8>, EpubError> {
let io = |e: std::io::Error| EpubError(e.to_string());
let zerr = |e: zip::result::ZipError| EpubError(e.to_string());
let title = opts.title.clone().unwrap_or_else(|| doc.title.clone());
let id = identifier(doc, opts, &title);
let modified = modified(doc, opts);
@@ -1428,56 +1446,26 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result<Vec<u8>, EpubEr
);
}
let cover_spine = if opts.cover.is_some() { "<itemref idref=\"cover\"/>\n" } else { "" };
let author = opts.author.as_deref().map(|a| format!("<dc:creator>{}</dc:creator>", xml_text(a))).unwrap_or_default();
let opf = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<package xmlns=\"http://www.idpf.org/2007/opf\" version=\"3.0\" unique-identifier=\"bookid\" xml:lang=\"{lang}\">\n<metadata xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n<dc:identifier id=\"bookid\">{}</dc:identifier>\n<dc:title>{}</dc:title>\n<dc:language>{lang}</dc:language>{author}\n<meta property=\"dcterms:modified\">{modified}</meta>\n<meta name=\"generator\" content=\"DesignCraft\"/>\n</metadata>\n<manifest>\n{manifest}</manifest>\n<spine>\n{cover_spine}{spine}</spine>\n</package>\n",
xml_text(&id),
xml_text(&title)
);
let container = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<container version=\"1.0\" xmlns=\"urn:oasis:names:tc:opendocument:xmlns:container\"><rootfiles><rootfile full-path=\"OEBPS/content.opf\" media-type=\"application/oebps-package+xml\"/></rootfiles></container>\n";
let mut buf = std::io::Cursor::new(Vec::new());
{
let mut z = zip::ZipWriter::new(&mut buf);
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
let deflate = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
z.start_file("mimetype", stored).map_err(zerr)?;
z.write_all(b"application/epub+zip").map_err(io)?;
for (name, data) in [
("META-INF/container.xml", container.as_bytes()),
("OEBPS/content.opf", opf.as_bytes()),
("OEBPS/nav.xhtml", nav.as_bytes()),
("OEBPS/style.css", stylesheet_with(doc, &opts.generic_families, &opts.fonts).as_bytes()),
] {
z.start_file(name, deflate).map_err(zerr)?;
z.write_all(data).map_err(io)?;
}
for (k, c) in chapters.iter().enumerate() {
z.start_file(format!("OEBPS/{}", chapter_file(k)), deflate).map_err(zerr)?;
z.write_all(c.as_bytes()).map_err(io)?;
}
for (name, (_, data)) in &images {
z.start_file(format!("OEBPS/{name}"), stored).map_err(zerr)?;
z.write_all(data).map_err(io)?;
}
for (k, f) in opts.fonts.iter().enumerate() {
z.start_file(format!("OEBPS/{}", font_file(k, f).0), deflate).map_err(zerr)?;
z.write_all(&f.data).map_err(io)?;
}
if let Some(c) = &opts.cover {
z.start_file(format!("OEBPS/{}", cover_file(c).0), stored).map_err(zerr)?;
z.write_all(c).map_err(io)?;
let xhtml = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:epub=\"http://www.idpf.org/2007/ops\" xml:lang=\"{lang}\">\n<head><meta charset=\"utf-8\"/><title>{}</title><style>body{{margin:0;text-align:center}} img{{max-width:100%;max-height:100vh}}</style></head>\n<body epub:type=\"cover\"><img src=\"{}\" alt=\"{}\"/></body>\n</html>\n",
xml_text(&title),
cover_file(c).0,
xml_attr(&title)
);
z.start_file("OEBPS/cover.xhtml", deflate).map_err(zerr)?;
z.write_all(xhtml.as_bytes()).map_err(io)?;
}
z.finish().map_err(zerr)?;
let head = PackageHead { lang, id: &id, title: &title, author: opts.author.as_deref(), modified: &modified };
let opf = package_opf(&head, "", "", &manifest, &format!("{cover_spine}{spine}"));
let css = stylesheet_with(doc, &opts.generic_families, &opts.fonts);
let mut entries: Vec<(String, &[u8], bool)> = vec![("OEBPS/style.css".into(), css.as_bytes(), false)];
entries.extend(chapters.iter().enumerate().map(|(k, c)| (format!("OEBPS/{}", chapter_file(k)), c.as_bytes(), false)));
entries.extend(images.iter().map(|(name, (_, data))| (format!("OEBPS/{name}"), *data, true)));
entries.extend(opts.fonts.iter().enumerate().map(|(k, f)| (format!("OEBPS/{}", font_file(k, f).0), f.data.as_slice(), false)));
let cover_xhtml = opts.cover.as_ref().map(|c| {
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:epub=\"http://www.idpf.org/2007/ops\" xml:lang=\"{lang}\">\n<head><meta charset=\"utf-8\"/><title>{}</title><style>body{{margin:0;text-align:center}} img{{max-width:100%;max-height:100vh}}</style></head>\n<body epub:type=\"cover\"><img src=\"{}\" alt=\"{}\"/></body>\n</html>\n",
xml_text(&title),
cover_file(c).0,
xml_attr(&title)
)
});
if let (Some(c), Some(xhtml)) = (&opts.cover, &cover_xhtml) {
entries.push((format!("OEBPS/{}", cover_file(c).0), c.as_slice(), true));
entries.push(("OEBPS/cover.xhtml".into(), xhtml.as_bytes(), false));
}
Ok(buf.into_inner())
write_package(&opf, &nav, entries)
}
#[cfg(test)]
@@ -1582,6 +1570,14 @@ mod tests {
(951_782_400, "2000-02-29T00:00:00Z"),
(1_791_891_000, "2026-10-13T11:30:00Z"),
(-86_400, "1969-12-31T00:00:00Z"),
// Years past 9999 keep the plain zero-padded year (no `+`).
(253_402_300_800, "10000-01-01T00:00:00Z"),
// Beyond chrono's range the old `civil_from_days` still converted; pin that output
// (never the epoch) in both directions and at the `i64` extremes.
(10_000_000_000_000, "318857-05-20T17:46:40Z"),
(-10_000_000_000_000, "-314918-08-13T06:13:20Z"),
(i64::MAX, "292277026596-12-04T15:30:07Z"),
(i64::MIN, "-292277022657-01-27T08:29:52Z"),
] {
assert_eq!(iso8601(t), want);
}
+2
View File
@@ -0,0 +1,2 @@
mod export_tag_styles;
mod grouped_reading_order;
+4
View File
@@ -0,0 +1,4 @@
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod system_fonts;
@@ -1,7 +1,5 @@
//! A new session finds the installed system fonts by family name (#22). One test in its own
//! process, so the process-wide database starts fresh, as it does when the app starts.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use designcraft_fonts::{FontDb, system_font_dirs};
+5 -4
View File
@@ -8,7 +8,7 @@ use designcraft_doc::{
CharAttrs, CharFormat, ColumnsKind, Content, Document, Item, ListType, PageSide, ParaAttrs, ParaFormat, Rule, Shape, SpanColumns, Spread, Story,
TextFrameOptions, story as st,
};
use designcraft_geom::{Affine, PathData, Rect};
use designcraft_geom::{Affine, PathData};
use crate::names::{self, CHAR_BUILTINS, OBJECT_BUILTINS, PARA_BUILTINS, escape_id, num, pt};
use crate::xml::{El, Node, document, document_with_pi};
@@ -776,7 +776,7 @@ impl<'a> Ex<'a> {
},
)
.attr("CreatePrimaryTextFrame", bool_s(s.primary_text_frame))
.attr("ColumnGuideLocked", "true")
.attr("ColumnGuideLocked", bool_s(s.column_guides_locked))
.attr("MasterTextFrame", bool_s(s.primary_text_frame))
.attr("SnippetImportUsesOriginalLocation", "false"),
);
@@ -2908,8 +2908,9 @@ impl Group {
fn margin_el(pg: &designcraft_doc::Page) -> El {
let m = pg.margin_rect();
let cols = designcraft_doc::column_rects(Rect::new(0.0, 0.0, m.width(), m.height()), pg.columns.count.max(1), pg.columns.gutter);
let pos: Vec<String> = cols.iter().flat_map(|c| [num(c.x0), num(c.x1)]).collect();
// Dragged column guides that still fit the margin box are written as they stand, so they
// survive the round trip; otherwise the even split, which InDesign writes for unmoved columns.
let pos: Vec<String> = pg.columns.edges(m.width()).into_iter().map(num).collect();
El::new("MarginPreference")
.attr("ColumnCount", pg.columns.count.max(1))
.attr("ColumnGutter", num(pg.columns.gutter))
+86 -6
View File
@@ -850,6 +850,9 @@ impl<'r> Importer<'r> {
if let Some(v) = e.boolean("CreatePrimaryTextFrame") {
s.primary_text_frame = v;
}
if let Some(v) = e.boolean("ColumnGuideLocked") {
s.column_guides_locked = v;
}
}
fn grid_prefs(&mut self, e: &El) {
@@ -2550,9 +2553,15 @@ impl<'r> Importer<'r> {
.map(|q| (q.margins, q.columns.clone()))
};
let (margins, columns) = match (p.find("MarginPreference"), &from_parent) {
(Some(m), _) => margins_of(m),
(Some(m), _) => {
let (margins, columns) = margins_of(m);
(margins, fit_columns(columns, &margins, r.width()))
}
(None, Some(own)) => own.clone(),
(None, None) => self.default_margins.clone().unwrap_or((Margins::uniform(36.0), Columns::default())),
(None, None) => {
let (margins, columns) = self.default_margins.clone().unwrap_or((Margins::uniform(36.0), Columns::default()));
(margins, fit_columns(columns, &margins, r.width()))
}
};
let local_margins = from_parent.is_some_and(|(m, c)| m != margins || c != columns);
out_pages.push(Page {
@@ -3481,10 +3490,39 @@ fn composer_in(name: &str) -> designcraft_doc::Composer {
fn margins_of(m: &El) -> (Margins, Columns) {
let g = |k: &str, d: f64| m.num(k).filter(|v| v.is_finite()).unwrap_or(d);
(
Margins { top: g("Top", 36.0), bottom: g("Bottom", 36.0), inside: g("Left", 36.0), outside: g("Right", 36.0) },
Columns { count: g("ColumnCount", 1.0).clamp(1.0, 216.0) as u32, gutter: g("ColumnGutter", 12.0), positions: None },
)
let margins = Margins { top: g("Top", 36.0), bottom: g("Bottom", 36.0), inside: g("Left", 36.0), outside: g("Right", 36.0) };
let count = g("ColumnCount", 1.0).clamp(1.0, f64::from(designcraft_doc::MAX_COLUMNS)) as u32;
let gutter = g("ColumnGutter", 12.0);
let positions = m.get("ColumnsPositions").and_then(|s| custom_columns(s, count, gutter));
(margins, Columns { count, gutter, positions })
}
/// `ColumnsPositions` when the column guides have been dragged off the even split: `2 × count`
/// offsets from the margin box's left edge, valid as [`designcraft_doc::valid_column_positions`]
/// over the box they span. InDesign writes the attribute whether or not they have been moved, so
/// an even set is dropped: `positions` means "custom", and keeping an even copy would freeze the
/// columns against a later change of count or gutter. Anything malformed is the even split; at
/// most one number past `2 × count` is parsed, so a long list costs nothing.
fn custom_columns(s: &str, count: u32, gutter: f64) -> Option<Vec<f64>> {
let want = 2 * count as usize;
let pos = s.split_whitespace().take(want + 1).map(|v| v.parse::<f64>().ok()).collect::<Option<Vec<f64>>>()?;
let width = *pos.last()?;
if !designcraft_doc::valid_column_positions(&pos, count, width) {
return None;
}
let even = Columns { count, gutter, positions: None }.edges(width);
// The tolerance only absorbs float noise in InDesign's arithmetic; a dragged guide moves far more.
pos.iter().zip(&even).any(|(a, b)| (a - b).abs() > 1e-4).then_some(pos)
}
/// `columns` with custom positions only when they fit the margin box of a `page_width`-wide page
/// with these margins (the box's width doesn't depend on which side of the spine it is).
fn fit_columns(mut columns: Columns, margins: &Margins, page_width: f64) -> Columns {
let width = (page_width - margins.inside - margins.outside).max(0.0);
if columns.valid_positions(width).is_none() {
columns.positions = None;
}
columns
}
/// Text Frame Options from `TextFramePreference` elements: each attribute from the first that sets it.
@@ -4011,6 +4049,48 @@ mod page_column_import_safety_tests {
assert_eq!(margins, Margins::uniform(36.));
}
}
/// Hostile `ColumnsPositions` are the even split: never half-read, never a panic, and a long
/// list is not parsed past what `ColumnCount` allows (#1096).
#[test]
fn hostile_column_positions_fall_back_to_the_even_split() {
let positions = |count: &str, raw: &str| {
let m = El::new("MarginPreference").attr("ColumnCount", count).attr("ColumnGutter", "20").attr("ColumnsPositions", raw);
margins_of(&m).1.positions
};
assert_eq!(positions("2", "0 150 170 450"), Some(vec![0., 150., 170., 450.]));
let huge = (0..10_000).map(|i| i.to_string()).collect::<Vec<_>>().join(" ");
for (count, raw) in [
("2", "0 215 235 450"), // the even split
("2", "0 150 170"), // odd
("2", "0 450"), // too few for the count
("2", "0 100 120 200 220 450"), // too many for the count
("2", "0 150 90 450"), // descending
("2", "0 NaN 170 450"),
("2", "0 inf 170 450"),
("2", "0 150 170 1e309"),
("2", "0 x 170 450"),
("2", "10 150 170 450"), // not from the margin
("2", ""),
("2", huge.as_str()),
("4000000000", huge.as_str()), // the count is capped at 216, so is the list
("216", huge.as_str()),
("NaN", "0 150 170 450"),
] {
assert_eq!(positions(count, raw), None, "{count}: {}", raw.get(..raw.len().min(40)).unwrap_or(""));
}
// The cap itself: 216 one-point columns with no gutter are the even split; one edge moved
// makes them custom.
let gapless = |raw: &str| {
let m = El::new("MarginPreference").attr("ColumnCount", "216").attr("ColumnGutter", "0").attr("ColumnsPositions", raw);
margins_of(&m).1.positions
};
let edges = |first_right: &str| {
(0..432).map(|i| if i == 1 { first_right.to_string() } else { ((i + 1) / 2).to_string() }).collect::<Vec<_>>().join(" ")
};
assert_eq!(gapless(&edges("1")), None);
assert_eq!(gapless(&edges("0.5")).map(|p| p.len()), Some(432));
}
}
#[cfg(test)]
+2 -1
View File
@@ -17,7 +17,8 @@
//! ## Supported subset
//! Swatches (process/spot CMYK, RGB and Lab colours, tints, linear/radial gradients), paragraph,
//! character and object styles (with `BasedOn` and style groups), fonts, document preferences
//! (page size, facing pages, bleed/slug, margins and columns, units, grids), layers, sections,
//! (page size, facing pages, bleed/slug, margins and columns including dragged column positions,
//! units, grids), ruler guides, layers, sections,
//! parent (master) spreads and applied parents, spreads and pages, page items (`TextFrame`,
//! `Rectangle`, `Oval`, `Polygon`, `GraphicLine`, `Group`) with fill/stroke, corner options, text
//! frame options (with column rules), text wrap, transparency (opacity, blend mode, drop shadow,
+134
View File
@@ -2798,6 +2798,18 @@ fn round_trips_the_zero_point_and_ruler_origin() {
assert_eq!(fixture("NaN 4").settings.zero_point, [0.0, 0.0]);
}
#[test]
fn round_trips_lock_column_guides() {
let mut d = Document::new(&NewDocument::default());
assert!(zip_text(&export_idml(&d), "Resources/Preferences.xml").contains(r#"ColumnGuideLocked="true""#));
assert!(import_idml(&export_idml(&d)).unwrap().settings.column_guides_locked);
d.settings.column_guides_locked = false;
let bytes = export_idml(&d);
let prefs = zip_text(&bytes, "Resources/Preferences.xml");
assert!(prefs.contains(r#"ColumnGuideLocked="false""#), "{prefs}");
assert!(!import_idml(&bytes).unwrap().settings.column_guides_locked);
}
#[test]
fn kenten_shatai_and_grid_settings_import_and_round_trip() {
use designcraft_doc::cjk::CharacterAlignment;
@@ -4219,6 +4231,128 @@ mod liquid_page_rules {
}
}
/// A dragged column gutter (InDesign's Custom columns) survives an IDML import, and an even set —
/// which InDesign writes for every page, moved or not — does not pretend to be custom (#1096).
#[test]
fn custom_column_positions_import_and_round_trip() {
// Page 1 of SPREAD is 500 wide with Left=20 Right=30, so its margin box is 450 wide and
// starts at x=30. Two columns with gutter 20 split evenly as "0 215 235 450".
let with = |cols: &str| {
let spread = SPREAD.replace(
r#"<MarginPreference ColumnCount="3" ColumnGutter="12" Top="10" Bottom="10" Left="20" Right="30"/>"#,
&format!(
r#"<MarginPreference ColumnCount="2" ColumnGutter="20" Top="10" Bottom="10" Left="20" Right="30" ColumnDirection="Horizontal" {cols}/>"#
),
);
import_idml_with(
&zip_files(&[
("designmap.xml", DESIGNMAP),
("Resources/Graphic.xml", GRAPHIC),
("Resources/Styles.xml", STYLES),
("Resources/Preferences.xml", PREFS),
("MasterSpreads/MasterSpread_m1.xml", MASTER),
("Spreads/Spread_sp1.xml", &spread),
("Stories/Story_s1.xml", STORY),
]),
&|_| None,
)
.unwrap()
};
let rects = |d: &designcraft_doc::Document| d.spreads[0].pages[0].column_rects().iter().map(|r| (r.x0, r.x1)).collect::<Vec<_>>();
// Dragged: the gutter sits at 150..170 instead of 215..235, and the columns follow it.
let d = with(r#"ColumnsPositions="0 150 170 450""#);
assert_eq!(d.spreads[0].pages[0].columns.positions, Some(vec![0.0, 150.0, 170.0, 450.0]));
assert_eq!(rects(&d), vec![(30.0, 180.0), (200.0, 480.0)]);
// The even set is dropped, so a later change of count or gutter is not frozen out. The
// geometry is the same either way, which is what makes keeping it pointless.
let even = with(r#"ColumnsPositions="0 215 235 450""#);
assert_eq!(even.spreads[0].pages[0].columns.positions, None);
assert_eq!(rects(&even), vec![(30.0, 245.0), (265.0, 480.0)]);
// Including when InDesign's own arithmetic leaves the last bits different.
assert_eq!(with(r#"ColumnsPositions="0 215.00000000000003 234.99999999999997 450""#).spreads[0].pages[0].columns.positions, None);
// Malformed sets are ignored rather than half-read: an odd count, three columns' worth for a
// count of two, a descending pair, a NaN, a set that stops short of the page's margin box, and
// a list far longer than any count allows. Each page keeps the even split.
let huge = format!(r#"ColumnsPositions="{}""#, (0..10_000).map(|i| i.to_string()).collect::<Vec<_>>().join(" "));
for bad in [
r#"ColumnsPositions="0 150 170""#,
r#"ColumnsPositions="0 100 120 200 220 450""#,
r#"ColumnsPositions="0 150 90 450""#,
r#"ColumnsPositions="0 x 170 450""#,
r#"ColumnsPositions="0 NaN 170 450""#,
r#"ColumnsPositions="0 150 170 400""#,
r#"ColumnsPositions="""#,
huge.as_str(),
] {
let d = with(bad);
assert_eq!(d.spreads[0].pages[0].columns.positions, None, "{}", bad.get(..bad.len().min(60)).unwrap_or(""));
assert_eq!(rects(&d), rects(&even));
}
// And they come back from an export, so the round trip keeps the dragged gutter.
let back = import_idml(&export_idml(&d)).unwrap();
assert_eq!(back.spreads[0].pages[0].columns.positions, Some(vec![0.0, 150.0, 170.0, 450.0]));
assert_eq!(rects(&back), rects(&d));
// A page nobody dragged still exports the even split, not a frozen copy of it.
assert_eq!(import_idml(&export_idml(&even)).unwrap().spreads[0].pages[0].columns.positions, None);
}
/// #1096: a ruler guide inside the gutter of a two-column page, written the way InDesign writes
/// it (a `<Page>` child after its `MarginPreference`, with `Properties`/`GuideColor`, `PageIndex`
/// and `GuideZone`), in a document without facing pages. A spread guide sits beside it.
#[test]
fn ruler_guide_in_a_column_gutter_imports_without_facing_pages() {
use designcraft_doc::Orientation::{Horizontal, Vertical};
let package = |view: &str| {
let prefs =
PREFS.replace(r#"FacingPages="true""#, r#"FacingPages="false""#).replace("</idPkg:Preferences>", &format!("{view}</idPkg:Preferences>"));
let spread = SPREAD.replace(
r#"<Page Self="p1" AppliedMaster="m1" GeometricBounds="0 0 700 500" ItemTransform="1 0 0 1 -500 -350">
<MarginPreference ColumnCount="3" ColumnGutter="12" Top="10" Bottom="10" Left="20" Right="30"/>
</Page>"#,
r#"<Page Self="p1" AppliedMaster="m1" GeometricBounds="0 0 700 500" ItemTransform="1 0 0 1 -500 -350" Name="1" OverrideList="">
<Properties><PageColor type="enumeration">UseMasterColor</PageColor></Properties>
<MarginPreference ColumnCount="2" ColumnGutter="20" Top="10" Bottom="10" Left="20" Right="30" ColumnDirection="Horizontal" ColumnsPositions="0 150 170 450"/>
<GridDataInformation FontStyle="Regular" PointSize="12"/>
<Guide Self="g1" OverriddenPageItemProps="" Orientation="Vertical" Location="180" FitToPage="true" ViewThreshold="0" Locked="false" ItemLayer="L1" PageIndex="0" GuideType="Ruler" GuideZone="0">
<Properties><GuideColor type="enumeration">Cyan</GuideColor></Properties>
</Guide>
<Guide Self="g2" OverriddenPageItemProps="" Orientation="Horizontal" Location="300" FitToPage="false" ViewThreshold="5" Locked="false" ItemLayer="L1" PageIndex="0" GuideType="Ruler" GuideZone="1">
<Properties><GuideColor type="enumeration">Cyan</GuideColor></Properties>
</Guide>
</Page>"#,
);
assert_ne!(spread, SPREAD, "the fixture's first page was replaced");
import_idml(&zip_files(&[
("designmap.xml", DESIGNMAP),
("Resources/Graphic.xml", GRAPHIC),
("Resources/Styles.xml", STYLES),
("Resources/Preferences.xml", &prefs),
("MasterSpreads/MasterSpread_m1.xml", MASTER),
("Spreads/Spread_sp1.xml", &spread),
("Stories/Story_s1.xml", STORY),
]))
.unwrap()
};
let guides =
|d: &Document| d.spreads[0].pages[0].guides.iter().map(|g| (g.orientation, g.position, g.spread, g.layer.is_some())).collect::<Vec<_>>();
// The first page starts at x 0 in our spread coordinates, so the spread's and the page's ruler
// origins agree; no ViewPreference means InDesign's default, the spread.
for view in ["", r#"<ViewPreference RulerOrigin="SpreadOrigin"/>"#, r#"<ViewPreference RulerOrigin="PageOrigin"/>"#] {
let first = package(view);
assert!(!first.settings.facing_pages);
assert_eq!(guides(&first), [(Vertical, 180.0, false, true), (Horizontal, 300.0, true, true)], "{view:?}");
// The guide sits between the page's left margin (20) and right margin (470).
let m = first.spreads[0].pages[0].margin_rect();
assert!(m.x0 < 180.0 && 180.0 < m.x1);
let back = import_idml(&export_idml(&first)).unwrap();
assert_eq!(guides(&back), guides(&first), "round trip, {view:?}");
}
}
/// Cell Options › Text › First Baseline, set by a cell style that a table style applies to its body
/// region (cells with `[None]` take it from there) and overridden on one cell.
const FIRST_BASELINE_STYLES: &str = r#"<idPkg:Styles xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging">
+1 -1
View File
@@ -118,7 +118,7 @@ fn select_font() -> usvg::FontSelectionFn<'static> {
return Some(id);
}
}
// A family (or generic family) that matches: usvg's own selection.
// A family (or generic family) that matches: the query below.
_ => break,
}
}
+1
View File
@@ -0,0 +1 @@
mod rgba_conversion_contract;
@@ -3,8 +3,6 @@
//! goes inside the write root. Paths that leave a root (`..`, another folder, a sibling with the
//! same prefix, a link) are refused, nothing from a refused file reaches the document, and nothing
//! is written outside.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::{Path, PathBuf};
+4
View File
@@ -0,0 +1,4 @@
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod automation_roots;
+1
View File
@@ -22,6 +22,7 @@ designcraft-images = { workspace = true }
image = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
chrono = { workspace = true }
# The placed-PDF version check names hayro's PdfVersion (krilla::pdf::Pdf::version returns it).
hayro-syntax = "0.7.2"
+22 -13
View File
@@ -457,20 +457,14 @@ fn date_time(t: i64) -> krilla::metadata::DateTime {
.utc_offset_minute(0)
}
/// Unix seconds (UTC) → (year, month, day, hour, minute, second), proleptic Gregorian.
/// Unix seconds (UTC) → (year, month, day, hour, minute, second), proleptic Gregorian. A time
/// outside chrono's year range still converts (the calendar repeats every 400 years, so the
/// instant is moved into range by whole cycles and only the year is carried, matching the old
/// `civil`), rather than resetting to the epoch. `date_time` applies the PDF `0..=9999` year clamp.
pub(crate) fn civil(t: i64) -> (i64, u8, u8, u8, u8, u8) {
let days = t.div_euclid(86_400);
let secs = t.rem_euclid(86_400);
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
(year, month as u8, day as u8, (secs / 3600) as u8, (secs / 60 % 60) as u8, (secs % 60) as u8)
use chrono::{Datelike, Timelike};
let (y, t) = designcraft_doc::time::utc_parts(t, 0);
(y, t.month() as u8, t.day() as u8, t.hour() as u8, t.minute() as u8, t.second() as u8)
}
/// A successful conversion and its name-independent diagnostic. Replayed once for each asset.
@@ -2244,4 +2238,19 @@ mod eps_tests {
assert_eq!(images, vec![("DeviceCMYK".to_string(), pixels.clone())], "{standard:?}");
}
}
#[test]
fn civil_dates_match_the_old_cycle_conversion_including_beyond_chrono() {
use super::civil;
assert_eq!(civil(0), (1970, 1, 1, 0, 0, 0));
assert_eq!(civil(951_782_400), (2000, 2, 29, 0, 0, 0));
assert_eq!(civil(1_791_504_000 + 3_723), (2026, 10, 9, 1, 2, 3));
// Beyond chrono's year range the old `civil` still converted; pin that output, never the
// epoch. `date_time` is what clamps the year to the PDF `0..=9999` range afterwards.
assert_eq!(civil(253_402_300_800), (10000, 1, 1, 0, 0, 0));
assert_eq!(civil(10_000_000_000_000), (318857, 5, 20, 17, 46, 40));
assert_eq!(civil(-10_000_000_000_000), (-314918, 8, 13, 6, 13, 20));
assert_eq!(civil(i64::MAX), (292277026596, 12, 4, 15, 30, 7));
assert_eq!(civil(i64::MIN), (-292277022657, 1, 27, 8, 29, 52));
}
}
-5
View File
@@ -1,5 +0,0 @@
//! Run public contracts with the production content-cache hasher.
#[path = "support/image_content_cache_contract.rs"]
mod contracts;
#[path = "support/image_fixtures.rs"]
mod image_fixtures;
@@ -42,7 +42,7 @@ fn mixed_gradient_export_survives_in_subprocess() {
const CHILD: &str = "DESIGNCRAFT_TEST_MIXED_GRADIENT_CHILD";
if std::env::var_os(CHILD).is_none() {
let out = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "mixed_gradient_export_survives_in_subprocess", "--nocapture"])
.args(["--exact", "gradient_color_spaces::mixed_gradient_export_survives_in_subprocess", "--nocapture"])
.env(CHILD, "1")
.output()
.unwrap();
@@ -0,0 +1,3 @@
//! Run public contracts with the production content-cache hasher.
#[path = "../support/image_content_cache_contract.rs"]
mod contracts;
+7
View File
@@ -0,0 +1,7 @@
#[path = "../support/image_fixtures.rs"]
mod image_fixtures;
mod gradient_color_spaces;
mod image_content_cache;
mod page_entry_search_contract;
mod spot_gradients;
+1
View File
@@ -19,6 +19,7 @@ skrifa = { workspace = true }
image = { workspace = true }
thiserror = { workspace = true }
zip = { workspace = true }
chrono = { workspace = true }
[dev-dependencies]
designcraft-fonts = { workspace = true, features = ["testing"] }
+15 -13
View File
@@ -290,20 +290,14 @@ fn core(p: &Package) -> String {
s
}
/// Unix seconds → UTC civil date and time (proleptic Gregorian).
/// Unix seconds → UTC civil date and time (proleptic Gregorian), year clamped to the W3CDTF
/// `0..=9999` range. A time outside chrono's year range still converts (the calendar repeats every
/// 400 years, so the instant is moved into range by whole cycles and only the year is carried,
/// matching the old `civil`), then the year is clamped — never a silent reset to the epoch.
fn civil(t: i64) -> (i64, u32, u32, u32, u32, u32) {
let days = t.div_euclid(86_400);
let secs = t.rem_euclid(86_400);
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
let y = yoe + era * 400 + i64::from(m <= 2);
(y.clamp(0, 9999), m, d, (secs / 3600) as u32, (secs / 60 % 60) as u32, (secs % 60) as u32)
use chrono::{Datelike, Timelike};
let (y, t) = designcraft_doc::time::utc_parts(t, 0);
(y.clamp(0, 9999), t.month(), t.day(), t.hour(), t.minute(), t.second())
}
/// A neutral theme: text sets its own fonts and colours, so these only matter for new objects.
@@ -331,5 +325,13 @@ mod tests {
assert_eq!(civil(0), (1970, 1, 1, 0, 0, 0));
assert_eq!(civil(951_782_400), (2000, 2, 29, 0, 0, 0));
assert_eq!(civil(1_791_504_000 + 3_723), (2026, 10, 9, 1, 2, 3));
// Beyond chrono's year range the old `civil` still converted and clamped only the year to
// the W3CDTF `0..=9999` range; pin that (month/day/time keep the real instant), never the
// epoch, in both directions and at the `i64` extremes.
assert_eq!(civil(253_402_300_800), (9999, 1, 1, 0, 0, 0));
assert_eq!(civil(10_000_000_000_000), (9999, 5, 20, 17, 46, 40));
assert_eq!(civil(-10_000_000_000_000), (0, 8, 13, 6, 13, 20));
assert_eq!(civil(i64::MAX), (9999, 12, 4, 15, 30, 7));
assert_eq!(civil(i64::MIN), (0, 1, 27, 8, 29, 52));
}
}
+2 -1
View File
@@ -17,9 +17,10 @@ designcraft-compose = { workspace = true }
vello_cpu = { workspace = true }
vello_common = { workspace = true }
image = { workspace = true }
hayro = { workspace = true }
craft-pdf = { workspace = true, features = ["raster"] }
designcraft-images = { workspace = true }
log = { workspace = true }
thiserror = { workspace = true }
serde = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
+113 -24
View File
@@ -1235,29 +1235,93 @@ pub fn is_pdf(bytes: &[u8]) -> bool {
/// Page count of a PDF.
pub fn pdf_page_count(bytes: &[u8]) -> Option<usize> {
let pdf = hayro::hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.to_vec())).ok()?;
let pdf = craft_pdf::open_slice(bytes, None).ok()?;
Some(pdf.pages().len())
}
/// Size of PDF page `page` (0-based), in points, as it shows (crop box, rotation applied).
pub fn pdf_page_size(bytes: &[u8], page: usize) -> Option<(f64, f64)> {
let pdf = hayro::hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.to_vec())).ok()?;
let pdf = craft_pdf::open_slice(bytes, None).ok()?;
let p = pdf.pages().get(page)?;
let (w, h) = p.render_dimensions();
Some((w as f64, h as f64))
}
/// PDF page `page` as PNG, its longer side about `max_side` pixels, with the `hidden` layers off;
/// only the `shown` part of it ([`designcraft_doc::Asset::shown_box`]) when given.
/// A placed page could not be rasterized completely for export.
#[derive(Debug, thiserror::Error)]
pub enum PdfRasterError {
#[error("{0}")]
Input(#[source] craft_pdf::OpenError),
#[error("{0}")]
Render(#[from] craft_pdf::raster::Error),
#[error("the PDF page could not be read or rendered")]
InvalidPage,
#[error("PDF page {page} is unavailable; choose an existing page in Image Import Options or place the replacement PDF again")]
MissingPage { page: usize },
#[error("the selected PDF layers could not be hidden")]
LayerRewrite,
#[error("an image could not be decoded or exceeded the PDF renderer's resource limits; repair or replace this placed PDF before exporting again")]
ImageDecode,
#[error("the PDF renderer encountered an unsupported font")]
UnsupportedFont,
#[error(
"the PDF renderer could not read all page content or reached its resource limits; repair or replace this placed PDF before exporting again"
)]
ContentTruncated,
}
/// Reject oversized PDFs before placement, layer rewriting or output allocation.
pub fn check_pdf_input_size(size: usize) -> Result<(), PdfRasterError> {
craft_pdf::check_input_size(size).map_err(PdfRasterError::Input)
}
/// PDF page `page` as PNG, its longer side about `max_side` pixels, with the `hidden` layers off.
/// Only the `shown` part ([`designcraft_doc::Asset::shown_box`]) is kept when given.
/// Returns `None` on the same failures as [`pdf_page_png_checked`].
pub fn pdf_page_png(bytes: &[u8], page: usize, max_side: u32, hidden: &[String], shown: Option<[f64; 4]>) -> Option<Vec<u8>> {
let data = if hidden.is_empty() { bytes.to_vec() } else { pdf_hide_layers(bytes, hidden).unwrap_or_else(|| bytes.to_vec()) };
let pm = render_pdf_page(&data, page, max_side)?;
pdf_page_png_checked(bytes, page, max_side, hidden, shown).ok()
}
/// Rasterize a placed page for export, preserving available interpreter diagnostics.
/// This cannot detect backend approximations that do not emit an interpreter warning.
pub fn pdf_page_png_checked(bytes: &[u8], page: usize, max_side: u32, hidden: &[String], shown: Option<[f64; 4]>) -> Result<Vec<u8>, PdfRasterError> {
check_pdf_input_size(bytes.len())?;
let data = if hidden.is_empty() {
std::borrow::Cow::Borrowed(bytes)
} else {
std::borrow::Cow::Owned(pdf_hide_layers(bytes, hidden).ok_or(PdfRasterError::LayerRewrite)?)
};
let raster = render_pdf_raster(&data, page, max_side)?;
if raster.diagnostics.image_decode_failed {
return Err(PdfRasterError::ImageDecode);
}
if raster.diagnostics.unsupported_font {
return Err(PdfRasterError::UnsupportedFont);
}
if raster.diagnostics.content_truncated {
return Err(PdfRasterError::ContentTruncated);
}
let pm = raster_pixmap(&raster);
let pm = match shown {
Some(frac) => crop_pixmap(&pm, frac)?,
Some(frac) => crop_pixmap(&pm, frac).ok_or(PdfRasterError::InvalidPage)?,
None => pm,
};
let pixels: Vec<u8> = pm.data().iter().flat_map(|p| [p.r, p.g, p.b, p.a]).collect();
Some(Rendered { width: pm.width() as u32, height: pm.height() as u32, pixels }.to_png())
let pixels = pm.data().iter().flat_map(|p| [p.r, p.g, p.b, p.a]).collect();
Ok(Rendered { width: u32::from(pm.width()), height: u32::from(pm.height()), pixels }.to_png())
}
/// Rasterize PDF page `page` so its longer side is about `max_side` pixels (screen display).
/// Export callers that need failure diagnostics use [`pdf_page_png_checked`].
pub fn render_pdf_page(bytes: &[u8], page: usize, max_side: u32) -> Option<Pixmap> {
#[cfg(test)]
PDF_RENDERS.with(|n| n.set(n.get() + 1));
let raster = render_pdf_raster(bytes, page, max_side).ok()?;
Some(raster_pixmap(&raster))
}
fn raster_pixmap(raster: &craft_pdf::raster::Raster) -> Pixmap {
let data = raster.bytes().as_chunks::<4>().0.iter().map(|c| vello_cpu::color::PremulRgba8 { r: c[0], g: c[1], b: c[2], a: c[3] }).collect();
Pixmap::from_parts(data, raster.width as u16, raster.height as u16)
}
#[cfg(test)]
@@ -1266,21 +1330,46 @@ thread_local! {
static PDF_RENDERS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Rasterize PDF page `page` so its longer side is about `max_side` pixels (screen display; PDF
/// export embeds the page as vectors).
pub fn render_pdf_page(bytes: &[u8], page: usize, max_side: u32) -> Option<Pixmap> {
#[cfg(test)]
PDF_RENDERS.with(|n| n.set(n.get() + 1));
let pdf = hayro::hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.to_vec())).ok()?;
let p = pdf.pages().get(page)?;
fn render_pdf_raster(bytes: &[u8], page: usize, max_side: u32) -> Result<craft_pdf::raster::Raster, PdfRasterError> {
let pdf = craft_pdf::open_slice(bytes, None).map_err(|e| match e {
craft_pdf::OpenError::Load(_) => PdfRasterError::InvalidPage,
other => PdfRasterError::Input(other),
})?;
let p = pdf.pages().get(page).ok_or(PdfRasterError::MissingPage { page: page.saturating_add(1) })?;
let (w, h) = p.render_dimensions();
let scale = (max_side as f32 / w.max(h).max(1.0)).min(8.0);
let rs = hayro::RenderSettings { x_scale: scale, y_scale: scale, ..Default::default() };
let pm = hayro::render(p, &hayro::RenderCache::new(), &hayro::hayro_interpret::InterpreterSettings::default(), &rs);
let (pw, ph) = (pm.width(), pm.height());
let data: Vec<vello_cpu::color::PremulRgba8> =
pm.data_as_u8_slice().as_chunks::<4>().0.iter().map(|c| vello_cpu::color::PremulRgba8 { r: c[0], g: c[1], b: c[2], a: c[3] }).collect();
Some(Pixmap::from_parts(data, pw, ph))
craft_pdf::raster::render(
p,
&craft_pdf::raster::RenderCache::new(),
&craft_pdf::interpret::InterpreterSettings::default(),
&craft_pdf::raster::Options { scale: [scale; 2], ..Default::default() },
)
.map_err(PdfRasterError::Render)
}
/// A page box of a PDF page (`crop`, `trim`, `bleed`, `art`, `media`) as (x, y, w, h) in points
/// from the top-left of the page as rendered (its visible crop box); missing boxes fall back to the
/// crop box, as PDF readers do.
pub fn pdf_page_box(bytes: &[u8], page: usize, kind: &str) -> Option<(f64, f64, f64, f64)> {
use craft_pdf::syntax::object::{Rect as PRect, dict::keys};
let pdf = craft_pdf::open_slice(bytes, None).ok()?;
let p = pdf.pages().get(page)?;
let visible = p.intersected_crop_box();
let page_box = match kind {
"media" => p.media_box(),
"trim" | "bleed" | "art" => {
let key: &[u8] = match kind {
"trim" => keys::TRIM_BOX,
"bleed" => keys::BLEED_BOX,
_ => keys::ART_BOX,
};
p.raw().get::<PRect>(key).map(|b| b.intersect(p.media_box())).unwrap_or(visible)
}
_ => visible,
};
let transform = Affine::new(p.initial_transform(true).as_coeffs());
let b = transform.transform_rect_bbox(Rect::new(page_box.x0, page_box.y0, page_box.x1, page_box.y1));
b.is_finite().then_some((b.x0, b.y0, b.width(), b.height()))
}
/// The part of PDF page `page` that `crop` shows: its left, top, right and bottom edges as
@@ -1289,10 +1378,10 @@ pub fn render_pdf_page(bytes: &[u8], page: usize, max_side: u32) -> Option<Pixma
/// are the bounds of what the page draws, found from its drawing operations without rendering it
/// (the whole page when it draws nothing).
pub fn pdf_crop_box(bytes: &[u8], page: usize, crop: designcraft_doc::PdfCrop) -> Option<([f64; 4], (f64, f64))> {
use craft_pdf::syntax::object::{Rect as PRect, dict::keys};
use designcraft_doc::PdfCrop;
use hayro::hayro_syntax::object::{Rect as PRect, dict::keys};
const WHOLE: [f64; 4] = [0.0, 0.0, 1.0, 1.0];
let pdf = hayro::hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.to_vec())).ok()?;
let pdf = craft_pdf::open_slice(bytes, None).ok()?;
let p = pdf.pages().get(page)?;
let (w, h) = p.render_dimensions();
let (w, h) = (f64::from(w), f64::from(h));
+3 -3
View File
@@ -2,8 +2,8 @@
//! of every painted path, glyph and image, each cut to the clip in force when it's painted.
//! Nothing is rasterized, so measuring a page costs a fraction of drawing it.
use hayro::hayro_interpret::font::Glyph;
use hayro::hayro_interpret::{
use craft_pdf::interpret::font::Glyph;
use craft_pdf::interpret::{
BlendMode, ClipPath, Context, Device, FillRule, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, RectExt,
SoftMask, TransformExt, interpret_page,
};
@@ -12,7 +12,7 @@ use vello_cpu::kurbo::{self, Affine, BezPath, Rect, Shape};
/// The bounds of what PDF page `page` draws, as fractions of the page as rendered (its visible
/// crop box, rotated as it displays): left, top, right, bottom. `None` when it draws nothing.
pub(crate) fn page_content_bounds(bytes: &[u8], page: usize) -> Option<[f64; 4]> {
let pdf = hayro::hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.to_vec())).ok()?;
let pdf = craft_pdf::open_slice(bytes, None).ok()?;
let p = pdf.pages().get(page)?;
let (w, h) = p.render_dimensions();
let (w, h) = (f64::from(w), f64::from(h));
+7 -7
View File
@@ -5,8 +5,8 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use hayro::hayro_syntax::Pdf;
use hayro::hayro_syntax::object::{Array, Dict, Name, ObjectIdentifier};
use craft_pdf::syntax::Pdf;
use craft_pdf::syntax::object::{Array, Dict, Name, ObjectIdentifier};
/// (object number, name, on by default) of each layer.
fn ocgs(pdf: &Pdf) -> Vec<(i32, String, bool)> {
@@ -28,7 +28,7 @@ fn ocgs(pdf: &Pdf) -> Vec<(i32, String, bool)> {
.filter_map(|r| {
let d = xref.get::<Dict<'_>>(ObjectIdentifier::from(r))?;
let name = d
.get::<hayro::hayro_syntax::object::String<'_>>(b"Name")
.get::<craft_pdf::syntax::object::String<'_>>(b"Name")
.map(|s| String::from_utf8_lossy(s.as_bytes()).to_string())
.unwrap_or_default();
let visible = if base_off { on.contains(&r.obj_number) } else { !off.contains(&r.obj_number) };
@@ -39,13 +39,13 @@ fn ocgs(pdf: &Pdf) -> Vec<(i32, String, bool)> {
/// The layers of a placed PDF: (name, visible by default).
pub fn pdf_layers(bytes: &[u8]) -> Vec<(String, bool)> {
Pdf::new(Arc::new(bytes.to_vec())).map(|p| ocgs(&p).into_iter().map(|(_, n, v)| (n, v)).collect()).unwrap_or_default()
craft_pdf::open_slice(bytes, None).map(|p| ocgs(&p).into_iter().map(|(_, n, v)| (n, v)).collect()).unwrap_or_default()
}
/// `bytes` with exactly the layers named in `hidden` off (the others on). `None` when the file
/// has no such layers or can't be read.
pub fn pdf_hide_layers(bytes: &[u8], hidden: &[String]) -> Option<Vec<u8>> {
let pdf = Pdf::new(Arc::new(bytes.to_vec())).ok()?;
let pdf = craft_pdf::open_slice(bytes, None).ok()?;
let layers = ocgs(&pdf);
if layers.is_empty() {
return None;
@@ -74,8 +74,8 @@ pub fn pdf_hide_layers(bytes: &[u8], hidden: &[String]) -> Option<Vec<u8>> {
);
let xref_at = out.len();
let prev = {
let tail = String::from_utf8_lossy(&bytes[bytes.len().saturating_sub(64)..]).to_string();
tail.rsplit("startxref").next().and_then(|t| t.split_whitespace().next()).and_then(|n| n.parse::<usize>().ok())?
let tail = String::from_utf8_lossy(&bytes[bytes.len().saturating_sub(1024)..]).to_string();
tail.rsplit_once("startxref").and_then(|(_, t)| t.split_whitespace().next()).and_then(|n| n.parse::<usize>().ok())?
};
out.extend_from_slice(
format!("xref\n{root} 1\n{at:010} 00000 n\r\ntrailer\n<</Size {}/Root {root} 0 R/Prev {prev}>>\nstartxref\n{xref_at}\n%%EOF\n", root + 1)
@@ -3,7 +3,7 @@
//!
//! Goldens live in `tests/golden/*.png` and are compared with a tolerance (antialiasing and blur
//! rounding may differ by a few levels between SIMD levels). Regenerate them after an intended
//! change with `DESIGNCRAFT_BLESS=1 cargo test -p designcraft-render --test effects`.
//! change with `DESIGNCRAFT_BLESS=1 cargo test -p designcraft-render --test it effects::`.
use designcraft_compose::Cache;
use designcraft_doc::build::NewDocument;
+12
View File
@@ -0,0 +1,12 @@
mod blend_over_paper;
mod colorize;
mod effects;
mod glyph_retention;
mod gradient_inks;
mod gradients;
mod pdf_geometry;
mod perf;
mod placed_pdf;
mod stroke_alignment;
mod text_frame_bottom;
mod text_stroke_behind;
+61
View File
@@ -0,0 +1,61 @@
fn page(width: f32, height: f32) -> Vec<u8> {
let content = format!("1 0 0 rg 0 0 {width} {height} re f");
format!(
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 {width} {height}] /Contents 4 0 R >> endobj\n4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF",
content.len()
)
.into_bytes()
}
#[test]
fn thin_pdf_page_has_visible_preview_and_export_pixels() {
let bytes = page(1000.0, 0.2);
let preview = designcraft_render::render_pdf_page(&bytes, 0, 700).unwrap();
assert_eq!((preview.width(), preview.height()), (700, 1));
assert!(preview.data().iter().any(|pixel| pixel.a > 0));
let png = designcraft_render::pdf_page_png_checked(&bytes, 0, 700, &[], None).unwrap();
let image = image::load_from_memory(&png).unwrap().to_rgba8();
assert_eq!(image.dimensions(), (700, 1));
assert!(image.pixels().any(|pixel| pixel[3] > 0));
}
#[test]
fn empty_media_box_preserves_the_parsers_default_page_repair() {
let bytes = page(0.0, 0.0);
let missing = String::from_utf8(bytes.clone()).unwrap().replace("/MediaBox [0 0 0 0]", "");
let size = designcraft_render::pdf_page_size(&bytes, 0).unwrap();
assert!(size.0 > 0.0 && size.1 > 0.0);
assert_eq!(Some(size), designcraft_render::pdf_page_size(missing.as_bytes(), 0));
let preview = designcraft_render::render_pdf_page(&bytes, 0, 700).unwrap();
let png = designcraft_render::pdf_page_png_checked(&bytes, 0, 700, &[], None).unwrap();
let image = image::load_from_memory(&png).unwrap();
assert_eq!((image.width(), image.height()), (u32::from(preview.width()), u32::from(preview.height())));
}
#[test]
fn pdf_input_limit_is_actionable_before_placement() {
let error = designcraft_render::check_pdf_input_size(craft_pdf::MAX_INPUT_BYTES + 1).unwrap_err();
assert!(matches!(error, designcraft_render::PdfRasterError::Input(_)));
assert!(error.to_string().contains("input limit"));
}
#[test]
fn pdf_boxes_match_scaled_and_rotated_page_coordinates() {
for (rotation, size, trim, media) in [
(0, (200.0, 120.0), (20.0, 40.0, 140.0, 60.0), (-20.0, -40.0, 400.0, 200.0)),
(90, (120.0, 200.0), (20.0, 20.0, 60.0, 140.0), (-40.0, -20.0, 200.0, 400.0)),
(180, (200.0, 120.0), (40.0, 20.0, 140.0, 60.0), (-180.0, -40.0, 400.0, 200.0)),
(270, (120.0, 200.0), (40.0, 40.0, 60.0, 140.0), (-40.0, -180.0, 200.0, 400.0)),
] {
let bytes = String::from_utf8(page(200.0, 100.0)).unwrap().replace(
"/MediaBox [0 0 200 100]",
&format!("/MediaBox [0 0 200 100] /CropBox [10 20 110 80] /TrimBox [20 30 90 60] /UserUnit 2 /Rotate {rotation}"),
);
let bytes = bytes.as_bytes();
assert_eq!(designcraft_render::pdf_page_size(bytes, 0), Some(size), "rotation {rotation}");
assert_eq!(designcraft_render::pdf_page_box(bytes, 0, "crop"), Some((0.0, 0.0, size.0, size.1)), "rotation {rotation}");
assert_eq!(designcraft_render::pdf_page_box(bytes, 0, "trim"), Some(trim), "rotation {rotation}");
assert_eq!(designcraft_render::pdf_page_box(bytes, 0, "media"), Some(media), "rotation {rotation}");
assert_eq!(designcraft_render::pdf_page_box(bytes, 0, "art"), Some((0.0, 0.0, size.0, size.1)), "missing box, rotation {rotation}");
}
}
@@ -1,6 +1,6 @@
//! Renderer caching/culling behaviour, and a render-time regression test (`#[ignore]`: wall-clock
//! timings are only meaningful on an idle machine — run with
//! `cargo test --release -p designcraft-render --test perf -- --ignored`).
//! `cargo test --release -p designcraft-render --test it perf:: -- --ignored`).
use std::time::Instant;

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