mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-10 20:41:55 +00:00
Merge upstream main and preserve Linux GNOME decorations
Integrate updated DesignCraft notices, dependencies and package scripts while keeping Adwaita's MIT notice.
This commit is contained in:
commit
c2b2d260a5
217 files changed
+10798
-1823
No files matched your search
@@ -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]"
|
||||
@@ -0,0 +1,29 @@
|
||||
name: CI
|
||||
|
||||
# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
|
||||
# suite's shared gate workflow in craft-common-actions.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# A newer push to a PR branch cancels its older run; every push to main runs to the end.
|
||||
concurrency:
|
||||
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
|
||||
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
|
||||
with:
|
||||
# Same craft-fonts commit as release.yml.
|
||||
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
|
||||
timeout-minutes: 90
|
||||
packages: >-
|
||||
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
|
||||
libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
|
||||
@@ -2,16 +2,17 @@
|
||||
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
|
||||
name: FreeBSD
|
||||
|
||||
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
|
||||
# request: a drifted dependency still shows up within a day, and always before a release ships.
|
||||
# PRs that change the FreeBSD workflow itself still run it.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [release]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- "crates/fonts/**"
|
||||
- .github/workflows/freebsd.yml
|
||||
schedule:
|
||||
- cron: "27 2 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -26,4 +27,4 @@ jobs:
|
||||
# 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/freebsd.yml@main
|
||||
with:
|
||||
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
|
||||
@@ -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
|
||||
@@ -11,20 +11,65 @@ on:
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
git-sources:
|
||||
name: Git dependencies come from storytold
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Git dependencies come from storytold
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
|
||||
# Source replacement, patches and path overrides in cargo config never show up
|
||||
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
|
||||
# Cargo still reads the legacy .cargo/config.
|
||||
python3 - <<'PY'
|
||||
import os, sys, tomllib
|
||||
for path in (".cargo/config", ".cargo/config.toml"):
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
config = tomllib.load(open(path, "rb"))
|
||||
replaced = [name for name, source in config.get("source", {}).items()
|
||||
if isinstance(source, dict) and "replace-with" in source]
|
||||
if replaced or "patch" in config or "paths" in config:
|
||||
print(f"{path} replaces, patches or overrides a source")
|
||||
sys.exit(1)
|
||||
PY
|
||||
# Fails when Cargo.lock is out of date with the manifests.
|
||||
cargo metadata --locked --format-version 1 > /dev/null
|
||||
python3 - <<'PY'
|
||||
import sys, tomllib
|
||||
lock = tomllib.load(open("Cargo.lock", "rb"))
|
||||
bad = sorted({p["source"] for p in lock.get("package", [])
|
||||
if p.get("source", "").startswith("git+")
|
||||
and not p["source"].startswith("git+https://github.com/storytold/")})
|
||||
if bad:
|
||||
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
|
||||
sys.exit(1)
|
||||
PY
|
||||
|
||||
release:
|
||||
needs: git-sources
|
||||
permissions:
|
||||
contents: write
|
||||
# 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/release.yml@main
|
||||
with:
|
||||
app-name: designcraft
|
||||
app-title: DesignCraft
|
||||
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
|
||||
web-craft-fonts: true
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
name: SonarCloud
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: sonarcloud-${{ github.ref }}
|
||||
# A push to main is the run that publishes coverage; superseded runs there would never finish.
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
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
|
||||
@@ -4,19 +4,17 @@
|
||||
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
|
||||
name: Windows ARM64
|
||||
|
||||
# Nightly on main and on pushes to `release` (dependency and runner-image drift shows up within a
|
||||
# day, and always before a release ships), and on PRs that change the Windows packaging itself.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "3 5 * * *"
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [release]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- "**/build.rs"
|
||||
- .github/workflows/windows-arm64.yml
|
||||
- packaging/windows/**
|
||||
schedule:
|
||||
- cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -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`).
|
||||
|
||||
|
||||
@@ -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
+48
-31
@@ -683,6 +683,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"
|
||||
@@ -832,8 +841,12 @@ source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"hayro",
|
||||
"hayro-interpret",
|
||||
"hayro-syntax",
|
||||
"krilla",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
@@ -929,7 +942,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"designcraft-engine",
|
||||
@@ -951,7 +964,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-cli"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"designcraft-color",
|
||||
"designcraft-compose",
|
||||
@@ -967,7 +980,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-color"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"craft-cms",
|
||||
"kurbo",
|
||||
@@ -979,7 +992,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-compose"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"designcraft-color",
|
||||
"designcraft-doc",
|
||||
@@ -999,8 +1012,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-doc"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"designcraft-color",
|
||||
"designcraft-geom",
|
||||
"proptest",
|
||||
@@ -1011,7 +1025,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-engine"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"caseless",
|
||||
"craft-atomic",
|
||||
@@ -1050,8 +1064,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-epub"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"designcraft-color",
|
||||
"designcraft-doc",
|
||||
"zip",
|
||||
@@ -1059,7 +1074,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-fonts"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"core-text",
|
||||
"harfrust 0.13.3",
|
||||
@@ -1072,7 +1087,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-format"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"designcraft-doc",
|
||||
"designcraft-images",
|
||||
@@ -1084,7 +1099,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-geom"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"flo_curves",
|
||||
"kurbo",
|
||||
@@ -1097,7 +1112,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-idml"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"designcraft-color",
|
||||
"designcraft-compose",
|
||||
@@ -1112,7 +1127,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-images"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"craft-cms",
|
||||
"designcraft-color",
|
||||
@@ -1127,7 +1142,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-mcp"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"designcraft-engine",
|
||||
"designcraft-geom",
|
||||
@@ -1140,8 +1155,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-pdf"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-pdf",
|
||||
"designcraft-color",
|
||||
"designcraft-compose",
|
||||
@@ -1162,8 +1178,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-pptx"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"designcraft-color",
|
||||
"designcraft-compose",
|
||||
"designcraft-doc",
|
||||
@@ -1179,25 +1196,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",
|
||||
@@ -1206,7 +1224,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-tools"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"designcraft-color",
|
||||
"designcraft-compose",
|
||||
@@ -1218,7 +1236,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-ui-egui"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"designcraft-color",
|
||||
@@ -1245,7 +1263,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-web"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"designcraft-engine",
|
||||
"designcraft-format",
|
||||
@@ -2105,7 +2123,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "hayro"
|
||||
version = "0.7.1"
|
||||
source = "git+https://github.com/storytold/pdfcraft?rev=b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9#b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"hayro-interpret",
|
||||
@@ -2125,8 +2143,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a"
|
||||
[[package]]
|
||||
name = "hayro-cmap"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d285dc30731c8485de5fa732fbdf2b3affdf01e4da7c2135022ca6fa664bf6"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"hayro-postscript",
|
||||
@@ -2135,7 +2152,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "hayro-interpret"
|
||||
version = "0.7.0"
|
||||
source = "git+https://github.com/storytold/pdfcraft?rev=b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9#b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"hayro-cmap",
|
||||
@@ -2153,7 +2170,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "hayro-jbig2"
|
||||
version = "0.3.0"
|
||||
source = "git+https://github.com/storytold/pdfcraft?rev=b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9#b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
|
||||
dependencies = [
|
||||
"fearless_simd",
|
||||
"hayro-ccitt",
|
||||
@@ -2177,7 +2194,7 @@ checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa"
|
||||
[[package]]
|
||||
name = "hayro-syntax"
|
||||
version = "0.7.2"
|
||||
source = "git+https://github.com/storytold/pdfcraft?rev=b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9#b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hayro-ccitt",
|
||||
@@ -2541,7 +2558,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bumpalo",
|
||||
@@ -5658,7 +5675,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",
|
||||
@@ -5833,7 +5850,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+17
-11
@@ -4,15 +4,15 @@ 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.
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.1.0", default-features = false }
|
||||
# Shared reader and writer; publish the immutable revision before this consumer.
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.1.0", default-features = false }
|
||||
# internal
|
||||
designcraft-geom = { path = "crates/geom" }
|
||||
designcraft-color = { path = "crates/color" }
|
||||
@@ -39,6 +39,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"
|
||||
@@ -66,7 +68,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
|
||||
@@ -102,11 +103,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
|
||||
@@ -116,10 +122,10 @@ 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/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.8.2" }
|
||||
# The hayro crates from pdfcraft's vendor/ directory, with the patches its README lists.
|
||||
hayro = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" }
|
||||
hayro-interpret = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" }
|
||||
hayro-syntax = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" }
|
||||
hayro-jbig2 = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.8.2" }
|
||||
winit = { git = "https://github.com/storytold/winit.git", rev = "3d51077fd1a966f84df9ef287839dc84fd5b4ca7" }
|
||||
hayro = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.7.1" }
|
||||
hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.7.0" }
|
||||
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.7.2" }
|
||||
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.3.0" }
|
||||
hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.1.0" }
|
||||
@@ -81,16 +81,40 @@ 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, hayro-jbig2 0.3.0 and
|
||||
hayro-cmap 0.1.0 are fetched from https://github.com/storytold/craft-libs at revision
|
||||
9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef. The first four source snapshots were transferred
|
||||
from https://github.com/storytold/pdfcraft at revision
|
||||
7437bde378f43856eada40af25b983812e615e8a and hayro-cmap at revision
|
||||
a2ec27b4775f53c40cad3456936d9406611e3a8d, 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/9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef/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.
|
||||
|
||||
Linux Wayland window decorations
|
||||
-------------------------------
|
||||
sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita,
|
||||
|
||||
@@ -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">
|
||||
|
||||
+44
-2
@@ -11,6 +11,14 @@
|
||||
|
||||
DesignCraft aims at full Adobe InDesign parity — and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web.
|
||||
|
||||
- **2026-10-23** · PDF layer choices now validate before editing all targets; rejected rewrites
|
||||
leave document/undo state intact. Null trailer entries remain unencrypted, and unsupported
|
||||
all-content crops retain the whole page rather than silently clipping hidden layers.
|
||||
- **2026-10-23** · PDF layer visibility variants retain catalog/configuration entries and full
|
||||
object references; bounded incremental updates replace the existing catalog. Synthetic
|
||||
regression tests cover metadata, output-intent references, nonzero generations and
|
||||
forward Prev links; repeated cross-reference offsets are rejected explicitly. No readiness percentage change.
|
||||
|
||||
## Status (2026-10-01)
|
||||
|
||||
**Working today**
|
||||
@@ -33,7 +41,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.
|
||||
@@ -42,7 +50,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
|
||||
- Graphics backend chosen before the window exists (DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, OpenGL as the fallback; `WGPU_BACKEND` overrides) with a start-up fallback: a backend whose start never showed a frame is skipped at the next start and the status bar says so (`gpu.json`). Found on an AMD hybrid-graphics laptop whose Vulkan driver faulted at the first present.
|
||||
- Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool).
|
||||
- Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`.
|
||||
- Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`).
|
||||
- Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow threads frames on the pages that follow, then adds pages at the end of the document until the text fits; Remove Styles option (`designcraft-textimport`).
|
||||
- Gradient Feather (⇧G tool, `object.gradientFeather`): objects fade along a linear or radial opacity gradient, on screen and as a soft mask in PDF.
|
||||
- Script Label (`object.label`, `object.findByLabel`), alt text (`object.altText`), Isolate Blending / Knockout Group (`object.transparencyGroup`; on screen, PDF isolates), Table ▸ Sort (`table.sortRows`, numeric-aware, header/footer fixed).
|
||||
- File ▸ Export Text (`file.exportText`): a story as Text Only or RTF (fonts, styles, alignment, indents, tables as tab-separated rows); Export EPUB now asks for a path in the UI.
|
||||
@@ -301,6 +309,8 @@ measured); Ready estimated.
|
||||
## Languages
|
||||
|
||||
Interface strings translated, of 1,724 measured. Detail: [docs/localization-parity.md](docs/localization-parity.md).
|
||||
A string without a translation shows in English and fails no check; `cargo xtask i18n-report`
|
||||
(also a `cargo xtask ci` step) lists the missing strings per language.
|
||||
|
||||
| Language | Code | Status | Translated |
|
||||
|---|---|---|---|
|
||||
@@ -348,6 +358,28 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
|
||||
|
||||
Newest first.
|
||||
|
||||
- **2026-10-24** · Custom-positioned anchored objects honour Relative to Spine (`anchored.options {spineRelative}`, IDML `SpineRelative`): the settings describe a left-hand page and mirror on a right-hand page, so marginal notes stay in the outside margin on both pages of a spread. On right-hand pages they took the left-hand placement and landed in the inside margin, across the spine or over the body text. No readiness percentage change.
|
||||
- **2026-10-23** · Save and export dialogs (Save As, Save a Copy, Export PDF and the other exports, Package, libraries and books) start in the active document's folder once it has been saved, and for a document never saved in the folder of the last file saved or exported, remembered across sessions (#1653). A folder deleted since falls back to the system's choice. No readiness percentage change.
|
||||
- **2026-10-22** · Autoflow (Shift-click with the loaded text cursor, autoflow on Place, `text.thread {autoflow}`) fills the pages that follow before it adds any, as InDesign does: one frame per column on each following page, whatever else the page holds, then new pages at the end of the document. It inserted pages right after the story's last frame, in front of the document's own pages. Frames it set past the end of the text go again; a page the document had stays, and a page autoflow added only for such a frame goes with it. Smart Text Reflow of the primary text frame is unchanged. 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-23** · Translations: a missing string shows in English and fails no test; `cargo xtask i18n-report`, run by `cargo xtask ci`, prints the missing strings per language. Tests keep duplicate-key and placeholder checks and the language mechanics (lookup, context captions, plural captions, right-to-left). 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-17** · PDF/X-4 export warns about fonts drawing visible text without an embedded
|
||||
font program, including placed PDF forms; `validate-pdf` reports the font and page (#909).
|
||||
Invisible OCR text is exempt. Detection only: fonts are not embedded or outlined automatically.
|
||||
Repeated forms reuse context-aware font findings; a document-wide operation limit and a form-nesting depth limit
|
||||
report incomplete checks instead of allowing excessive nested-form traversal.
|
||||
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.
|
||||
@@ -355,6 +387,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.
|
||||
@@ -638,3 +678,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.
|
||||
@@ -39,7 +39,7 @@ use designcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// The usage block. Shared by `--help` (stdout, success) and an unknown command (stderr, failure).
|
||||
const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR]\n the two roots confine every file the agent's commands read (open, placed images, data sources, libraries) and\n write (save, export, package) to those folders, links followed. A root left out grants none of its access.\n They run the in-process server, so they conflict with --connect (a running app is not confined by them).\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli links\n designcraft-cli --version";
|
||||
const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR]\n the two roots confine every file the agent's commands read (open, placed images, data sources, libraries) and\n write (save, export, package) to those folders, links followed. A root left out grants none of its access.\n They run the in-process server, so they conflict with --connect (a running app is not confined by them).\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli validate-pdf FILE.pdf # built-in PDF/X-4 checks (including visible unembedded fonts)\n designcraft-cli links\n designcraft-cli --version";
|
||||
|
||||
/// `script --help`: the script syntax, otherwise only in `describe script.run` and docs/agents.md.
|
||||
const SCRIPT_HELP: &str = r#"usage: designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]
|
||||
@@ -124,7 +124,7 @@ fn main() -> ExitCode {
|
||||
Some("bench") => report(perf::bench(&args[1..])),
|
||||
Some("validate-pdf") => report(validate_pdf(args.get(1).map(String::as_str))),
|
||||
Some("links") => {
|
||||
use designcraft_engine::links::*;
|
||||
use designcraft_engine::links::{APP_PAGE, DISCORD, GITHUB, ISSUES, WEBSITE};
|
||||
outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}\nIssues {ISSUES}");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
@@ -345,33 +345,7 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
"--export" => {
|
||||
let out = val()?;
|
||||
match Export::of(&out)? {
|
||||
Export::Image => export(&mut s, &out, page, scale)?,
|
||||
Export::Pdf => {
|
||||
let mut p = pdf_opts.clone();
|
||||
p["path"] = json!(out);
|
||||
let r = s.execute("file.exportPdf", &p).map_err(|e| e.to_string())?;
|
||||
eprintln!("wrote {out} ({} pages, {} bytes)", r["pages"], r["bytes"]);
|
||||
for w in r["warnings"].as_array().into_iter().flatten() {
|
||||
eprintln!("warning: {}", w.as_str().unwrap_or_default());
|
||||
}
|
||||
}
|
||||
Export::Document => {
|
||||
s.execute("file.saveAs", &json!({"path": out})).map_err(|e| e.to_string())?;
|
||||
eprintln!("saved {out}");
|
||||
}
|
||||
Export::Pptx => {
|
||||
let r = s.execute("file.exportPptx", &json!({"path": out})).map_err(|e| e.to_string())?;
|
||||
eprintln!("wrote {out} ({} slides, {} bytes)", r["slides"], r["bytes"]);
|
||||
for w in r["warnings"].as_array().into_iter().flatten() {
|
||||
eprintln!("warning: {}", w.as_str().unwrap_or_default());
|
||||
}
|
||||
}
|
||||
Export::Command(cmd) => {
|
||||
let r = s.execute(cmd, &json!({"path": out})).map_err(|e| e.to_string())?;
|
||||
eprintln!("wrote {out} ({} bytes)", r["bytes"]);
|
||||
}
|
||||
}
|
||||
handle_export(&mut s, &out, page, scale, &pdf_opts)?;
|
||||
}
|
||||
"--all-pages" => {
|
||||
let dir = val()?;
|
||||
@@ -387,6 +361,37 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_export(s: &mut Session, out: &str, page: usize, scale: f64, pdf_opts: &Value) -> Result<(), String> {
|
||||
match Export::of(out)? {
|
||||
Export::Image => export(s, out, page, scale)?,
|
||||
Export::Pdf => {
|
||||
let mut p = pdf_opts.clone();
|
||||
p["path"] = json!(out);
|
||||
let r = s.execute("file.exportPdf", &p).map_err(|e| e.to_string())?;
|
||||
eprintln!("wrote {out} ({} pages, {} bytes)", r["pages"], r["bytes"]);
|
||||
for w in r["warnings"].as_array().into_iter().flatten() {
|
||||
eprintln!("warning: {}", w.as_str().unwrap_or_default());
|
||||
}
|
||||
}
|
||||
Export::Document => {
|
||||
s.execute("file.saveAs", &json!({"path": out})).map_err(|e| e.to_string())?;
|
||||
eprintln!("saved {out}");
|
||||
}
|
||||
Export::Pptx => {
|
||||
let r = s.execute("file.exportPptx", &json!({"path": out})).map_err(|e| e.to_string())?;
|
||||
eprintln!("wrote {out} ({} slides, {} bytes)", r["slides"], r["bytes"]);
|
||||
for w in r["warnings"].as_array().into_iter().flatten() {
|
||||
eprintln!("warning: {}", w.as_str().unwrap_or_default());
|
||||
}
|
||||
}
|
||||
Export::Command(cmd) => {
|
||||
let r = s.execute(cmd, &json!({"path": out})).map_err(|e| e.to_string())?;
|
||||
eprintln!("wrote {out} ({} bytes)", r["bytes"]);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A page as PNG, or as JPEG for `.jpg` and `.jpeg`.
|
||||
fn export(s: &mut Session, out: &str, page: usize, scale: f64) -> Result<(), String> {
|
||||
let st = s.doc().map_err(|e| e.to_string())?;
|
||||
|
||||
File renamed without changes.
@@ -0,0 +1 @@
|
||||
mod cli;
|
||||
@@ -2,12 +2,12 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>DesignCraft</title>
|
||||
<link data-trunk rel="rust" data-bin="designcraft-web" data-wasm-opt="z" data-weak-refs />
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
|
||||
#designcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
#designcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; }
|
||||
#designcraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
color: #b8b8b8; font: 14px system-ui, sans-serif; pointer-events: none; }
|
||||
</style>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -231,7 +231,21 @@ impl Fallback {
|
||||
choice.backend.name(),
|
||||
choice.record.failed
|
||||
),
|
||||
None => log::info!("graphics: {} (WGPU_BACKEND overrides)", choice.backend.name()),
|
||||
// Not a switch from an unfinished start. `begin` has already returned when
|
||||
// `WGPU_BACKEND` names a backend, so wgpu is not being told which to use here -- an
|
||||
// empty or unrecognised value is no choice and reaches this line like an unset one.
|
||||
// The message used to say the environment had been asked, which reads in a log as
|
||||
// though the user had chosen the backend (#1343). What this start did choose is
|
||||
// either the first candidate or the first one that has not failed before, so it says
|
||||
// which of the two it is rather than claiming the first choice.
|
||||
None if choice.record.failed.is_empty() => {
|
||||
log::info!("graphics: {} (nothing has failed before; set WGPU_BACKEND to choose another)", choice.backend.name())
|
||||
}
|
||||
None => log::info!(
|
||||
"graphics: {} (the first that hasn't failed before, after {:?}; set WGPU_BACKEND to choose another)",
|
||||
choice.backend.name(),
|
||||
choice.record.failed
|
||||
),
|
||||
}
|
||||
let instance = instance_backends(choice.backend, &choice.record.failed);
|
||||
Some(Fallback { backend: choice.backend, switched_from: choice.switched_from, instance, path, record: choice.record })
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"] }],
|
||||
@@ -276,12 +277,27 @@ fn picked_save_path(picked: std::path::PathBuf, ext: Option<&str>) -> std::path:
|
||||
s.into()
|
||||
}
|
||||
|
||||
/// The folder a save dialog starts in and the name it suggests. A suggestion with a folder in
|
||||
/// front (the document's, or the last one used) starts there while that folder still exists;
|
||||
/// otherwise the system picks, as it did before.
|
||||
fn save_dialog_start(suggested: &str) -> (Option<std::path::PathBuf>, String) {
|
||||
let path = std::path::Path::new(suggested);
|
||||
let name = path.file_name().map_or_else(|| suggested.to_string(), |n| n.to_string_lossy().to_string());
|
||||
let folder = path.parent().filter(|f| !f.as_os_str().is_empty() && f.is_dir()).map(std::path::Path::to_path_buf);
|
||||
(folder, name)
|
||||
}
|
||||
|
||||
/// The save dialog: the suggested name's extension as its filter, and the extension added to a
|
||||
/// name typed without it. Adding it names another file than the one the dialog checked, so
|
||||
/// replacing an existing one asks again.
|
||||
fn pick_save(name: &str) -> Option<String> {
|
||||
fn pick_save(suggested: &str) -> Option<String> {
|
||||
let (folder, name) = save_dialog_start(suggested);
|
||||
let name = name.as_str();
|
||||
let ext = suggested_extension(name);
|
||||
let mut dialog = rfd::FileDialog::new().set_file_name(name);
|
||||
if let Some(folder) = folder {
|
||||
dialog = dialog.set_directory(folder);
|
||||
}
|
||||
if let Some(ext) = ext {
|
||||
dialog = dialog.add_filter(ext.to_ascii_uppercase(), &[ext]);
|
||||
}
|
||||
@@ -605,6 +621,17 @@ mod tests {
|
||||
assert_eq!(save("/d/Brochure.txt", "Untitled.rtf"), PathBuf::from("/d/Brochure.txt"), "another export format is kept");
|
||||
assert_eq!(save("/d/Package", "Untitled 1.2 Folder"), PathBuf::from("/d/Package"), "a folder has no extension");
|
||||
}
|
||||
|
||||
/// #1653: the save dialog opens in the folder the suggestion names, unless it is gone.
|
||||
#[test]
|
||||
fn save_dialog_starts_in_the_suggested_folder_while_it_exists() {
|
||||
let folder = std::env::temp_dir();
|
||||
let suggested = folder.join("Brochure.pdf");
|
||||
assert_eq!(super::save_dialog_start(&suggested.to_string_lossy()), (Some(folder.clone()), "Brochure.pdf".to_string()));
|
||||
let gone = folder.join(format!("designcraft-gone-{}", std::process::id())).join("Brochure.pdf");
|
||||
assert_eq!(super::save_dialog_start(&gone.to_string_lossy()), (None, "Brochure.pdf".to_string()), "a folder deleted since: the system's own");
|
||||
assert_eq!(super::save_dialog_start("Untitled 1.2 Folder"), (None, "Untitled 1.2 Folder".to_string()), "a bare name: the system's own");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
|
||||
@@ -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", ¶ms).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);
|
||||
|
||||
+23
-2
@@ -128,8 +128,14 @@ pub fn write(swatches: &[Swatch]) -> Vec<u8> {
|
||||
continue;
|
||||
}
|
||||
let mut b = Vec::new();
|
||||
let units: Vec<u16> = s.name.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
b.extend((units.len() as u16).to_be_bytes());
|
||||
// The length field is a u16 that counts the terminating NUL: truncate longer names.
|
||||
let mut units: Vec<u16> = s.name.encode_utf16().take(usize::from(u16::MAX) - 1).collect();
|
||||
// Do not leave half of a surrogate pair at the cut.
|
||||
if units.last().is_some_and(|u| (0xD800..0xDC00).contains(u)) {
|
||||
units.pop();
|
||||
}
|
||||
units.push(0);
|
||||
b.extend(u16::try_from(units.len()).unwrap_or(u16::MAX).to_be_bytes());
|
||||
for u in units {
|
||||
b.extend(u.to_be_bytes());
|
||||
}
|
||||
@@ -233,6 +239,21 @@ mod tests {
|
||||
assert_eq!(read(&write(&sw[1..])).map(|w| w.len()), Ok(MAX_COLORS), "up to the limit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_long_name_is_truncated_to_fit() {
|
||||
let sw = vec![Swatch {
|
||||
name: "x".repeat(70_000),
|
||||
value: SwatchValue::Color { color: Color::Gray { k: 0.5 }, color_type: ColorType::Process },
|
||||
locked: false,
|
||||
named: true,
|
||||
hidden: false,
|
||||
}];
|
||||
let back = read(&write(&sw)).unwrap();
|
||||
assert_eq!(back.len(), 1, "the swatch must survive the round trip");
|
||||
assert_eq!(back[0].name, "x".repeat(usize::from(u16::MAX) - 1));
|
||||
assert_eq!(back[0].value, sw[0].value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_range_components_are_clamped() {
|
||||
let mut f = Vec::new();
|
||||
|
||||
+10
-10
@@ -17,7 +17,7 @@ pub mod lab;
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock, RwLock};
|
||||
use std::sync::{Arc, Mutex, OnceLock, PoisonError, RwLock};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -201,7 +201,7 @@ static USER: RwLock<Vec<Arc<IccProfile>>> = RwLock::new(Vec::new());
|
||||
|
||||
fn builtin_rgb_cache(name: &str) -> Option<Arc<IccProfile>> {
|
||||
static CACHE: Mutex<Vec<Arc<IccProfile>>> = Mutex::new(Vec::new());
|
||||
let mut c = CACHE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut c = CACHE.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some(p) = c.iter().find(|p| p.name == name) {
|
||||
return Some(p.clone());
|
||||
}
|
||||
@@ -211,7 +211,7 @@ fn builtin_rgb_cache(name: &str) -> Option<Arc<IccProfile>> {
|
||||
}
|
||||
|
||||
fn user_profile(name: &str) -> Option<Arc<IccProfile>> {
|
||||
USER.read().unwrap_or_else(|e| e.into_inner()).iter().find(|p| p.name == name).cloned()
|
||||
USER.read().unwrap_or_else(PoisonError::into_inner).iter().find(|p| p.name == name).cloned()
|
||||
}
|
||||
|
||||
/// The default CMYK space: Coated FOGRA39, or Generic CMYK should that profile be unusable.
|
||||
@@ -255,7 +255,7 @@ pub fn profiles() -> Vec<ProfileInfo> {
|
||||
.map(|n| ProfileInfo { name: (*n).into(), kind: ProfileKind::Rgb, builtin: true })
|
||||
.chain([COATED_FOGRA39, GENERIC_CMYK, DEVICE_CMYK].iter().map(|n| ProfileInfo { name: (*n).into(), kind: ProfileKind::Cmyk, builtin: true }))
|
||||
.collect();
|
||||
for p in USER.read().unwrap_or_else(|e| e.into_inner()).iter() {
|
||||
for p in USER.read().unwrap_or_else(PoisonError::into_inner).iter() {
|
||||
v.push(ProfileInfo { name: p.name.clone(), kind: p.kind, builtin: false });
|
||||
}
|
||||
v
|
||||
@@ -275,7 +275,7 @@ pub fn register_icc(bytes: &[u8], name: Option<String>) -> Result<ProfileInfo, C
|
||||
return Err(CmsError::Unsupported(format!("{}: no usable transform to/from sRGB", p.name)));
|
||||
}
|
||||
let info = ProfileInfo { name: p.name.clone(), kind: p.kind, builtin: false };
|
||||
let mut u = USER.write().unwrap_or_else(|e| e.into_inner());
|
||||
let mut u = USER.write().unwrap_or_else(PoisonError::into_inner);
|
||||
u.retain(|x| x.name != info.name);
|
||||
u.push(Arc::new(p));
|
||||
drop(u);
|
||||
@@ -522,7 +522,7 @@ impl Cms {
|
||||
/// A cached 3-D lookup table implementing [`Cms::proof_srgb`] for 8-bit pixels.
|
||||
pub fn proof_lut(&self, proof: &ProofSetup) -> Arc<ProofLut> {
|
||||
let key = format!("{:?}|{:?}|{:?}|{}", self.settings, proof.target, proof.intent, proof.simulate_paper);
|
||||
let mut cache = PROOF_CACHE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut cache = PROOF_CACHE.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some((_, l)) = cache.iter().find(|(k, _)| *k == key) {
|
||||
return l.clone();
|
||||
}
|
||||
@@ -538,7 +538,7 @@ impl Cms {
|
||||
static PROOF_CACHE: Mutex<Vec<(String, Arc<ProofLut>)>> = Mutex::new(Vec::new());
|
||||
|
||||
fn clear_proof_cache() {
|
||||
PROOF_CACHE.lock().unwrap_or_else(|e| e.into_inner()).clear();
|
||||
PROOF_CACHE.lock().unwrap_or_else(PoisonError::into_inner).clear();
|
||||
}
|
||||
|
||||
fn cmyk4(v: Option<Vec<f32>>) -> Option<[f32; 4]> {
|
||||
@@ -704,10 +704,10 @@ pub fn generation() -> u64 {
|
||||
|
||||
/// The active colour-management context (Edit → Color Settings).
|
||||
pub fn active() -> Arc<Cms> {
|
||||
if let Some(c) = ACTIVE.read().unwrap_or_else(|e| e.into_inner()).as_ref() {
|
||||
if let Some(c) = ACTIVE.read().unwrap_or_else(PoisonError::into_inner).as_ref() {
|
||||
return c.clone();
|
||||
}
|
||||
let mut w = ACTIVE.write().unwrap_or_else(|e| e.into_inner());
|
||||
let mut w = ACTIVE.write().unwrap_or_else(PoisonError::into_inner);
|
||||
w.get_or_insert_with(|| Arc::new(Cms::default())).clone()
|
||||
}
|
||||
|
||||
@@ -721,7 +721,7 @@ pub fn set_active(settings: &ColorSettings) -> Result<(), CmsError> {
|
||||
let changed = active().settings != *settings;
|
||||
RGB_IS_SRGB.store(cms.rgb_is_srgb(), Ordering::Relaxed);
|
||||
CMYK_IS_DEVICE.store(matches!(cms.cmyk, CmykSpace::Device), Ordering::Relaxed);
|
||||
*ACTIVE.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(cms));
|
||||
*ACTIVE.write().unwrap_or_else(PoisonError::into_inner) = Some(Arc::new(cms));
|
||||
if changed {
|
||||
GENERATION.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
@@ -295,7 +295,7 @@ fn cjk_break(a: &Glyph, b: &Glyph, sp: &Spacing) -> bool {
|
||||
}
|
||||
|
||||
pub fn is_forced(c: char) -> bool {
|
||||
use designcraft_doc::story::*;
|
||||
use designcraft_doc::story::{FORCED_LINE_BREAK, is_break_char};
|
||||
c == FORCED_LINE_BREAK || is_break_char(c)
|
||||
}
|
||||
|
||||
|
||||
+13
-13
@@ -7,7 +7,7 @@
|
||||
//! can't be reused by other allocations while the entry lives.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use designcraft_doc::{Document, Item, Story, StoryId, Styles};
|
||||
|
||||
@@ -196,7 +196,7 @@ impl Cache {
|
||||
sig.push(std::hash::Hasher::finish(&h) as usize);
|
||||
}
|
||||
{
|
||||
let mut g = self.map.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut g = self.map.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
g.1 += 1;
|
||||
let stamp = g.1;
|
||||
if let Some(e) = g.0.get_mut(&key)
|
||||
@@ -216,7 +216,7 @@ impl Cache {
|
||||
&ComposeOptions { page_name, section_marker, page, running, label: None, xrefs: with_xrefs.then(|| self.xref_index(doc)) },
|
||||
&others.iter().map(|o| &**o).collect::<Vec<_>>(),
|
||||
));
|
||||
let mut g = self.map.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut g = self.map.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let stamp = g.1;
|
||||
if g.0.len() > 4096 {
|
||||
g.0.retain(|_, e| stamp - e.stamp < 64);
|
||||
@@ -228,7 +228,7 @@ impl Cache {
|
||||
&& cross
|
||||
&& let Some(old) = replaced
|
||||
{
|
||||
self.previous.lock().unwrap_or_else(|e| e.into_inner()).insert(sid, (old.sig, old.out));
|
||||
self.previous.lock().unwrap_or_else(PoisonError::into_inner).insert(sid, (old.sig, old.out));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -241,7 +241,7 @@ impl Cache {
|
||||
// Only the geometry/story part of the signature (variables etc. add to it).
|
||||
let matches = |s: &[usize]| s.len() >= sig.len() && s[..sig.len()] == sig[..];
|
||||
{
|
||||
let g = self.map.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let g = self.map.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
for cross in [true, false] {
|
||||
if let Some(e) = g.0.get(&(sid, ShownOn::Own, cross))
|
||||
&& matches(&e.sig)
|
||||
@@ -250,7 +250,7 @@ impl Cache {
|
||||
}
|
||||
}
|
||||
}
|
||||
let g = self.previous.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let g = self.previous.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
g.get(&sid).filter(|(s, _)| matches(s)).map(|(_, c)| c.clone())
|
||||
}
|
||||
|
||||
@@ -306,34 +306,34 @@ impl Cache {
|
||||
}
|
||||
|
||||
pub fn clear(&self) {
|
||||
self.map.lock().unwrap_or_else(|e| e.into_inner()).0.clear();
|
||||
*self.running.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
*self.xrefs.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
self.map.lock().unwrap_or_else(PoisonError::into_inner).0.clear();
|
||||
*self.running.lock().unwrap_or_else(PoisonError::into_inner) = None;
|
||||
*self.xrefs.lock().unwrap_or_else(PoisonError::into_inner) = None;
|
||||
}
|
||||
|
||||
/// Anchor pages for cross-references (rebuilt when the document changes).
|
||||
pub fn xref_index(&self, doc: &Document) -> Arc<crate::xref::XrefIndex> {
|
||||
let sig = doc_signature(doc);
|
||||
if let Some((s, r)) = &*self.xrefs.lock().unwrap_or_else(|e| e.into_inner())
|
||||
if let Some((s, r)) = &*self.xrefs.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
&& *s == sig
|
||||
{
|
||||
return r.clone();
|
||||
}
|
||||
let r = Arc::new(crate::xref::XrefIndex::build(doc, &|sid| self.get(doc, sid, None)));
|
||||
*self.xrefs.lock().unwrap_or_else(|e| e.into_inner()) = Some((sig, r.clone()));
|
||||
*self.xrefs.lock().unwrap_or_else(PoisonError::into_inner) = Some((sig, r.clone()));
|
||||
r
|
||||
}
|
||||
|
||||
/// The running-header index for `doc` (rebuilt when the document changes).
|
||||
pub fn running_index(&self, doc: &Document) -> Arc<RunningIndex> {
|
||||
let sig = doc_signature(doc);
|
||||
if let Some((s, r)) = &*self.running.lock().unwrap_or_else(|e| e.into_inner())
|
||||
if let Some((s, r)) = &*self.running.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
&& *s == sig
|
||||
{
|
||||
return r.clone();
|
||||
}
|
||||
let r = Arc::new(RunningIndex::build(doc, &|sid| self.get(doc, sid, None)));
|
||||
*self.running.lock().unwrap_or_else(|e| e.into_inner()) = Some((sig, r.clone()));
|
||||
*self.running.lock().unwrap_or_else(PoisonError::into_inner) = Some((sig, r.clone()));
|
||||
r
|
||||
}
|
||||
}
|
||||
|
||||
+54
-16
@@ -165,6 +165,20 @@ pub struct PlacedGlyph {
|
||||
}
|
||||
|
||||
impl PlacedGlyph {
|
||||
/// Whether an underline or a strikethrough runs across this glyph.
|
||||
///
|
||||
/// A tab is not drawn, but it is a character with an advance, and the rule runs across its
|
||||
/// whole width as InDesign draws it: forms rule their fill-in lines with underlined tabs, and
|
||||
/// without this they lost every writing line (#1466). The spaces a line break trimmed off the
|
||||
/// end, the break characters themselves and the markers' carriers are not part of the line's
|
||||
/// text and are not spanned.
|
||||
pub fn spans_rules(&self) -> bool {
|
||||
if self.visible {
|
||||
return true;
|
||||
}
|
||||
self.adv > 0.0 && matches!(self.rendered_char, '\t' | designcraft_doc::story::RIGHT_INDENT_TAB)
|
||||
}
|
||||
|
||||
/// In a vertical frame, the turn that sets this glyph upright, applied after drawing it at `x`
|
||||
/// on `baseline`: it hangs from its vertical origin ([`designcraft_fonts::FontFace::v_origin`],
|
||||
/// centred across) at `x` on the line's centre, the middle of the font's em box; a tate-chu-yoko
|
||||
@@ -569,6 +583,9 @@ pub struct FrameSpec {
|
||||
pub grid: Option<(f64, f64)>,
|
||||
/// The frame is on a left page (for towards/away-from-spine alignment).
|
||||
pub left_page: bool,
|
||||
/// The frame is on a right page of a facing spread (spine-relative anchored objects mirror
|
||||
/// there; a single page has no spine).
|
||||
pub right_page: bool,
|
||||
/// The page and its margins in the frame's inner space (custom anchored objects).
|
||||
pub page_rect: Option<(Rect, Rect)>,
|
||||
}
|
||||
@@ -648,7 +665,10 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> {
|
||||
let loc = doc.find(fid);
|
||||
let Some(item) = loc.as_ref().and_then(|l| doc.item_at(l)).or_else(|| doc.anchored_item(fid)) else { continue };
|
||||
let Some(tf) = item.text_frame() else { continue };
|
||||
let xf = loc.as_ref().map(|l| doc.parent_xf(l)).unwrap_or(designcraft_geom::Affine::IDENTITY) * item.xf;
|
||||
let parent = loc.as_ref().map(|l| doc.parent_xf(l)).unwrap_or(designcraft_geom::Affine::IDENTITY);
|
||||
let xf = parent * item.xf;
|
||||
// Where the frame is on the spread (a group's transform included), to find its page.
|
||||
let center_x = parent.transform_rect_bbox(item.bounds()).center().x;
|
||||
let inv = xf.inverse();
|
||||
let mut exclusions = Vec::new();
|
||||
let spread_ref = loc.as_ref().map(|l| l.spread);
|
||||
@@ -672,25 +692,27 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> {
|
||||
r.is_finite() && r.x0 < a.x1 && r.x1 > a.x0 && r.y0 < a.y1 && r.y1 > a.y0
|
||||
});
|
||||
}
|
||||
let (page_name, page, left_page) = match (spread_ref, spread) {
|
||||
let (page_name, page, side) = match (spread_ref, spread) {
|
||||
(Some(designcraft_doc::SpreadRef::Doc(si)), Some(sp)) => {
|
||||
let c = item.bounds().center();
|
||||
let pi = sp.page_at_x(c.x).unwrap_or(0);
|
||||
let pi = sp.page_at_x(center_x).unwrap_or(0);
|
||||
let abs = doc.first_page_of_spread(si) + pi;
|
||||
(Some(doc.page_name(abs)), Some(abs), sp.pages.get(pi).is_some_and(|p| p.side == designcraft_doc::PageSide::Left))
|
||||
(Some(doc.page_name(abs)), Some(abs), sp.pages.get(pi).map(|p| p.side))
|
||||
}
|
||||
(Some(designcraft_doc::SpreadRef::Parent(pi)), Some(sp)) => {
|
||||
(Some(designcraft_doc::SpreadRef::Parent(_)), Some(sp)) => {
|
||||
let prefix = sp.parent.as_ref().map(|p| p.prefix.clone()).unwrap_or_else(|| "A".into());
|
||||
let _ = pi;
|
||||
(Some(prefix), None, false)
|
||||
// The side of the parent page under the frame, so a folio there aligns to the spine of its own page.
|
||||
let under = sp.page_at_x(center_x).and_then(|pi| sp.pages.get(pi));
|
||||
(Some(prefix), None, under.map(|p| p.side))
|
||||
}
|
||||
_ => (None, None, false),
|
||||
_ => (None, None, None),
|
||||
};
|
||||
let left_page = side == Some(designcraft_doc::PageSide::Left);
|
||||
let right_page = side == Some(designcraft_doc::PageSide::Right);
|
||||
let section_marker = page.map(|abs| section_marker(doc, abs));
|
||||
// The page under the frame and its margins, in inner space (bounding boxes).
|
||||
let page_rect = match (spread_ref, spread) {
|
||||
(_, Some(sp)) => sp
|
||||
.page_at_x(item.bounds().center().x)
|
||||
.page_at_x(center_x)
|
||||
.and_then(|pi| sp.pages.get(pi))
|
||||
.map(|pg| (inv.transform_rect_bbox(pg.bounds()), inv.transform_rect_bbox(pg.margin_rect()))),
|
||||
_ => None,
|
||||
@@ -757,6 +779,7 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> {
|
||||
page,
|
||||
grid,
|
||||
left_page,
|
||||
right_page,
|
||||
page_rect,
|
||||
});
|
||||
}
|
||||
@@ -1674,10 +1697,16 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
|
||||
}
|
||||
// A drop cap sits at the start-side indent; the first-line indent doesn't move it.
|
||||
let beside = drop_cap.as_ref().filter(|dc| line_no < dc.lines && col_first_line == 0).map(DropCap::indent);
|
||||
let first_line = if line_no == 0 { pp.first_line_indent } else { 0.0 };
|
||||
let (ind_l, ind_r) = match beside {
|
||||
Some(w) if rtl => (pp.left_indent, pp.right_indent + w),
|
||||
Some(w) => (pp.left_indent + w, pp.right_indent),
|
||||
None => (pp.left_indent + if line_no == 0 { pp.first_line_indent } else { 0.0 }, pp.right_indent),
|
||||
// The first-line indent is at the paragraph's start side, which is the right
|
||||
// one in a right-to-left paragraph: applied on the left it moved the line the
|
||||
// wrong way, out of the frame for the hanging indent a numbered list uses
|
||||
// (#1448).
|
||||
None if rtl => (pp.left_indent, pp.right_indent + first_line),
|
||||
None => (pp.left_indent + first_line, pp.right_indent),
|
||||
};
|
||||
let lx0 = x0 + ind_l;
|
||||
let lx1 = x1 - ind_r;
|
||||
@@ -1860,11 +1889,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()
|
||||
@@ -1981,6 +2011,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
|
||||
object_point,
|
||||
ref_point,
|
||||
keep_within_column,
|
||||
spine_relative,
|
||||
} => {
|
||||
use designcraft_doc::anchored::AnchorRelative as R;
|
||||
let col = ft.columns.get(l.column as usize).copied().unwrap_or(f.area);
|
||||
@@ -1992,15 +2023,21 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
|
||||
R::PageEdge => Some(f.page_rect.map_or(f.area, |p| p.0)),
|
||||
};
|
||||
let frac = |i: u8| ([0.0, 0.5, 1.0][(i % 3) as usize], [0.0, 0.5, 1.0][(i / 3).min(2) as usize]);
|
||||
let (rfx, rfy) = frac(*ref_point);
|
||||
let (ofx, ofy) = frac(*object_point);
|
||||
let (mut rfx, rfy) = frac(*ref_point);
|
||||
let (mut ofx, ofy) = frac(*object_point);
|
||||
let mut dx = *x_offset;
|
||||
// The settings describe a left-hand page; a right-hand page mirrors them
|
||||
// across the spine.
|
||||
if *spine_relative && f.right_page {
|
||||
(rfx, ofx, dx) = (1.0 - rfx, 1.0 - ofx, -dx);
|
||||
}
|
||||
let rx = area(*x_relative).map_or(o.x, |a| a.x0 + a.width() * rfx);
|
||||
let ry = area(*y_relative).map_or(l.baseline, |a| a.y0 + a.height() * rfy);
|
||||
let mut y = ry + y_offset - h * ofy;
|
||||
if *keep_within_column {
|
||||
y = y.clamp(col.y0, (col.y1 - h).max(col.y0));
|
||||
}
|
||||
Point::new(rx + x_offset - w * ofx, y)
|
||||
Point::new(rx + dx - w * ofx, y)
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -4626,6 +4663,7 @@ mod column_allocation_safety_tests {
|
||||
page: None,
|
||||
grid: None,
|
||||
left_page: false,
|
||||
right_page: false,
|
||||
page_rect: None,
|
||||
outline: None,
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ pub(crate) struct Notes {
|
||||
pub key: Option<usize>,
|
||||
pub placed: Vec<Placed>,
|
||||
/// Composed footnote text by (index, label, width).
|
||||
cache: HashMap<(usize, String, u64), (Arc<ComposedStory>, NoteMetrics)>,
|
||||
cache: HashMap<(usize, String, u64, bool, bool), (Arc<ComposedStory>, NoteMetrics)>,
|
||||
notes: Vec<Arc<designcraft_doc::Footnote>>,
|
||||
/// Footnotes span the columns of their frame.
|
||||
span: bool,
|
||||
@@ -153,7 +153,8 @@ impl Notes {
|
||||
fn compose(&mut self, doc: &Document, k: usize, w: f64, f: &FrameSpec, opts: &ComposeOptions) -> (Arc<ComposedStory>, NoteMetrics) {
|
||||
let o = &doc.footnote_options;
|
||||
let label = format!("{}{}", o.label(self.numbers[k], true), o.separator);
|
||||
let key = (k, label.clone(), w.to_bits());
|
||||
// The page side sets towards/away-from-spine alignment and spine-relative anchored objects.
|
||||
let key = (k, label.clone(), w.to_bits(), f.left_page, f.right_page);
|
||||
if let Some(c) = self.cache.get(&key) {
|
||||
return c.clone();
|
||||
}
|
||||
@@ -169,6 +170,7 @@ impl Notes {
|
||||
page: f.page,
|
||||
grid: None,
|
||||
left_page: f.left_page,
|
||||
right_page: f.right_page,
|
||||
page_rect: None,
|
||||
};
|
||||
let nopts = ComposeOptions {
|
||||
|
||||
@@ -121,7 +121,7 @@ impl CellLineExtent {
|
||||
}
|
||||
|
||||
/// Compose a cell story at width `w` (content space, top-left at the origin).
|
||||
fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, left_page: bool) -> CellLayout {
|
||||
fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, (left_page, right_page): (bool, bool)) -> CellLayout {
|
||||
let spec = FrameSpec {
|
||||
id: ItemId(0),
|
||||
area: Rect::new(0.0, 0.0, w.max(1.0), 1.0e6),
|
||||
@@ -134,6 +134,7 @@ fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, left
|
||||
page: opts.page,
|
||||
grid: None,
|
||||
left_page,
|
||||
right_page,
|
||||
page_rect: None,
|
||||
};
|
||||
let cs = crate::compose(doc, &cell.text, std::slice::from_ref(&spec), opts);
|
||||
@@ -200,7 +201,7 @@ pub(crate) fn place_table(
|
||||
let (nr, nc) = (table.nrows(), table.ncols());
|
||||
let owners = table.owners();
|
||||
let is_owner = |r: usize, c: usize| owners[r * nc + c] == (r, c);
|
||||
let left_page = frames[cur.fi].left_page;
|
||||
let side = (frames[cur.fi].left_page, frames[cur.fi].right_page);
|
||||
// Compose every owner cell.
|
||||
let mut layouts: Vec<Option<CellLayout>> = Vec::with_capacity(nr * nc);
|
||||
for r in 0..nr {
|
||||
@@ -212,7 +213,7 @@ pub(crate) fn place_table(
|
||||
let cell = &table.cells[r * nc + c];
|
||||
let cs = (cell.col_span.max(1) as usize).min(nc - c);
|
||||
let w = colx[c + cs] - colx[c] - cell.insets[1] - cell.insets[3];
|
||||
layouts.push(Some(compose_cell(doc, cell, w, opts, left_page)));
|
||||
layouts.push(Some(compose_cell(doc, cell, w, opts, side)));
|
||||
}
|
||||
}
|
||||
// Row heights.
|
||||
@@ -813,7 +814,7 @@ mod height_tests {
|
||||
}
|
||||
|
||||
fn measure(cell: &Cell, width: f64) -> CellLayout {
|
||||
compose_cell(&Document::new(&NewDocument::default()), cell, width, &ComposeOptions::default(), false)
|
||||
compose_cell(&Document::new(&NewDocument::default()), cell, width, &ComposeOptions::default(), (false, false))
|
||||
}
|
||||
|
||||
fn compose_table(table: Table, height: f64) -> ComposedStory {
|
||||
@@ -1061,6 +1062,7 @@ mod height_tests {
|
||||
object_point: 0,
|
||||
ref_point: 0,
|
||||
keep_within_column: false,
|
||||
spine_relative: false,
|
||||
}),
|
||||
);
|
||||
assert!((measure(&cell, 92.0).content_h - original).abs() < 1e-6);
|
||||
|
||||
@@ -2752,6 +2752,7 @@ fn custom_anchors_do_not_change_bottom_baseline_fitting() {
|
||||
object_point: 0,
|
||||
ref_point: 0,
|
||||
keep_within_column: false,
|
||||
spine_relative: false,
|
||||
};
|
||||
d.story_mut(sid).unwrap().insert_object(0, designcraft_doc::AnchoredObject::new(it, pos));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
@@ -6740,6 +6741,7 @@ fn anchor_box(d: &mut Document, sid: StoryId, at: usize, x: f64, y: f64, y_rel:
|
||||
object_point: 0,
|
||||
ref_point: 0,
|
||||
keep_within_column: false,
|
||||
spine_relative: false,
|
||||
};
|
||||
d.story_mut(sid).unwrap().insert_object(at, designcraft_doc::AnchoredObject::new(it, position));
|
||||
}
|
||||
@@ -6808,6 +6810,7 @@ fn anchored_object_wraps_with_its_contour_and_side() {
|
||||
object_point: 0,
|
||||
ref_point: 0,
|
||||
keep_within_column: false,
|
||||
spine_relative: false,
|
||||
};
|
||||
d.story_mut(a).unwrap().insert_object(0, designcraft_doc::AnchoredObject::new(it, position));
|
||||
let cs = compose_story(&d, b, &ComposeOptions::default());
|
||||
@@ -6821,6 +6824,70 @@ fn anchored_object_wraps_with_its_contour_and_side() {
|
||||
assert!(top > mid + 5.0, "top {top}, middle {mid}");
|
||||
}
|
||||
|
||||
/// Relative to Spine: a marginal note set up for a left-hand page (its right edge 12 pt left of
|
||||
/// the column) mirrors on the right-hand page (its left edge 12 pt right of the column), so the
|
||||
/// notes of a story that runs across a spread all sit in the outside margin. Placed as on a
|
||||
/// left-hand page, the right page's note sat in the inside margin and reached across the spine.
|
||||
#[test]
|
||||
fn spine_relative_anchored_notes_stay_in_the_outside_margin_on_both_pages() {
|
||||
use designcraft_doc::anchored::AnchorRelative as R;
|
||||
let compose_spread = |spine_relative: bool| {
|
||||
let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() });
|
||||
let lid = d.default_layer();
|
||||
let sp = &d.spreads[1];
|
||||
let (l, r) = (sp.pages[0].clone(), sp.pages[1].clone());
|
||||
assert_eq!((l.side, r.side), (designcraft_doc::PageSide::Left, designcraft_doc::PageSide::Right));
|
||||
// Body frames leave a 120 pt outside margin for the notes.
|
||||
let left_rect = Rect::new(l.x + 120.0, 36.0, l.x + l.width - 36.0, 120.0);
|
||||
let right_rect = Rect::new(r.x + 36.0, 36.0, r.x + r.width - 120.0, 600.0);
|
||||
let text = [LOREM; 3].join(" ");
|
||||
let (lf, sid) = d.add_text_frame(SpreadRef::Doc(1), left_rect, lid, &text, ParaFormat::default()).unwrap();
|
||||
let (rf, _) = d.add_text_frame(SpreadRef::Doc(1), right_rect, lid, "", ParaFormat::default()).unwrap();
|
||||
d.thread(lf, rf).unwrap();
|
||||
let plain = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let second_line = |fid| plain.frame(fid).unwrap().lines[1].range.start;
|
||||
let (at_left, at_right) = (second_line(lf), second_line(rf));
|
||||
// Top-right corner of the note 12 pt left of the column's top-left, on the anchor's baseline.
|
||||
let position = designcraft_doc::AnchorPosition::Custom {
|
||||
x_relative: R::ColumnEdge,
|
||||
y_relative: R::Anchor,
|
||||
x_offset: -12.0,
|
||||
y_offset: 0.0,
|
||||
object_point: 2,
|
||||
ref_point: 0,
|
||||
keep_within_column: false,
|
||||
spine_relative,
|
||||
};
|
||||
for at in [at_right, at_left] {
|
||||
let note = designcraft_doc::Item::new(
|
||||
ItemId(d.alloc()),
|
||||
lid,
|
||||
designcraft_doc::Shape::Rectangle,
|
||||
designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 72.0, 40.0)),
|
||||
);
|
||||
d.story_mut(sid).unwrap().insert_object(at, designcraft_doc::AnchoredObject::new(note, position.clone()));
|
||||
}
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
// Per page: the note's left and right edges, the column, and the page's left and right edges.
|
||||
let placed = |fid, page: &designcraft_doc::Page| {
|
||||
let ft = cs.frame(fid).unwrap();
|
||||
assert_eq!(ft.objects.len(), 1);
|
||||
let o = &ft.objects[0];
|
||||
((o.origin.x, o.origin.x + o.size.0), ft.columns[0], (page.x, page.x + page.width))
|
||||
};
|
||||
(placed(lf, &l), placed(rf, &r))
|
||||
};
|
||||
let near = |a: f64, b: f64| (a - b).abs() < 1e-6;
|
||||
|
||||
let (((lx0, lx1), lcol, (lp0, _)), ((rx0, rx1), rcol, (_, rp1))) = compose_spread(true);
|
||||
assert!(near(lx1, lcol.x0 - 12.0) && lx0 >= lp0, "left page: note {lx0}..{lx1}, column {lcol:?}");
|
||||
assert!(near(rx0, rcol.x1 + 12.0) && rx1 <= rp1, "right page: note {rx0}..{rx1}, column {rcol:?}");
|
||||
|
||||
// Without Relative to Spine both pages take the settings as written.
|
||||
let (_, ((rx0, rx1), rcol, _)) = compose_spread(false);
|
||||
assert!(near(rx1, rcol.x0 - 12.0), "right page as written: note {rx0}..{rx1}, column {rcol:?}");
|
||||
}
|
||||
|
||||
const OBJECT_LEN: usize = designcraft_doc::OBJECT_MARK.len_utf8();
|
||||
|
||||
#[test]
|
||||
@@ -7290,3 +7357,97 @@ 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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_page_frames_know_which_side_of_the_spine_they_are_on() {
|
||||
// Folios on a facing-pages parent: towards/away from spine must differ between its left and right page.
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let lid = d.default_layer();
|
||||
let w = d.parents[0].pages[0].width;
|
||||
let para = ParaFormat { para: ParaAttrs { align: Some(Align::TowardsSpine), ..Default::default() }, ..Default::default() };
|
||||
let (_, left_story) = d.add_text_frame(SpreadRef::Parent(0), Rect::new(36.0, 700.0, w - 36.0, 730.0), lid, "7", para.clone()).unwrap();
|
||||
let (_, right_story) = d.add_text_frame(SpreadRef::Parent(0), Rect::new(w + 36.0, 700.0, 2.0 * w - 36.0, 730.0), lid, "8", para).unwrap();
|
||||
assert!(frame_specs(&d, left_story)[0].left_page);
|
||||
assert!(!frame_specs(&d, right_story)[0].left_page);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grouped_frame_takes_the_side_of_the_page_its_group_moves_it_to() {
|
||||
let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() });
|
||||
let lid = d.default_layer();
|
||||
let (l, r) = (d.spreads[1].pages[0].clone(), d.spreads[1].pages[1].clone());
|
||||
let rect = Rect::new(l.x + 36.0, 36.0, l.x + l.width - 36.0, 300.0);
|
||||
let (fid, sid) = d.add_text_frame(SpreadRef::Doc(1), rect, lid, "Text", ParaFormat::default()).unwrap();
|
||||
// The frame's own coordinates are on the left page; its group's transform puts it on the right one.
|
||||
let mut g = designcraft_doc::Item::new(ItemId(d.alloc()), lid, designcraft_doc::Shape::Group, designcraft_geom::PathData::default());
|
||||
g.xf = designcraft_geom::Affine::translate((r.x - l.x, 0.0));
|
||||
let sp = Arc::make_mut(&mut d.spreads[1]);
|
||||
let at = sp.items.iter().position(|it| it.id == fid).unwrap();
|
||||
g.content = designcraft_doc::Content::Group { items: vec![sp.items.remove(at)] };
|
||||
sp.items.push(Arc::new(g));
|
||||
let spec = &frame_specs(&d, sid)[0];
|
||||
assert!(spec.right_page && !spec.left_page);
|
||||
assert_eq!(spec.page, Some(2));
|
||||
}
|
||||
|
||||
/// A right-to-left paragraph's first-line indent belongs at its start side, the right. Applied on
|
||||
/// the left it moved the first line the wrong way — out of the frame for the hanging indent a
|
||||
/// numbered list uses (right indent 10 mm, first line −10 mm), while the wrapped lines sat where
|
||||
/// the first line should have been (#1448).
|
||||
#[test]
|
||||
fn an_rtl_first_line_indent_is_at_the_right_edge() {
|
||||
const MM: f64 = 72.0 / 25.4;
|
||||
// Latin text in a right-to-left paragraph: the direction is the paragraph's, and it is the
|
||||
// placement that is being measured, so no Arabic font has to be present.
|
||||
let text = LOREM;
|
||||
let rect = Rect::new(0.0, 0.0, 200.0, 300.0);
|
||||
let para = |dir, align, first, left, right| ParaAttrs {
|
||||
direction: Some(dir),
|
||||
align: Some(align),
|
||||
first_line_indent: Some(first),
|
||||
left_indent: Some(left),
|
||||
right_indent: Some(right),
|
||||
..Default::default()
|
||||
};
|
||||
// The hanging indent of a numbered list, right to left.
|
||||
let (d, sid, _) = doc_with(text, rect, para(designcraft_doc::TextDirection::RightToLeft, Align::Right, -10.0 * MM, 0.0, 10.0 * MM));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let lines = all_lines(&cs);
|
||||
assert!(lines.len() >= 2, "the text wraps: {}", lines.len());
|
||||
let right_edge = |l: &Line| l.glyphs.iter().filter(|g| g.visible).map(|g| g.x + g.adv).fold(f64::MIN, f64::max);
|
||||
let left_edge = |l: &Line| l.glyphs.iter().filter(|g| g.visible).map(|g| g.x).fold(f64::MAX, f64::min);
|
||||
// The first line reaches the frame's right edge; the wrapped lines stop 10 mm short of it.
|
||||
assert!((right_edge(lines[0]) - rect.x1).abs() < 1.0, "first line at the edge: {}", right_edge(lines[0]));
|
||||
assert!((right_edge(lines[1]) - (rect.x1 - 10.0 * MM)).abs() < 1.0, "wrapped line indented: {}", right_edge(lines[1]));
|
||||
// And nothing leaves the frame on the left, which is where the first line used to go.
|
||||
for (i, l) in lines.iter().enumerate() {
|
||||
assert!(left_edge(l) >= rect.x0 - 0.01, "line {i} starts inside the frame: {}", left_edge(l));
|
||||
}
|
||||
|
||||
// The same paragraph left to right is unchanged: the first line hangs on the left.
|
||||
let (d, sid, _) = doc_with(text, rect, para(designcraft_doc::TextDirection::LeftToRight, Align::Left, -10.0 * MM, 10.0 * MM, 0.0));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let lines = all_lines(&cs);
|
||||
assert!((left_edge(lines[0]) - rect.x0).abs() < 1.0, "left to right, first line at the edge: {}", left_edge(lines[0]));
|
||||
assert!((left_edge(lines[1]) - (rect.x0 + 10.0 * MM)).abs() < 1.0, "left to right, wrapped line indented: {}", left_edge(lines[1]));
|
||||
}
|
||||
@@ -217,13 +217,31 @@ fn layout(styles: &[RunStyle], line: &mut [PlacedGlyph], members: &[usize]) -> f
|
||||
restore(line, members, &saved);
|
||||
return 0.0;
|
||||
}
|
||||
let rows = counts.len().max(1);
|
||||
let small = st.size * scale;
|
||||
// 0 is the default (one small em). Any other value, including a negative one, is added to
|
||||
// that em and then clamped so the rows cannot swap order.
|
||||
// Bounded, so a hostile value can't move a row to an infinite or huge coordinate.
|
||||
let spacing = if st.warichu_line_spacing.is_finite() { st.warichu_line_spacing.clamp(-16.0 * st.size, 16.0 * st.size) } else { 0.0 };
|
||||
let gap = if spacing == 0.0 { small } else { (small + spacing).max(0.0) };
|
||||
position_warichu_rows(line, members, &counts, &widths, RowLayout { block, x0: old_x0, gap }, st);
|
||||
let delta = block - old_span;
|
||||
shift_non_member_glyphs(line, members, &old_xs, old_x0, old_right, delta, scale);
|
||||
delta
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct RowLayout {
|
||||
block: f64,
|
||||
x0: f64,
|
||||
gap: f64,
|
||||
}
|
||||
|
||||
fn position_warichu_rows(line: &mut [PlacedGlyph], members: &[usize], counts: &[usize], widths: &[f64], layout: RowLayout, st: &RunStyle) {
|
||||
let block = layout.block;
|
||||
let old_x0 = layout.x0;
|
||||
let gap = layout.gap;
|
||||
|
||||
let rows = counts.len().max(1);
|
||||
let mut off = 0_usize;
|
||||
for (li, &c) in counts.iter().enumerate() {
|
||||
let w = widths[li];
|
||||
@@ -242,7 +260,9 @@ fn layout(styles: &[RunStyle], line: &mut [PlacedGlyph], members: &[usize]) -> f
|
||||
}
|
||||
off += c;
|
||||
}
|
||||
let delta = block - old_span;
|
||||
}
|
||||
|
||||
fn shift_non_member_glyphs(line: &mut [PlacedGlyph], members: &[usize], old_xs: &[f64], old_x0: f64, old_right: f64, delta: f64, scale: f64) {
|
||||
let mut placed: Vec<(f64, f64)> = old_xs.iter().enumerate().map(|(i, ox)| (*ox, line[members[i]].x)).collect();
|
||||
placed.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let mut member_at = vec![false; line.len()];
|
||||
@@ -265,7 +285,6 @@ fn layout(styles: &[RunStyle], line: &mut [PlacedGlyph], members: &[usize]) -> f
|
||||
g.x = anchor.1 + (g.x - anchor.0) * scale;
|
||||
}
|
||||
}
|
||||
delta
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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 }
|
||||
@@ -60,6 +60,11 @@ pub enum AnchorPosition {
|
||||
/// Keep Within Top/Bottom Column Boundaries.
|
||||
#[serde(default)]
|
||||
keep_within_column: bool,
|
||||
/// Relative to Spine: the settings describe a left-hand page, and on a right-hand page
|
||||
/// the reference point, the object point and the X offset mirror horizontally, so an
|
||||
/// object in the outside margin stays in the outside margin.
|
||||
#[serde(default)]
|
||||
spine_relative: bool,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+81
-2
@@ -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() });
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Insert Anchored Object…",
|
||||
["Object", "Anchored Object"],
|
||||
None,
|
||||
"{ids, story?, pos? (default: the text insertion point), position?: inline|aboveLine|custom, yOffset?, align?: left|center|right, spaceBefore?, spaceAfter?, xRelative? | yRelative?: anchor|textFrame|columnEdge|pageMargin|pageEdge, xOffset?, objectPoint? | refPoint?: 0..8, keepWithinColumn?} — moves the items into the text",
|
||||
"{ids, story?, pos? (default: the text insertion point), position?: inline|aboveLine|custom, yOffset?, align?: left|center|right, spaceBefore?, spaceAfter?, xRelative? | yRelative?: anchor|textFrame|columnEdge|pageMargin|pageEdge, xOffset?, objectPoint? | refPoint?: 0..8, keepWithinColumn?, spineRelative? (custom: the settings describe a left-hand page and mirror on a right-hand page)} — moves the items into the text",
|
||||
has_doc,
|
||||
insert
|
||||
),
|
||||
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Anchored Object Options…",
|
||||
["Object", "Anchored Object"],
|
||||
None,
|
||||
"{story?, index? (default: the object at the insertion point), position?: inline|aboveLine|custom, yOffset?, align?: left|center|right, spaceBefore?, spaceAfter?, xRelative? | yRelative?: anchor|textFrame|columnEdge|pageMargin|pageEdge, xOffset?, objectPoint? | refPoint?: 0..8, keepWithinColumn?}",
|
||||
"{story?, index? (default: the object at the insertion point), position?: inline|aboveLine|custom, yOffset?, align?: left|center|right, spaceBefore?, spaceAfter?, xRelative? | yRelative?: anchor|textFrame|columnEdge|pageMargin|pageEdge, xOffset?, objectPoint? | refPoint?: 0..8, keepWithinColumn?, spineRelative? (custom: the settings describe a left-hand page and mirror on a right-hand page)}",
|
||||
has_doc,
|
||||
options
|
||||
),
|
||||
@@ -80,11 +80,18 @@ fn position_param(p: &Value, base: &AnchorPosition) -> Result<AnchorPosition> {
|
||||
}
|
||||
("custom", b) => {
|
||||
use designcraft_doc::anchored::AnchorRelative as R;
|
||||
let (xr0, yr0, xo0, yo0, op0, rp0, k0) = match b {
|
||||
AnchorPosition::Custom { x_relative, y_relative, x_offset, y_offset, object_point, ref_point, keep_within_column } => {
|
||||
(*x_relative, *y_relative, *x_offset, *y_offset, *object_point, *ref_point, *keep_within_column)
|
||||
}
|
||||
_ => (R::TextFrame, R::Anchor, 0.0, 0.0, 0, 0, false),
|
||||
let (xr0, yr0, xo0, yo0, op0, rp0, k0, sr0) = match b {
|
||||
AnchorPosition::Custom {
|
||||
x_relative,
|
||||
y_relative,
|
||||
x_offset,
|
||||
y_offset,
|
||||
object_point,
|
||||
ref_point,
|
||||
keep_within_column,
|
||||
spine_relative,
|
||||
} => (*x_relative, *y_relative, *x_offset, *y_offset, *object_point, *ref_point, *keep_within_column, *spine_relative),
|
||||
_ => (R::TextFrame, R::Anchor, 0.0, 0.0, 0, 0, false, false),
|
||||
};
|
||||
let rel = |k: &str, d: R| -> Result<R> {
|
||||
match p.get(k) {
|
||||
@@ -102,6 +109,7 @@ fn position_param(p: &Value, base: &AnchorPosition) -> Result<AnchorPosition> {
|
||||
object_point: pt("objectPoint", op0),
|
||||
ref_point: pt("refPoint", rp0),
|
||||
keep_within_column: p.get("keepWithinColumn").and_then(Value::as_bool).unwrap_or(k0),
|
||||
spine_relative: p.get("spineRelative").and_then(Value::as_bool).unwrap_or(sr0),
|
||||
}
|
||||
}
|
||||
(o, _) => return Err(bad("anchored", format!("unknown position `{o}`"))),
|
||||
@@ -109,7 +117,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")) }
|
||||
@@ -316,5 +324,12 @@ mod custom_tests {
|
||||
matches!(obj.position, designcraft_doc::AnchorPosition::Custom { x_offset, object_point: 2, .. } if x_offset == -6.0),
|
||||
"IDML Anchored position"
|
||||
);
|
||||
// Relative to Spine keeps the other custom settings.
|
||||
s.execute("anchored.options", &json!({"story": sid.0, "index": 0, "spineRelative": true})).unwrap();
|
||||
let st = s.doc().unwrap().doc.story(sid).unwrap().clone();
|
||||
assert!(matches!(
|
||||
st.objects[0].position,
|
||||
designcraft_doc::AnchorPosition::Custom { spine_relative: true, x_offset, object_point: 2, .. } if x_offset == -6.0
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+152
-27
@@ -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}))
|
||||
@@ -220,12 +262,29 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
st.selection.cells = None;
|
||||
if let Some(i) = st.selection.items.iter().position(|x| *x == id) {
|
||||
st.selection.items.remove(i);
|
||||
// A key object needs another object to align to it.
|
||||
if st.selection.key == Some(id) || st.selection.items.len() < 2 {
|
||||
st.selection.key = None;
|
||||
}
|
||||
} else {
|
||||
st.selection.items.push(id);
|
||||
}
|
||||
st.bump_revision();
|
||||
ok()
|
||||
}),
|
||||
cmd!(noundo "selection.key", "Key Object", [], None, "{id} — make a selected item the key object for Align; the current key object drops the key", has_doc, |s, p| {
|
||||
let id = super::id_param(p, "id").ok_or_else(|| super::bad("selection.key", "missing id"))?;
|
||||
let st = s.doc_mut()?;
|
||||
if !st.selection.items.contains(&id) {
|
||||
return Err(super::bad("selection.key", "the key object must be selected"));
|
||||
}
|
||||
if st.selection.items.len() < 2 {
|
||||
return Err(super::bad("selection.key", "a key object needs at least two selected objects"));
|
||||
}
|
||||
st.selection.key = if st.selection.key == Some(id) { None } else { Some(id) };
|
||||
st.bump_revision();
|
||||
ok()
|
||||
}),
|
||||
cmd!(noundo "edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{} — all items on the active spreads, or all text in the story", has_doc, |s, _| {
|
||||
let st = s.doc_mut()?;
|
||||
if let Some(t) = st.selection.text {
|
||||
@@ -256,28 +315,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 +386,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 +598,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();
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::{EngineError, Result, Session};
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(noundo "file.exportPdf", "Export PDF…", ["File"], None,
|
||||
"{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), flatten?: high|medium|low|ppi (Transparency Flattener: spreads with transparency are rasterised), spreads?: bool, fullScreen?: bool, bookmarksPanel?: bool, pageLayout?: single|continuous|twoUp|twoUpCover|twoUpContinuous, view?: fitPage|fitWidth|actual, advanceSeconds?: number (interactive PDF), bleed?: bool (document bleed), marks?: bool | {crop?, bleed?, pageInfo?, weight?, offset?}, standard?: \"none\"|\"x4\"|\"a2b\", compressImages?: bool, tagged?: bool (structure tree: stories as paragraphs, figures with alt text), media?: bool (interactive: embed placed video and sound), title?, author?} → {path, bytes, pages, warnings} (no path: {base64, …})",
|
||||
"{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), flatten?: high|medium|low|ppi (Transparency Flattener: spreads with transparency are rasterised), spreads?: bool, fullScreen?: bool, bookmarksPanel?: bool, pageLayout?: single|continuous|twoUp|twoUpCover|twoUpContinuous, view?: fitPage|fitWidth|actual, advanceSeconds?: number (interactive PDF), bleed?: bool (document bleed), marks?: bool | {crop?, bleed?, pageInfo?, weight?, offset?}, standard?: \"none\"|\"x4\"|\"a2b\", compressImages?: bool, tagged?: bool (structure tree: stories as paragraphs, figures with alt text), media?: bool (interactive: embed placed video and sound), title?, author?, created?: int (creation date in Unix seconds; default now, for reproducible files)} → {path, bytes, pages, warnings} (no path: {base64, …})",
|
||||
has_doc, export_pdf),
|
||||
cmd!(noundo "file.exportEpub", "Export EPUB (Reflowable)…", ["File"], None,
|
||||
"{path?, title?, author?, language?: \"en\", identifier?: str (dc:identifier; a stable urn:uuid by default), coverImage?: path (a PNG or JPEG as the cover), tocTitle?: str (the table of contents' title; from the language by default), cover?: bool (the first page as the cover image), embedFonts?: bool (package the stylesheet's fonts whose licence allows it, whole files), fixedLayout?: bool (pre-paginated: each page as an image with its text)} → {path, bytes, warnings} (no path: {base64, bytes, warnings}); warnings name images without alt text, exported as decorative, and fonts that could not be embedded",
|
||||
@@ -331,7 +331,12 @@ fn pages_param(id: &str, p: &Value, page_count: usize) -> Result<Option<Vec<usiz
|
||||
.and_then(|n| usize::try_from(n).ok())
|
||||
.filter(|n| *n >= 1 && *n <= page_count)
|
||||
.ok_or_else(|| bad(id, format!("bad page {x}")))?;
|
||||
v.push(n - 1);
|
||||
// A page named twice is one page, as `"2,2"` already is: the list says which
|
||||
// pages to export, not how many times. `[2, 2]` exported two copies of page 2
|
||||
// while `"2,2"` exported one.
|
||||
if !v.contains(&(n - 1)) {
|
||||
v.push(n - 1);
|
||||
}
|
||||
}
|
||||
Some(v)
|
||||
}
|
||||
@@ -371,6 +376,10 @@ pub(crate) fn options(p: &Value, page_count: usize) -> Result<PdfOptions> {
|
||||
Some(s) => Standard::parse(s).ok_or_else(|| bad(ID, format!("unknown standard `{s}` (none, x4, a2b)")))?,
|
||||
None => Standard::None,
|
||||
};
|
||||
let created = match p.get("created") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(v.as_i64().ok_or_else(|| bad(ID, "`created`: Unix seconds, an integer"))?),
|
||||
};
|
||||
Ok(PdfOptions {
|
||||
pages,
|
||||
spreads,
|
||||
@@ -382,6 +391,7 @@ pub(crate) fn options(p: &Value, page_count: usize) -> Result<PdfOptions> {
|
||||
author: str_param(p, "author").map(str::to_string),
|
||||
tagged: p.get("tagged").and_then(Value::as_bool).unwrap_or(false),
|
||||
media: p.get("media").and_then(Value::as_bool).unwrap_or(false),
|
||||
created,
|
||||
..PdfOptions::default()
|
||||
})
|
||||
}
|
||||
@@ -659,11 +669,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;
|
||||
@@ -678,7 +688,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(
|
||||
@@ -697,7 +708,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).
|
||||
@@ -724,7 +735,7 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let flattened = 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 (fx_doc, fx_count) = rasterize_effects(base2, &s.cache);
|
||||
let doc: &designcraft_doc::Document = fx_doc.as_ref().unwrap_or(base2);
|
||||
@@ -1325,3 +1336,52 @@ mod pptx_tests {
|
||||
assert!(x.contains("<p:pic>") && !x.contains("prst=\"ellipse\""), "{x}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod page_list_tests {
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn doc(pages: u64) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200, "pages": pages, "facingPages": false, "margins": 0})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// The same selection written two ways exported different files: `"2,2"` is one page, because
|
||||
/// `parse_page_range` keeps the first of a repeat, while `[2, 2]` was two copies of page 2.
|
||||
/// The list says which pages to export, not how many times.
|
||||
#[test]
|
||||
fn a_page_named_twice_is_exported_once_either_way() {
|
||||
let mut s = doc(4);
|
||||
for (pages, want) in [
|
||||
(json!("2,2"), 1),
|
||||
(json!([2, 2]), 1),
|
||||
(json!([2, 2, 2]), 1),
|
||||
(json!("2-2,2"), 1),
|
||||
(json!([1, 2, 1]), 2),
|
||||
(json!("1,2,1"), 2),
|
||||
(json!([4, 1]), 2),
|
||||
(json!("4,1"), 2),
|
||||
(json!("all"), 4),
|
||||
] {
|
||||
let r = s.execute("file.exportPdf", &json!({"pages": pages.clone()})).unwrap();
|
||||
assert_eq!(r["pages"].as_u64(), Some(want), "pdf {pages}");
|
||||
let r = s.execute("file.exportPptx", &json!({"pages": pages.clone()})).unwrap();
|
||||
assert_eq!(r["slides"].as_u64(), Some(want), "pptx {pages}");
|
||||
}
|
||||
// The two forms now agree byte for byte, and the pages come out in the document's order
|
||||
// whichever order the list names them in (which is how the export already behaved).
|
||||
let mut file = |pages: Value| {
|
||||
let r = s.execute("file.exportPdf", &json!({"pages": pages})).unwrap();
|
||||
r["base64"].as_str().unwrap_or_default().to_string()
|
||||
};
|
||||
assert_eq!(file(json!([4, 1])), file(json!("4,1")), "the array and the string agree");
|
||||
assert_eq!(file(json!([4, 1])), file(json!([1, 4])), "pages 1 and 4, in the document's order");
|
||||
assert_eq!(file(json!([2, 2])), file(json!("2")), "a repeat is the single page");
|
||||
// A page that does not exist is still refused, and an empty list still exports nothing.
|
||||
assert!(s.execute("file.exportPdf", &json!({"pages": [5]})).is_err());
|
||||
assert!(s.execute("file.exportPdf", &json!({"pages": []})).is_err());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::sync::{Arc, Weak};
|
||||
|
||||
use designcraft_compose::grep::{Grep, GrepOptions, translate_replacement};
|
||||
use designcraft_doc::story::floor_char_boundary;
|
||||
use designcraft_doc::{Document, Selection, StoryId, TextSel};
|
||||
use designcraft_doc::{CellAddr, Document, Selection, StoryId, TextSel};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, bool_or, cmd, has_doc, str_param};
|
||||
@@ -61,7 +61,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
),
|
||||
cmd!(query "find.find", "Find", ["Edit", "Find/Change"], None,
|
||||
"{find, grep?: bool, caseSensitive?: bool, wholeWord?: bool, scope?: document|story|selection, story?, change?|first?|attrs?: ignored (find.change takes them; one request serves all three Find/Change commands)} → matches [{story, start, end, text}]",
|
||||
"{find, grep?: bool, caseSensitive?: bool, wholeWord?: bool, scope?: document|story|selection, story?, change?|first?|attrs?: ignored (find.change takes them; one request serves all three Find/Change commands)} → matches [{story, start, end, text, cell?: {table, row, col}}] (document scope also searches table cells; start/end then index the cell's text)",
|
||||
has_doc, find),
|
||||
cmd!(
|
||||
"find.change",
|
||||
@@ -153,6 +153,9 @@ impl Matcher {
|
||||
}
|
||||
}
|
||||
|
||||
/// A searched text: a story, or one table cell of it, and the byte range inside.
|
||||
type Target = (StoryId, Option<CellAddr>, Range<usize>);
|
||||
|
||||
/// A search owns its original domain, not the selection used to paint the latest hit.
|
||||
/// Weak identity avoids retaining an old document merely for this transient cache.
|
||||
#[derive(Clone)]
|
||||
@@ -163,7 +166,7 @@ pub(crate) struct FindContext {
|
||||
selection: Selection,
|
||||
domain: Domain,
|
||||
matcher: Matcher,
|
||||
targets: Vec<(StoryId, Range<usize>)>,
|
||||
targets: Vec<Target>,
|
||||
order: Vec<usize>,
|
||||
target: usize,
|
||||
offset: usize,
|
||||
@@ -184,21 +187,25 @@ impl FindContext {
|
||||
self.target = 0;
|
||||
self.last_match_end = None;
|
||||
self.order = (0..self.targets.len()).collect();
|
||||
let caret = st.selection.text.filter(|t| t.cell.is_none() && st.selection.cells.is_none());
|
||||
if self.domain == Domain::Document
|
||||
&& let Some(t) = st.selection.text
|
||||
&& let Some(i) = self.targets.iter().position(|(sid, _)| *sid == t.story)
|
||||
{
|
||||
let caret = st.selection.text.filter(|_| st.selection.cells.is_none());
|
||||
// A caret in a searched cell starts there; one in a footnote starts at its story.
|
||||
let start = st.selection.text.and_then(|t| {
|
||||
let at = |want: Option<CellAddr>| self.targets.iter().position(|(sid, cell, _)| *sid == t.story && *cell == want);
|
||||
at(t.cell).or_else(|| at(None))
|
||||
});
|
||||
if let Some(i) = start.filter(|_| self.domain == Domain::Document) {
|
||||
self.order.rotate_left(i);
|
||||
}
|
||||
let first = self.order.first().and_then(|i| self.targets.get(*i));
|
||||
self.offset = match first {
|
||||
Some((sid, r)) if self.domain != Domain::Selection => caret.filter(|t| t.story == *sid).map_or(r.start, |t| t.range().end.min(r.end)),
|
||||
Some((_, r)) => r.start,
|
||||
Some((sid, cell, r)) if self.domain != Domain::Selection => {
|
||||
caret.filter(|t| t.story == *sid && t.cell == *cell).map_or(r.start, |t| t.range().end.min(r.end))
|
||||
}
|
||||
Some((_, _, r)) => r.start,
|
||||
None => 0,
|
||||
};
|
||||
if let Some((sid, r)) = first {
|
||||
let text = target_text(&st.doc, *sid, r)?;
|
||||
if let Some((sid, cell, r)) = first {
|
||||
let text = target_text(&st.doc, *sid, *cell, r)?;
|
||||
if text.get(self.offset.saturating_sub(r.start)..).is_none() {
|
||||
return Err(bad("find", "invalid UTF-8 search cursor"));
|
||||
}
|
||||
@@ -209,7 +216,7 @@ impl FindContext {
|
||||
fn advance_target(&mut self) {
|
||||
self.target = self.target.saturating_add(1);
|
||||
self.last_match_end = None;
|
||||
self.offset = self.order.get(self.target).and_then(|i| self.targets.get(*i)).map_or(0, |(_, r)| r.start);
|
||||
self.offset = self.order.get(self.target).and_then(|i| self.targets.get(*i)).map_or(0, |(_, _, r)| r.start);
|
||||
}
|
||||
|
||||
fn advance_empty(&mut self, text: &str, relative_end: usize, end: usize) {
|
||||
@@ -222,8 +229,8 @@ impl FindContext {
|
||||
}
|
||||
}
|
||||
|
||||
fn target_text<'a>(d: &'a Document, sid: StoryId, r: &Range<usize>) -> Result<&'a str> {
|
||||
d.story(sid)
|
||||
fn target_text<'a>(d: &'a Document, sid: StoryId, cell: Option<CellAddr>, r: &Range<usize>) -> Result<&'a str> {
|
||||
d.text_story(sid, cell)
|
||||
.ok_or_else(|| bad("find", "no such story"))?
|
||||
.text
|
||||
.get(r.clone())
|
||||
@@ -246,7 +253,7 @@ fn scope(s: &Session, p: &Value) -> Result<FindContext> {
|
||||
let targets = match domain {
|
||||
Domain::Explicit(sid) => {
|
||||
let len = d.story(sid).ok_or_else(|| bad("find", "no such story"))?.len();
|
||||
vec![(sid, 0..len)]
|
||||
vec![(sid, None, 0..len)]
|
||||
}
|
||||
Domain::Story | Domain::Selection => {
|
||||
if st.selection.cells.is_some() || st.selection.text.is_some_and(|t| t.cell.is_some()) {
|
||||
@@ -255,10 +262,21 @@ fn scope(s: &Session, p: &Value) -> Result<FindContext> {
|
||||
let t = st.selection.text.ok_or_else(|| bad("find", "no text selected"))?;
|
||||
let story = d.story(t.story).ok_or_else(|| bad("find", "no such story"))?;
|
||||
let r = if domain == Domain::Selection { t.range() } else { 0..story.len() };
|
||||
target_text(d, t.story, &r)?;
|
||||
vec![(t.story, r)]
|
||||
target_text(d, t.story, None, &r)?;
|
||||
vec![(t.story, None, r)]
|
||||
}
|
||||
Domain::Document => d.stories.values().map(|x| (x.id, 0..x.len())).collect(),
|
||||
// Table cells hold their own text outside `stories`; search them right after the story that anchors the table.
|
||||
Domain::Document => d
|
||||
.stories
|
||||
.values()
|
||||
.flat_map(|x| {
|
||||
let cells = x.tables.iter().flat_map(move |(id, t)| {
|
||||
let cols = t.ncols().max(1);
|
||||
t.cells.iter().enumerate().map(move |(i, c)| (x.id, Some(CellAddr { table: *id, row: i / cols, col: i % cols }), 0..c.text.len()))
|
||||
});
|
||||
std::iter::once((x.id, None, 0..x.len())).chain(cells)
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
let mut context = FindContext {
|
||||
document: Arc::downgrade(d),
|
||||
@@ -285,10 +303,14 @@ fn find(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let context = scope(s, p)?;
|
||||
let st = s.doc()?;
|
||||
let mut out = Vec::new();
|
||||
for (sid, r) in &context.targets {
|
||||
let text = target_text(&st.doc, *sid, r)?;
|
||||
for (sid, cell, r) in &context.targets {
|
||||
let text = target_text(&st.doc, *sid, *cell, r)?;
|
||||
for m in re.find_all(text, MAX_MATCHES.saturating_sub(out.len())).map_err(|e| bad("find", e))? {
|
||||
out.push(json!({"story": sid.0, "start": r.start + m.start, "end": r.start + m.end, "text": text.get(m)}));
|
||||
let mut hit = json!({"story": sid.0, "start": r.start + m.start, "end": r.start + m.end, "text": text.get(m)});
|
||||
if let Some(c) = cell {
|
||||
hit["cell"] = json!({"table": c.table, "row": c.row, "col": c.col});
|
||||
}
|
||||
out.push(hit);
|
||||
}
|
||||
}
|
||||
s.find_context = Some(context);
|
||||
@@ -317,8 +339,8 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let targets = scope(s, p)?.targets;
|
||||
s.edit(|d, sel| {
|
||||
let mut count = 0usize;
|
||||
for (sid, r) in targets {
|
||||
let Some(story) = d.story(sid) else { continue };
|
||||
for (sid, cell, r) in targets {
|
||||
let Some(story) = d.text_story(sid, cell) else { continue };
|
||||
// Collect matches first (positions refer to the original text), apply back to front.
|
||||
let text = story.text.get(r.clone()).ok_or_else(|| bad("find", "invalid selection bounds or UTF-8 boundary"))?;
|
||||
let edits: Vec<(std::ops::Range<usize>, String)> = if grep {
|
||||
@@ -330,7 +352,7 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.into_iter()
|
||||
.map(|(m, out)| (r.start + m.start..r.start + m.end, out))
|
||||
.collect();
|
||||
let Some(st) = d.story_mut(sid) else { continue };
|
||||
let Some(st) = d.text_story_mut(sid, cell) else { continue };
|
||||
for (range, new) in edits.iter().rev() {
|
||||
st.replace(range.clone(), new);
|
||||
if !over.is_empty() && !new.is_empty() {
|
||||
@@ -338,7 +360,7 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid && t.cell.is_none()) {
|
||||
if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid && t.cell == cell) {
|
||||
// Repair byte validity once against the final story, without remapping valid offsets.
|
||||
let range = t.range();
|
||||
let start = floor_char_boundary(&st.text, range.start);
|
||||
@@ -365,8 +387,8 @@ fn find_next(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut context = scope(s, p)?;
|
||||
let st = s.doc()?;
|
||||
let mut hit = None;
|
||||
while let Some((sid, r)) = context.order.get(context.target).and_then(|i| context.targets.get(*i)).cloned() {
|
||||
let text = target_text(&st.doc, sid, &r)?;
|
||||
while let Some((sid, cell, r)) = context.order.get(context.target).and_then(|i| context.targets.get(*i)).cloned() {
|
||||
let text = target_text(&st.doc, sid, cell, &r)?;
|
||||
let from = context.offset.checked_sub(r.start).ok_or_else(|| bad("find", "invalid search cursor"))?;
|
||||
if text.get(from..).is_none() {
|
||||
return Err(bad("find", "invalid UTF-8 search cursor"));
|
||||
@@ -379,7 +401,7 @@ fn find_next(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
context.advance_empty(text, m.end, b);
|
||||
continue;
|
||||
}
|
||||
hit = Some((sid, a, b));
|
||||
hit = Some((sid, cell, a, b));
|
||||
context.last_match_end = Some(b);
|
||||
if m.is_empty() {
|
||||
context.advance_empty(text, m.end, b);
|
||||
@@ -390,17 +412,22 @@ fn find_next(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
context.advance_target();
|
||||
}
|
||||
let Some((sid, a, b)) = hit else {
|
||||
let Some((sid, cell, a, b)) = hit else {
|
||||
s.find_context = Some(context);
|
||||
return Ok(Value::Null);
|
||||
};
|
||||
let frame = st.doc.story(sid).and_then(|x| x.frames.first().copied());
|
||||
let cell_json = cell.map(|c| json!({"table": c.table, "row": c.row, "col": c.col}));
|
||||
let stm = s.doc_mut()?;
|
||||
stm.selection = Selection::text(TextSel { story: sid, anchor: a, focus: b, frame, cell: None });
|
||||
stm.selection = Selection::text(TextSel { story: sid, anchor: a, focus: b, frame, cell });
|
||||
stm.bump_revision();
|
||||
context.acknowledge(stm);
|
||||
s.find_context = Some(context);
|
||||
Ok(json!({"story": sid.0, "start": a, "end": b}))
|
||||
let mut out = json!({"story": sid.0, "start": a, "end": b});
|
||||
if let Some(c) = cell_json {
|
||||
out["cell"] = c;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1007,6 +1034,31 @@ mod tests {
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), history);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_scope_searches_and_changes_table_cells() {
|
||||
let (mut s, sid) = scope_session("Ore 12.15 here");
|
||||
scope_select(&mut s, sid, 0, 0);
|
||||
let table = s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap()["table"].as_u64().unwrap();
|
||||
s.execute("table.setCell", &json!({"row": 0, "col": 1, "text": "Ore 12.15"})).unwrap();
|
||||
// The caret sits in the first cell, so Find Next goes to the second cell, then the story.
|
||||
let p = json!({"find": "Ore 12.15", "scope": "document"});
|
||||
let cell = designcraft_doc::CellAddr { table, row: 0, col: 1 };
|
||||
assert_eq!(s.execute("find.next", &p).unwrap(), json!({"story": sid.0, "start": 0, "end": 9, "cell": {"table": table, "row": 0, "col": 1}}));
|
||||
assert_eq!(s.doc().unwrap().selection.text.map(|t| (t.story, t.range(), t.cell)), Some((sid, 0..9, Some(cell))));
|
||||
let next = s.execute("find.next", &p).unwrap();
|
||||
assert!(next.get("start").is_some() && next.get("cell").is_none(), "{next}");
|
||||
let hits = s.execute("find.find", &p).unwrap();
|
||||
let hits = hits.as_array().unwrap();
|
||||
assert_eq!(hits.len(), 2);
|
||||
let in_cell: Vec<_> = hits.iter().filter(|h| h.get("cell").is_some()).collect();
|
||||
assert_eq!(in_cell.len(), 1);
|
||||
assert_eq!((in_cell[0]["cell"]["row"].as_u64(), in_cell[0]["cell"]["col"].as_u64()), (Some(0), Some(1)));
|
||||
let n = s.execute("find.change", &json!({"find": "Ore 12.15", "change": "Ore 13.00", "scope": "document"})).unwrap();
|
||||
assert_eq!(n["count"], 2);
|
||||
assert!(s.execute("find.find", &json!({"find": "12.15", "scope": "document"})).unwrap().as_array().unwrap().is_empty());
|
||||
assert_eq!(s.execute("find.find", &json!({"find": "13.00", "scope": "document"})).unwrap().as_array().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
fn child_scope_session(note: bool, child: &str) -> (Session, designcraft_doc::StoryId, designcraft_doc::TextSel) {
|
||||
let (mut s, sid) = scope_session("red");
|
||||
scope_select(&mut s, sid, 3, 3);
|
||||
@@ -1051,12 +1103,15 @@ mod tests {
|
||||
p["story"] = json!(sid.0);
|
||||
p["scope"] = json!("selection");
|
||||
}
|
||||
// Document scope also covers table cells (#1624); an explicit story and footnotes stay top-level.
|
||||
let in_cell = if explicit || note { 0 } else { child.matches("red").count() };
|
||||
let changed = if in_cell > 0 { child.replace("red", "blue") } else { child.to_string() };
|
||||
s.doc_mut().unwrap().selection = selection.clone();
|
||||
assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1);
|
||||
assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1 + in_cell);
|
||||
scope_hit(&mut s, &p, sid, 0, 3);
|
||||
s.doc_mut().unwrap().selection = selection.clone();
|
||||
assert_eq!(s.execute("find.change", &p).unwrap()["count"], 1);
|
||||
assert_eq!(s.doc().unwrap().doc.text_story(sid, selected.cell).unwrap().text, child);
|
||||
assert_eq!(s.execute("find.change", &p).unwrap()["count"], 1 + in_cell);
|
||||
assert_eq!(s.doc().unwrap().doc.text_story(sid, selected.cell).unwrap().text, changed);
|
||||
assert_eq!(s.doc().unwrap().selection, selection, "parent replacement does not clamp child offsets");
|
||||
p["find"] = json!("blue");
|
||||
p["change"] = json!("red");
|
||||
|
||||
@@ -82,44 +82,49 @@ pub(crate) fn sync(d: &mut Document) {
|
||||
}
|
||||
}
|
||||
|
||||
/// InDesign's detection when objects become a container: objects in a row (or column) keep it,
|
||||
/// with Auto size and Space Between at their own spacing; others wrap in a row of their width.
|
||||
fn detect(bounds: &[Rect]) -> FlexLayout {
|
||||
let tol = 0.5;
|
||||
let in_line = |row: bool| {
|
||||
let mut v: Vec<&Rect> = bounds.iter().collect();
|
||||
v.sort_by(|a, b| if row { a.x0.total_cmp(&b.x0) } else { a.y0.total_cmp(&b.y0) });
|
||||
let apart = v.windows(2).all(|w| if row { w[1].x0 >= w[0].x1 - tol } else { w[1].y0 >= w[0].y1 - tol });
|
||||
let gaps: Vec<f64> = v.windows(2).map(|w| if row { w[1].x0 - w[0].x1 } else { w[1].y0 - w[0].y1 }).collect();
|
||||
(apart && bounds.len() > 1).then(|| gaps.iter().sum::<f64>() / gaps.len().max(1) as f64)
|
||||
};
|
||||
fn in_line_gap(bounds: &[Rect], row: bool, tol: f64) -> Option<f64> {
|
||||
let mut v: Vec<&Rect> = bounds.iter().collect();
|
||||
v.sort_by(|a, b| if row { a.x0.total_cmp(&b.x0) } else { a.y0.total_cmp(&b.y0) });
|
||||
let apart = v.windows(2).all(|w| if row { w[1].x0 >= w[0].x1 - tol } else { w[1].y0 >= w[0].y1 - tol });
|
||||
let gaps: Vec<f64> = v.windows(2).map(|w| if row { w[1].x0 - w[0].x1 } else { w[1].y0 - w[0].y1 }).collect();
|
||||
(apart && bounds.len() > 1).then(|| gaps.iter().sum::<f64>() / gaps.len().max(1) as f64)
|
||||
}
|
||||
|
||||
fn detect_align(bounds: &[Rect], row: bool, tol: f64) -> FlexAlign {
|
||||
let same = |f: &dyn Fn(&Rect) -> f64| bounds.windows(2).all(|w| (f(&w[0]) - f(&w[1])).abs() <= tol);
|
||||
let align = |row: bool| {
|
||||
let (a, c, e): (&dyn Fn(&Rect) -> f64, &dyn Fn(&Rect) -> f64, &dyn Fn(&Rect) -> f64) =
|
||||
if row { (&|r| r.y0, &|r| r.center().y, &|r| r.y1) } else { (&|r| r.x0, &|r| r.center().x, &|r| r.x1) };
|
||||
if same(a) {
|
||||
FlexAlign::Start
|
||||
} else if same(c) {
|
||||
FlexAlign::Center
|
||||
} else if same(e) {
|
||||
FlexAlign::End
|
||||
} else {
|
||||
FlexAlign::Start
|
||||
}
|
||||
};
|
||||
let auto = |gap: f64, row: bool| FlexLayout {
|
||||
let (a, c, e): (&dyn Fn(&Rect) -> f64, &dyn Fn(&Rect) -> f64, &dyn Fn(&Rect) -> f64) =
|
||||
if row { (&|r| r.y0, &|r| r.center().y, &|r| r.y1) } else { (&|r| r.x0, &|r| r.center().x, &|r| r.x1) };
|
||||
if same(a) {
|
||||
FlexAlign::Start
|
||||
} else if same(c) {
|
||||
FlexAlign::Center
|
||||
} else if same(e) {
|
||||
FlexAlign::End
|
||||
} else {
|
||||
FlexAlign::Start
|
||||
}
|
||||
}
|
||||
|
||||
fn auto_flex(gap: f64, row: bool, align: FlexAlign) -> FlexLayout {
|
||||
FlexLayout {
|
||||
direction: if row { FlexDirection::Row } else { FlexDirection::Column },
|
||||
justify: FlexJustify::SpaceBetween,
|
||||
align: align(row),
|
||||
align,
|
||||
width: FlexSize::Auto,
|
||||
height: FlexSize::Auto,
|
||||
gap_h: if row { gap.max(0.0) } else { 0.0 },
|
||||
gap_v: if row { 0.0 } else { gap.max(0.0) },
|
||||
..Default::default()
|
||||
};
|
||||
match (in_line(true), in_line(false)) {
|
||||
(Some(gap), _) => auto(gap, true),
|
||||
(None, Some(gap)) => auto(gap, false),
|
||||
}
|
||||
}
|
||||
|
||||
/// InDesign's detection when objects become a container: objects in a row (or column) keep it,
|
||||
/// with Auto size and Space Between at their own spacing; others wrap in a row of their width.
|
||||
fn detect(bounds: &[Rect]) -> FlexLayout {
|
||||
let tol = 0.5;
|
||||
match (in_line_gap(bounds, true, tol), in_line_gap(bounds, false, tol)) {
|
||||
(Some(gap), _) => auto_flex(gap, true, detect_align(bounds, true, tol)),
|
||||
(None, Some(gap)) => auto_flex(gap, false, detect_align(bounds, false, tol)),
|
||||
_ => FlexLayout { wrap: true, height: FlexSize::Auto, ..Default::default() },
|
||||
}
|
||||
}
|
||||
|
||||
+564
-27
@@ -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;
|
||||
|
||||
@@ -72,7 +72,9 @@ fn add_reference(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"currentPage" => IndexRange::CurrentPage,
|
||||
"toEndOfStory" => IndexRange::ToEndOfStory,
|
||||
"suppressPageRange" => IndexRange::SuppressPageRange,
|
||||
"nextParagraphs" => IndexRange::NextParagraphs(p.get("paragraphs").and_then(Value::as_u64).unwrap_or(1) as u32),
|
||||
"nextParagraphs" => {
|
||||
IndexRange::NextParagraphs(p.get("paragraphs").and_then(Value::as_u64).map_or(1, |x| u32::try_from(x).unwrap_or(u32::MAX)))
|
||||
}
|
||||
"see" => IndexRange::See(target()),
|
||||
"seeAlso" => IndexRange::SeeAlso(target()),
|
||||
r => return Err(bad("index.addReference", format!("unknown range `{r}`"))),
|
||||
@@ -103,8 +105,9 @@ fn resolve(s: &Session, d: &Document, skip: Option<StoryId>) -> Vec<(StoryId, us
|
||||
IndexRange::ToEndOfStory => page(st.len().saturating_sub(1)),
|
||||
IndexRange::NextParagraphs(n) => {
|
||||
let ranges = st.para_ranges();
|
||||
let pi = (st.para_at(pos) + *n as usize).min(ranges.len() - 1);
|
||||
page(ranges[pi].end.saturating_sub(1).max(ranges[pi].start))
|
||||
let n = usize::try_from(*n).unwrap_or(usize::MAX);
|
||||
let pi = st.para_at(pos).saturating_add(n).min(ranges.len().saturating_sub(1));
|
||||
ranges.get(pi).and_then(|r| page(r.end.saturating_sub(1).max(r.start)))
|
||||
}
|
||||
_ => start,
|
||||
};
|
||||
|
||||
@@ -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<_>>(),
|
||||
})
|
||||
|
||||
+496
-12
@@ -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!(
|
||||
@@ -112,14 +161,25 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Insert Pages…",
|
||||
["Layout", "Pages"],
|
||||
Some("Cmd+Shift+P"),
|
||||
"{count?: 1, after?: page index (default: last), parent?: id|\"A-Parent\"|\"A\"|null}",
|
||||
"{count?: 1 (1 to 9999), after?: 0-based page index (default: the last page), parent?: id|\"A-Parent\"|\"A\"|null}",
|
||||
has_doc,
|
||||
|s, p| {
|
||||
let count = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 9999) as usize;
|
||||
const ID: &str = "layout.pages.insert";
|
||||
// A count that is given but is not a number of pages used to insert one page:
|
||||
// `0`, `-1` and `1.5` all came out as 1, and 10000 was quietly capped.
|
||||
let count = match p.get("count").filter(|v| !v.is_null()) {
|
||||
None => 1,
|
||||
Some(v) => match v.as_u64().filter(|n| (1..=9999).contains(n)).map(|n| n as usize) {
|
||||
Some(n) => n,
|
||||
None => return Err(bad(ID, format!("count {v}: 1 to 9999 pages"))),
|
||||
},
|
||||
};
|
||||
let st = s.doc()?;
|
||||
let n = st.doc.page_count();
|
||||
// An `after` past the last page appends, which `insert_after_u64_max_appends_at_the_end`
|
||||
// pins deliberately.
|
||||
let after = p.get("after").and_then(Value::as_u64).map(|v| usize::try_from(v).unwrap_or(usize::MAX)).unwrap_or(n.saturating_sub(1));
|
||||
let parent = parent_ref("layout.pages.insert", &st.doc, p.get("parent"))?.unwrap_or_else(|| st.doc.parents.first().map(|x| x.id));
|
||||
let parent = parent_ref(ID, &st.doc, p.get("parent"))?.unwrap_or_else(|| st.doc.parents.first().map(|x| x.id));
|
||||
s.edit(|d, _| {
|
||||
let ids = d.insert_pages(Some(after), count, parent)?;
|
||||
Ok(json!({"pages": ids.len(), "total": d.page_count()}))
|
||||
@@ -197,8 +257,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 +313,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 +441,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 +559,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 +890,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 +1085,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 +1778,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 +2261,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)]
|
||||
@@ -2055,3 +2508,34 @@ mod parent_base_tests {
|
||||
#[cfg(test)]
|
||||
#[path = "parent_delete_tests.rs"]
|
||||
mod parent_delete_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod insert_count_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// A count that is given but is not a number of pages used to insert one page anyway:
|
||||
/// `{"count": 0}` added a page, `-1` and `1.5` added one each, and `10000` was quietly capped
|
||||
/// to 9999. A position past the last page appends, which is deliberate.
|
||||
#[test]
|
||||
fn a_page_count_insert_pages_cannot_mean_is_refused() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap();
|
||||
let total = |s: &mut Session| s.execute("layout.pages.insert", &json!({"count": 1})).unwrap()["total"].as_u64().unwrap();
|
||||
assert_eq!(total(&mut s), 3, "one page added");
|
||||
|
||||
for params in [json!({"count": 0}), json!({"count": -1}), json!({"count": 1.5}), json!({"count": 10000}), json!({"count": "two"})] {
|
||||
let e = s.execute("layout.pages.insert", ¶ms).expect_err("refused").to_string();
|
||||
assert!(e.contains("1 to 9999 pages"), "{params}: {e}");
|
||||
}
|
||||
// Nothing was inserted by any of those.
|
||||
assert_eq!(s.doc().unwrap().doc.page_count(), 3);
|
||||
// A position past the last page still appends, which `insert_pages_bounds.rs` pins.
|
||||
let r = s.execute("layout.pages.insert", &json!({"after": 99})).unwrap();
|
||||
assert_eq!((r["pages"].as_u64(), r["total"].as_u64()), (Some(1), Some(4)));
|
||||
// The positions it does have still work: after the first page of three.
|
||||
let r = s.execute("layout.pages.insert", &json!({"count": 2, "after": 0})).unwrap();
|
||||
assert_eq!((r["pages"].as_u64(), r["total"].as_u64()), (Some(2), Some(6)));
|
||||
}
|
||||
}
|
||||
@@ -144,7 +144,7 @@ pub fn link_missing(a: &designcraft_doc::Asset, document: Option<&Path>) -> bool
|
||||
let Some(link) = &a.link else { return false };
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
crate::io::document_path(link, document).map_or(true, |path| !crate::io::exists(path))
|
||||
!crate::io::document_path(link, document).is_ok_and(crate::io::exists)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
@@ -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))
|
||||
}
|
||||
@@ -487,8 +490,18 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(dir) => Some(dir.clone()),
|
||||
None => a.link.as_deref().and_then(|l| crate::io::document_path(l, document).ok()).and_then(|p| p.parent().map(Path::to_path_buf)),
|
||||
};
|
||||
let plain_name = name.to_str().is_some_and(|n| !n.contains(['/', '\\']) && craft_docpath::is_local(n));
|
||||
let cand = folder.filter(|_| plain_name).map(|f| f.join(&name));
|
||||
let plain_name = name.to_str().filter(|n| !n.contains(['/', '\\']));
|
||||
// A name that resolves to another file name (a `file:` URL such as `file:%2Ftmp%2Fx.png`,
|
||||
// a drive-relative `C:x.png`) or a folder the stored-path rules read differently (`a\b`
|
||||
// on Unix, `alias/..` through a symlink) could lead outside the folder, so only the name
|
||||
// itself, directly in the folder as the file system resolves it, counts. The link keeps
|
||||
// the folder as given.
|
||||
let cand = folder.zip(plain_name).and_then(|(f, n)| {
|
||||
let c = crate::io::folder_path(n, &f).ok()?;
|
||||
let real = |p: &Path| crate::io::canonicalize(p).ok();
|
||||
let same_folder = c.parent().and_then(real).is_some_and(|p| real(&f) == Some(p));
|
||||
(c.file_name().is_some_and(|c| c == n) && same_folder).then_some(c)
|
||||
});
|
||||
if let Some(cand) = cand.filter(|c| crate::io::exists(c)) {
|
||||
todo.push((a.id, cand));
|
||||
} else {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -686,7 +686,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Align",
|
||||
["Window", "Object & Layout", "Align"],
|
||||
None,
|
||||
"{edge: left|hcenter|right|top|vcenter|bottom, to?: selection|keyObject|margins|page|spread, ids?}",
|
||||
"{edge: left|hcenter|right|top|vcenter|bottom, to?: selection|keyObject|margins|page|spread (default: keyObject while a key object is set), ids?}",
|
||||
has_selection,
|
||||
align
|
||||
),
|
||||
@@ -708,7 +708,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
super::always,
|
||||
|s, p| {
|
||||
if let Some(n) = p.get("sides").and_then(Value::as_u64) {
|
||||
s.prefs.polygon_sides = (n as u32).clamp(3, 100);
|
||||
s.prefs.polygon_sides = n.clamp(3, 100) as u32;
|
||||
}
|
||||
if let Some(v) = p.get("starInset").and_then(Value::as_f64) {
|
||||
s.prefs.star_inset = (v / 100.0).clamp(0.0, 1.0);
|
||||
@@ -972,7 +972,7 @@ fn frame_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = super::text_param(p, "text");
|
||||
let caret = bool_or(p, "caret", content == "text");
|
||||
let vertical = bool_or(p, "vertical", false);
|
||||
let sides = p.get("sides").and_then(Value::as_u64).map_or(s.prefs.polygon_sides, |v| v as u32).clamp(3, 100);
|
||||
let sides = p.get("sides").and_then(Value::as_u64).unwrap_or(u64::from(s.prefs.polygon_sides)).clamp(3, 100) as u32;
|
||||
let inset = p.get("starInset").and_then(Value::as_f64).map_or(s.prefs.star_inset, |v| v / 100.0).clamp(0.0, 1.0);
|
||||
let rect = Rect::new(rect.x0, rect.y0, rect.x1.max(rect.x0 + 0.5), rect.y1.max(rect.y0 + 0.5));
|
||||
s.edit(|d, sel| {
|
||||
@@ -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()
|
||||
@@ -2516,7 +2517,7 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let Some(tf) = d.item_mut(*id).and_then(Item::text_frame_mut) else { continue };
|
||||
let o = &mut tf.options;
|
||||
if let Some(v) = p.get("columns").and_then(Value::as_u64) {
|
||||
o.columns = (v as u32).clamp(1, 40);
|
||||
o.columns = v.clamp(1, 40) as u32;
|
||||
}
|
||||
if let Some(v) = p.get("gutter").and_then(Value::as_f64) {
|
||||
o.gutter = v.max(0.0);
|
||||
@@ -3073,8 +3074,18 @@ fn align_reference(d: &Document, ids: &[ItemId], to: &str, key: Option<ItemId>)
|
||||
fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let edge = str_param(p, "edge").unwrap_or("left").to_string();
|
||||
let to = str_param(p, "to").map(str::to_string).unwrap_or_else(|| if ids.len() > 1 { "selection".into() } else { "page".into() });
|
||||
let key = s.doc()?.selection.key;
|
||||
// A key object counts only while it is one of several items aligned. With one, InDesign's
|
||||
// Align To switches to Key Object, so it is also the default target.
|
||||
let key = s.doc()?.selection.key.filter(|k| ids.len() > 1 && ids.contains(k));
|
||||
let to = str_param(p, "to").map(str::to_string).unwrap_or_else(|| {
|
||||
if key.is_some() {
|
||||
"keyObject".into()
|
||||
} else if ids.len() > 1 {
|
||||
"selection".into()
|
||||
} else {
|
||||
"page".into()
|
||||
}
|
||||
});
|
||||
s.edit(|d, _| {
|
||||
let r = align_reference(d, &ids, &to, key).ok_or_else(|| bad("object.align", "nothing to align"))?;
|
||||
for id in &ids {
|
||||
@@ -3787,6 +3798,21 @@ mod polygon_tests {
|
||||
.unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.subpaths[0].anchors.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polygon_sides_clamp_before_narrowing() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("tool.polygonSettings", &json!({"sides": 4294967301u64})).unwrap();
|
||||
assert_eq!(r["sides"], 100);
|
||||
let r = s.execute("tool.polygonSettings", &json!({"sides": 4294967297u64})).unwrap();
|
||||
assert_eq!(r["sides"], 100);
|
||||
let id =
|
||||
s.execute("frame.create", &json!({"rect": [0, 0, 50, 50], "shape": "polygon", "sides": 4294967301u64, "starInset": 0})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.subpaths[0].anchors.len(), 100);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -4497,6 +4523,21 @@ mod column_rule_tests {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(opts(&s), before);
|
||||
}
|
||||
|
||||
/// A column count past `u32::MAX` was cast to `u32` before the clamp, so
|
||||
/// 2^32 + 1 wrapped to 1 column instead of clamping to the maximum, 40.
|
||||
#[test]
|
||||
fn huge_column_count_clamps_to_the_maximum() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text"})).unwrap();
|
||||
let id = ItemId(r["id"].as_u64().unwrap());
|
||||
let columns = |s: &Session| s.doc().unwrap().doc.item(id).unwrap().text_frame().unwrap().options.columns;
|
||||
for (given, want) in [(4_294_967_297_u64, 40), (u64::MAX, 40), (0, 1)] {
|
||||
s.execute("object.textFrameOptions", &json!({"ids": [id.0], "columns": given})).unwrap();
|
||||
assert_eq!(columns(&s), want, "columns {given}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -135,8 +135,9 @@ impl OutputRoot {
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
// Match fs::write for a new output: 0666 restricted by the caller's umask.
|
||||
builder.permissions(std::fs::Permissions::from_mode(0o666));
|
||||
// Like fs::write for a new output: request 0666 and let the process umask decide the
|
||||
// effective mode (0644 with the usual 022, 0664 with 002).
|
||||
builder.permissions(std::fs::Permissions::from_mode(0o666)); // NOSONAR
|
||||
}
|
||||
let file = builder.tempfile_in(parent).map_err(|e| path_error(path, e))?;
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -42,6 +42,22 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
if ids.iter().all(|id| placed_pdf(s, *id).is_none()) {
|
||||
return Err(bad("object.layerOptions", "select a placed PDF"));
|
||||
}
|
||||
// Validate every selected PDF before editing any item or recording undo.
|
||||
// Repeated placements of the same source need only one rewrite check.
|
||||
if !hidden.is_empty() {
|
||||
let mut checked = std::collections::HashSet::new();
|
||||
for id in &ids {
|
||||
if let Some(data) = placed_pdf(s, *id)
|
||||
&& checked.insert(std::sync::Arc::as_ptr(&data))
|
||||
&& designcraft_render::pdf_hide_layers(&data, &hidden).is_none()
|
||||
{
|
||||
return Err(bad(
|
||||
"object.layerOptions",
|
||||
"the selected PDF layers could not be hidden; use the file defaults or replace the unsupported PDF",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
s.edit(|d, _| {
|
||||
for id in &ids {
|
||||
if let Some(it) = d.item_mut(*id) {
|
||||
@@ -63,31 +79,277 @@ pub(crate) mod tests {
|
||||
|
||||
/// A one-page PDF (100×100 pt) with two layers: "Blue" (left half) and "Red" (right half).
|
||||
pub(crate) fn two_layer_pdf() -> Vec<u8> {
|
||||
two_layer_pdf_generation(0)
|
||||
}
|
||||
|
||||
fn two_layer_pdf_generation(generation: i32) -> Vec<u8> {
|
||||
two_layer_pdf_options(generation, "", "")
|
||||
}
|
||||
|
||||
fn two_layer_pdf_options(generation: i32, config: &str, trailer: &str) -> Vec<u8> {
|
||||
let content = "/OC /a BDC 0 0 1 rg 0 0 50 100 re f EMC /OC /b BDC 1 0 0 rg 50 0 50 100 re f EMC";
|
||||
let objs = [
|
||||
"<</Type/Catalog/Pages 2 0 R/OCProperties<</OCGs[5 0 R 6 0 R]/D<</Order[5 0 R 6 0 R]>>>>>>".to_string(),
|
||||
format!(
|
||||
"<</Type/Catalog/Lang(en-GB)/Metadata 7 0 R/OutputIntents[8 0 R]/Pages 2 0 R/OCProperties<</OCGs[5 0 R 6 0 R]/D<</Order[5 0 R 6 0 R]/Name(Default)/Locked[5 0 R]{config}>>>>>>"
|
||||
),
|
||||
"<</Type/Pages/Kids[3 0 R]/Count 1>>".to_string(),
|
||||
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Resources<</Properties<</a 5 0 R/b 6 0 R>>>>/Contents 4 0 R>>".to_string(),
|
||||
format!("<</Length {}>>\nstream\n{content}\nendstream", content.len()),
|
||||
"<</Type/OCG/Name(Blue)>>".to_string(),
|
||||
"<</Type/OCG/Name(Red)>>".to_string(),
|
||||
"<</Type/Metadata/Subtype/XML/Length 4>>\nstream\n<x/>\nendstream".to_string(),
|
||||
"<</Type/OutputIntent/S/GTS_PDFX/OutputConditionIdentifier(Test)>>".to_string(),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offs = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offs.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
out.extend_from_slice(format!("{} {generation} obj\n{}\nendobj\n", i + 1, o.replace(" 0 R", &format!(" {generation} R"))).as_bytes());
|
||||
}
|
||||
let x = out.len();
|
||||
let mut t = format!("xref\n0 {}\n0000000000 65535 f\r\n", objs.len() + 1);
|
||||
for o in offs {
|
||||
t.push_str(&format!("{o:010} 00000 n\r\n"));
|
||||
t.push_str(&format!("{o:010} {generation:05} n\r\n"));
|
||||
}
|
||||
t.push_str(&format!("trailer\n<</Size {}/Root 1 0 R>>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1));
|
||||
t.push_str(&format!("trailer\n<</Size {}/Root 1 {generation} R{trailer}>>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1));
|
||||
out.extend_from_slice(t.as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_layer_update_preserves_catalog_configuration_and_generations() {
|
||||
for generation in [0, 1] {
|
||||
let original = two_layer_pdf_generation(generation);
|
||||
let updated = designcraft_render::pdf_hide_layers(&original, &["Red".into()]).unwrap();
|
||||
assert_eq!(designcraft_render::pdf_layers(&updated), vec![("Blue".into(), true), ("Red".into(), false)]);
|
||||
let appended = std::str::from_utf8(&updated[original.len()..]).unwrap();
|
||||
for token in [
|
||||
"/Lang(en-GB)".to_string(),
|
||||
format!("/Metadata 7 {generation} R"),
|
||||
format!("/OutputIntents[8 {generation} R]"),
|
||||
format!("/Locked[5 {generation} R]"),
|
||||
"/Name(Default)".into(),
|
||||
format!("/Root 1 {generation} R"),
|
||||
] {
|
||||
assert!(appended.contains(&token), "missing {token}: {appended}");
|
||||
}
|
||||
assert!(!appended.contains("9999000"));
|
||||
let all_on = designcraft_render::pdf_hide_layers(&updated, &[]).unwrap();
|
||||
assert!(designcraft_render::pdf_layers(&all_on).iter().all(|(_, visible)| *visible));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_layer_update_accepts_xref_streams_and_rejects_unsupported_updates() {
|
||||
let original = two_layer_pdf();
|
||||
let text = std::str::from_utf8(&original).unwrap();
|
||||
let offset: usize = text.rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse().unwrap();
|
||||
let mut stream_pdf = original[..offset].to_vec();
|
||||
let mut entries = vec![0, 0, 0, 0, 0, 255, 255];
|
||||
for number in 1..=9 {
|
||||
let at = if number == 9 { offset } else { text.find(&format!("{number} 0 obj")).unwrap() };
|
||||
entries.push(1);
|
||||
entries.extend_from_slice(&u32::try_from(at).unwrap().to_be_bytes());
|
||||
entries.extend_from_slice(&[0, 0]);
|
||||
}
|
||||
stream_pdf.extend_from_slice(format!("9 0 obj\n<</Type/XRef/Size 10/Root 1 0 R/W[1 4 2]/Length {}>>\nstream\n", entries.len()).as_bytes());
|
||||
stream_pdf.extend_from_slice(&entries);
|
||||
stream_pdf.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{offset}\n%%EOF\n").as_bytes());
|
||||
let updated = designcraft_render::pdf_hide_layers(&stream_pdf, &["Red".into()]).unwrap();
|
||||
assert_eq!(designcraft_render::pdf_layers(&updated), vec![("Blue".into(), true), ("Red".into(), false)]);
|
||||
for unsupported in ["/Encrypt 7 0 R", "/Encrypt << /Filter /Standard >>", "/XRefStm 0"] {
|
||||
let altered = text.replace("/Size 9", &format!("{unsupported}/Size 9"));
|
||||
assert!(designcraft_render::pdf_hide_layers(altered.as_bytes(), &["Red".into()]).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_layer_update_follows_forward_prev_and_rejects_cycles() {
|
||||
let original = two_layer_pdf();
|
||||
let text = std::str::from_utf8(&original).unwrap();
|
||||
let first = original.len();
|
||||
let link = |prev: usize| format!("xref\n0 1\n0000000000 65535 f\ntrailer<</Size 9/Root 1 0 R/Prev {prev:010}>>\n");
|
||||
let main = first + link(0).len();
|
||||
let mut forward = original.clone();
|
||||
forward.extend_from_slice(link(main).as_bytes());
|
||||
let old: usize = text.rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse().unwrap();
|
||||
forward.extend_from_slice(text[old..].split("startxref").next().unwrap().as_bytes());
|
||||
forward.extend_from_slice(format!("startxref\n{first}\n%%EOF\n").as_bytes());
|
||||
let updated = designcraft_render::pdf_hide_layers(&forward, &["Red".into()]).unwrap();
|
||||
assert_eq!(designcraft_render::pdf_layers(&updated), vec![("Blue".into(), true), ("Red".into(), false)]);
|
||||
let mut cyclic = original;
|
||||
cyclic.extend_from_slice(link(first).as_bytes());
|
||||
cyclic.extend_from_slice(format!("startxref\n{first}\n%%EOF\n").as_bytes());
|
||||
assert!(designcraft_render::pdf_hide_layers(&cyclic, &[]).is_none());
|
||||
}
|
||||
|
||||
fn add_pdf_revision(bytes: &mut Vec<u8>) {
|
||||
let text = std::str::from_utf8(bytes).unwrap();
|
||||
let prev: usize = text.rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse().unwrap();
|
||||
let xref = bytes.len();
|
||||
bytes.extend_from_slice(
|
||||
format!("xref\n0 1\n0000000000 65535 f\r\ntrailer\n<</Size 9/Root 1 0 R/Prev {prev}>>\nstartxref\n{xref}\n%%EOF\n").as_bytes(),
|
||||
);
|
||||
}
|
||||
|
||||
fn place(s: &mut Session, bytes: &[u8], x: f64) -> serde_json::Value {
|
||||
s.execute("file.place", &json!({"base64": super::super::file::base64_encode(bytes), "name": "layers.pdf", "x": x, "y": 0, "width": 100}))
|
||||
.unwrap()["id"]
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn pixel(s: &Session, x: u32) -> [u8; 4] {
|
||||
let mut renderer = designcraft_render::Renderer::new();
|
||||
renderer.threads = 0;
|
||||
renderer.render_page(&s.doc().unwrap().doc, &s.cache, 0, 1.0, false, &Default::default()).unwrap().pixel(x, 50)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_encryption_entry_keeps_layer_editing_rendering_and_export_working() {
|
||||
for generation in [0, 1] {
|
||||
let bytes = two_layer_pdf_options(generation, "", "/Encrypt null");
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap();
|
||||
let id = place(&mut s, &bytes, 0.0);
|
||||
s.execute("object.layerOptions", &json!({"ids": [id], "hidden": ["Red"]})).unwrap();
|
||||
assert_eq!(s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["visible"], false);
|
||||
assert_eq!(pixel(&s, 25), [0, 0, 255, 255]);
|
||||
assert_eq!(pixel(&s, 75), [255, 255, 255, 255]);
|
||||
s.execute("file.exportPdf", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(pixel(&s, 75), [255, 0, 0, 255]);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(pixel(&s, 75), [255, 255, 255, 255]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrewritable_layer_choices_are_rejected_atomically_without_an_undo_step() {
|
||||
let mut unsupported = two_layer_pdf();
|
||||
for _ in 0..32 {
|
||||
add_pdf_revision(&mut unsupported);
|
||||
}
|
||||
assert_eq!(designcraft_render::pdf_layers(&unsupported).len(), 2);
|
||||
assert!(designcraft_render::pdf_hide_layers(&unsupported, &["Red".into()]).is_none());
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100, "pages": 1})).unwrap();
|
||||
let good = place(&mut s, &two_layer_pdf(), 0.0);
|
||||
let bad = place(&mut s, &unsupported, 100.0);
|
||||
s.execute("object.layerOptions", &json!({"ids": [good], "hidden": ["Blue"]})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(!s.doc().unwrap().history.redo.is_empty());
|
||||
let before = s.execute("file.serialize", &json!({})).unwrap();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let redo = s.doc().unwrap().history.redo.len();
|
||||
for ids in [json!([good, bad]), json!([bad, good])] {
|
||||
let err = s.execute("object.layerOptions", &json!({"ids": ids, "hidden": ["Red"]})).unwrap_err();
|
||||
assert!(err.to_string().contains("could not be hidden"));
|
||||
assert_eq!(s.execute("file.serialize", &json!({})).unwrap(), before);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
assert_eq!(s.doc().unwrap().history.redo.len(), redo);
|
||||
for id in [&good, &bad] {
|
||||
assert_eq!(s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["visible"], true);
|
||||
}
|
||||
}
|
||||
assert_eq!(pixel(&s, 75), [255, 0, 0, 255]);
|
||||
assert_eq!(pixel(&s, 175), [255, 0, 0, 255]);
|
||||
// A persisted/relinked choice can be unrewritable. Reset must still work.
|
||||
let bad_id = serde_json::from_value(bad.clone()).unwrap();
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).item_mut(bad_id).unwrap().pdf_hidden_layers = vec!["Red".into()];
|
||||
assert_eq!(pixel(&s, 175), [255, 255, 255, 255], "stale hidden content must not reappear on canvas");
|
||||
assert!(s.execute("file.exportPdf", &json!({})).is_err());
|
||||
s.execute("object.layerOptions", &json!({"ids": [good, bad], "hidden": []})).unwrap();
|
||||
assert_eq!(s.execute("object.pdfLayers", &json!({"id": bad})).unwrap()[1]["visible"], true);
|
||||
s.execute("file.exportPdf", &json!({})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_non_null_prev_entries_refuse_layer_rewrites() {
|
||||
for prev in ["7 0 R", "-1", "false", "<<>>", "[]", "/Unknown", "(unknown)"] {
|
||||
let bytes = two_layer_pdf_options(0, "", &format!("/Prev {prev}"));
|
||||
assert_eq!(designcraft_render::pdf_layers(&bytes).len(), 2, "reader control: {prev}");
|
||||
assert!(designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).is_none(), "must not truncate unknown history: {prev}");
|
||||
}
|
||||
for trailer in ["", "/Prev null"] {
|
||||
let bytes = two_layer_pdf_options(0, "", trailer);
|
||||
assert!(designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).is_some(), "end-of-chain control: {trailer}");
|
||||
}
|
||||
let mut revised = two_layer_pdf();
|
||||
let prev =
|
||||
std::str::from_utf8(&revised).unwrap().rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse::<usize>().unwrap();
|
||||
add_pdf_revision(&mut revised);
|
||||
let real_prev = std::str::from_utf8(&revised).unwrap().replace(&format!("/Prev {prev}>>"), &format!("/Prev {prev}.0>>"));
|
||||
assert!(real_prev.contains(&format!("/Prev {prev}.0>>")));
|
||||
for bytes in [revised, real_prev.into_bytes()] {
|
||||
assert!(designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).is_some(), "readable integer/real history must still work");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fake_null_trailer_cannot_bypass_hybrid_rejection() {
|
||||
for real_guard in ["", "/XRefStm 0"] {
|
||||
let bytes = two_layer_pdf_options(0, "", real_guard);
|
||||
let text = std::str::from_utf8(&bytes).unwrap();
|
||||
for fake in ["/Encrypt null", ""] {
|
||||
let comment = format!("% trailer <</Size 9/Root 1 0 R{fake}>>\n");
|
||||
let inside = text.replacen("xref\n", &format!("xref\n{comment}"), 1);
|
||||
assert!(inside.contains(&format!("xref\n{comment}")));
|
||||
// Repair-only comments inside a table are always refused, guard or not.
|
||||
assert_eq!(designcraft_render::pdf_layers(inside.as_bytes()).len(), 2);
|
||||
assert!(designcraft_render::pdf_hide_layers(inside.as_bytes(), &["Red".into()]).is_none());
|
||||
// Before xref or after the dictionary, the real trailer decides.
|
||||
let leading = text.replacen("xref\n", &format!("{comment}xref\n"), 1);
|
||||
let trailing = text.replacen("startxref\n", &format!("{comment}startxref\n"), 1);
|
||||
for source in [leading, trailing] {
|
||||
assert_eq!(designcraft_render::pdf_layers(source.as_bytes()).len(), 2);
|
||||
assert_eq!(designcraft_render::pdf_hide_layers(source.as_bytes(), &["Red".into()]).is_some(), real_guard.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_all_still_measures_layerless_and_stub_properties() {
|
||||
use designcraft_doc::PdfCrop;
|
||||
for props in ["", "/OCProperties null", "/OCProperties<<>>", "/OCProperties<</OCGs[]/D<<>>>>"] {
|
||||
let content = "0 0 1 rg 0 0 50 100 re f";
|
||||
let objs = [
|
||||
format!("<</Type/Catalog/Pages 2 0 R{props}>>"),
|
||||
"<</Type/Pages/Kids[3 0 R]/Count 1>>".into(),
|
||||
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Contents 4 0 R>>".into(),
|
||||
format!("<</Length {}>>\nstream\n{content}\nendstream", content.len()),
|
||||
];
|
||||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, body) in objs.iter().enumerate() {
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = bytes.len();
|
||||
bytes.extend_from_slice(b"xref\n0 5\n0000000000 65535 f\r\n");
|
||||
for at in offsets {
|
||||
bytes.extend_from_slice(format!("{at:010} 00000 n\r\n").as_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(format!("trailer\n<</Size 5/Root 1 0 R>>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
|
||||
assert!(designcraft_render::pdf_layers(&bytes).is_empty());
|
||||
assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentAll).unwrap().1, (50.0, 100.0), "{props}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_all_keeps_the_whole_page_when_layer_rewriting_is_unsupported() {
|
||||
use designcraft_doc::PdfCrop;
|
||||
let mut bytes = two_layer_pdf_options(0, "/BaseState/OFF/ON[5 0 R]", "");
|
||||
assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentVisible).unwrap().1, (50.0, 100.0));
|
||||
assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentAll).unwrap().1, (100.0, 100.0));
|
||||
for _ in 0..32 {
|
||||
add_pdf_revision(&mut bytes);
|
||||
}
|
||||
assert!(designcraft_render::pdf_hide_layers(&bytes, &[]).is_none());
|
||||
assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentVisible).unwrap().1, (50.0, 100.0));
|
||||
assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentAll).unwrap(), ([0.0, 0.0, 1.0, 1.0], (100.0, 100.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placed_pdf_layers_hide() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -233,11 +233,12 @@ fn same_rect(a: Rect, b: Rect) -> bool {
|
||||
}
|
||||
|
||||
/// Thread new frames until the story fits: first into the columns after the last frame's column
|
||||
/// on its page (when it sits in one), then one frame per column on each page it adds. With
|
||||
/// `primary` (Smart Text Reflow of the primary text frame), each added page gets one frame on the
|
||||
/// live area with the page's columns instead. Empty frames it made at the end of the thread are
|
||||
/// removed again, except the first on a page it didn't add; a page it added that holds only such
|
||||
/// a frame goes with it. Returns pages added.
|
||||
/// on its page (when it sits in one), then one frame per column on each page that follows it,
|
||||
/// whatever else the page holds, and on each page it adds at the end of the document, as InDesign
|
||||
/// does. With `primary` (Smart Text Reflow of the primary text frame), pages are added right after
|
||||
/// the last frame's page, each with one frame on the live area with the page's columns instead.
|
||||
/// Empty frames it made at the end of the thread are removed again; a page it added that holds
|
||||
/// only such a frame goes with it. Returns pages added.
|
||||
/// A story on a parent spread doesn't autoflow: its frames thread only on that parent. Errors
|
||||
/// name `cmd`, the command that autoflows.
|
||||
pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &str, primary: bool) -> Result<usize> {
|
||||
@@ -252,6 +253,8 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s
|
||||
let mut made = Vec::new();
|
||||
let mut new_pages = Vec::new();
|
||||
let mut first = true;
|
||||
// The next page of the document autoflow may set frames on; it only moves forward.
|
||||
let mut following = 0;
|
||||
loop {
|
||||
let (overset, held, full) = fit(d, sid);
|
||||
let Some(at) = overset else { break };
|
||||
@@ -267,17 +270,34 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s
|
||||
let rects = flow_rects(d, dir, vertical, page);
|
||||
let next = d.item(last).and_then(|it| rects.iter().position(|r| same_rect(*r, it.bounds()))).map_or(rects.len(), |k| k + 1);
|
||||
if let (Some(rest), Some((si, _))) = (rects.get(next..).filter(|r| !r.is_empty()), d.page_loc(page)) {
|
||||
for r in rest.iter().take(want) {
|
||||
// Whole pages: an estimate that stops inside one would leave its later columns out
|
||||
// of the thread for good. The cleanup below takes the frames the text doesn't reach.
|
||||
for r in rest {
|
||||
prev = add_flow_frame(d, si, *r, layer, prev)?;
|
||||
made.push(prev);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
following = following.max(page + 1);
|
||||
if !primary && following < d.page_count() {
|
||||
let mut left = want;
|
||||
while left > 0 && following < d.page_count() {
|
||||
let Some((si, _)) = d.page_loc(following) else { break };
|
||||
for r in flow_rects(d, dir, vertical, following) {
|
||||
prev = add_flow_frame(d, si, r, layer, prev)?;
|
||||
made.push(prev);
|
||||
left = left.saturating_sub(1);
|
||||
}
|
||||
following += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
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) };
|
||||
// Past the following pages, `page` is the document's last: autoflow adds pages at its end.
|
||||
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)?);
|
||||
@@ -304,16 +324,23 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s
|
||||
}
|
||||
let page = d.page_of_item(f);
|
||||
if d.page_of_item(*before) != page {
|
||||
// The first frame on its page: the estimate added more pages than the text needs.
|
||||
// Drop the page too when autoflow added it and the frame is all it holds.
|
||||
let Some(pg) = page.filter(|pg| d.page(*pg).is_some_and(|p| new_pages.contains(&p.id))) else { break };
|
||||
let alone = d.spreads.iter().flat_map(|sp| sp.items.iter()).all(|it| it.id == f || d.page_of_item(it.id) != Some(pg));
|
||||
if !alone {
|
||||
break;
|
||||
// The first frame on its page: the estimate asked for more pages than the text needs.
|
||||
// Drop the page too when autoflow added it and the frame is all it holds; a page the
|
||||
// document had keeps whatever else is on it.
|
||||
match page.filter(|pg| d.page(*pg).is_some_and(|p| new_pages.contains(&p.id))) {
|
||||
Some(pg) => {
|
||||
let alone = d.spreads.iter().flat_map(|sp| sp.items.iter()).all(|it| it.id == f || d.page_of_item(it.id) != Some(pg));
|
||||
if !alone {
|
||||
break;
|
||||
}
|
||||
d.remove_item(f)?;
|
||||
d.delete_pages(&[pg])?;
|
||||
added = added.saturating_sub(1);
|
||||
}
|
||||
None => {
|
||||
d.remove_item(f)?;
|
||||
}
|
||||
}
|
||||
d.remove_item(f)?;
|
||||
d.delete_pages(&[pg])?;
|
||||
added = added.saturating_sub(1);
|
||||
} else {
|
||||
d.remove_item(f)?;
|
||||
}
|
||||
@@ -511,6 +538,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()));
|
||||
@@ -531,6 +595,141 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A facing-pages document with two columns, its inside margin wider than the outside one, so
|
||||
/// left and right pages have their columns in different places. A graphic frame sits on the
|
||||
/// page `marker` (0-based). Returns the session and the marker's id.
|
||||
fn facing_document(pages: usize, marker: usize) -> (Session, u64) {
|
||||
let mut s = Session::new();
|
||||
let margins = json!({"top": 54, "bottom": 54, "inside": 72, "outside": 36});
|
||||
s.execute("file.new", &json!({"preset": "Letter", "pages": pages, "facingPages": true, "columns": 2, "gutter": 18, "margins": margins}))
|
||||
.unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let (si, _) = d.page_loc(marker).unwrap();
|
||||
let m = d.page(marker).unwrap().margin_rect();
|
||||
let rect = [m.x0 + 20.0, m.y0 + 20.0, m.x0 + 120.0, m.y0 + 120.0];
|
||||
let id = s.execute("frame.create", &json!({"rect": rect, "spread": si, "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
|
||||
// Selected, an empty frame would take the placed text.
|
||||
s.execute("edit.deselectAll", &json!({})).unwrap();
|
||||
(s, id)
|
||||
}
|
||||
|
||||
/// Every frame of the story on the column of its page, the story running through the pages in
|
||||
/// order, two frames a page.
|
||||
fn assert_frames_in_columns(d: &Document, frames: &[designcraft_doc::ItemId]) {
|
||||
for (i, f) in frames.iter().enumerate() {
|
||||
let page = d.page_of_item(*f).unwrap();
|
||||
assert_eq!(page, i / 2, "frame {i}");
|
||||
assert_eq!(d.item(*f).unwrap().bounds(), d.page(page).unwrap().column_rects()[i % 2], "frame {i}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autoflow_fills_the_pages_that_follow_before_adding_any() {
|
||||
// Gap 26: autoflow inserted pages after the first frame's page, in front of the empty
|
||||
// pages the document already had. InDesign threads frames on those first.
|
||||
let dir = std::env::temp_dir().join(format!("dc-place-following-{}", 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); 24].join("\n")).unwrap();
|
||||
let (mut s, marker) = facing_document(6, 2);
|
||||
let ids: Vec<_> = (0..6).map(|i| s.doc().unwrap().doc.page(i).unwrap().id).collect();
|
||||
let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "page": 1, "autoflow": true})).unwrap();
|
||||
assert_eq!(r["overset"], false, "{r}");
|
||||
assert_eq!(r["pagesAdded"], 0, "{r}");
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!((0..d.page_count()).map(|i| d.page(i).unwrap().id).collect::<Vec<_>>(), ids);
|
||||
let sid = StoryId(r["story"].as_u64().unwrap());
|
||||
let frames = d.story(sid).unwrap().frames.clone();
|
||||
// Past page 3, which holds the marker: frames go on a page whatever else it holds.
|
||||
assert!(d.page_of_item(*frames.last().unwrap()).unwrap() > 2, "{} frames", frames.len());
|
||||
assert_frames_in_columns(d, &frames);
|
||||
// The text ends before the document does: no empty frame after it.
|
||||
let cs = designcraft_compose::compose_story(d, sid, &Default::default());
|
||||
assert!(frames.iter().all(|f| cs.frame(*f).is_some_and(|ft| !ft.range.is_empty())));
|
||||
assert!(d.page_of_item(*frames.last().unwrap()).unwrap() < 5);
|
||||
assert_eq!(d.page_of_item(designcraft_doc::ItemId(marker)), Some(2));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autoflow_adds_pages_at_the_end_of_the_document() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-place-end-{}", 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); 60].join("\n")).unwrap();
|
||||
let (mut s, marker) = facing_document(3, 2);
|
||||
let ids: Vec<_> = (0..3).map(|i| s.doc().unwrap().doc.page(i).unwrap().id).collect();
|
||||
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() > 4, "{r}");
|
||||
assert_eq!(r["pagesAdded"].as_u64(), Some(d.page_count() as u64 - 3), "{r}");
|
||||
// The document's own pages come first, the added ones after the last of them.
|
||||
assert_eq!((0..3).map(|i| d.page(i).unwrap().id).collect::<Vec<_>>(), ids);
|
||||
assert_eq!(d.page_of_item(designcraft_doc::ItemId(marker)), Some(2));
|
||||
let frames = d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames.clone();
|
||||
assert_frames_in_columns(d, &frames);
|
||||
assert_eq!(d.page_of_item(*frames.last().unwrap()), Some(d.page_count() - 1));
|
||||
// One undo step takes the frames, the pages and the story away.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!((0..d.page_count()).map(|i| d.page(i).unwrap().id).collect::<Vec<_>>(), ids);
|
||||
assert!(d.story(StoryId(r["story"].as_u64().unwrap())).is_none());
|
||||
assert_eq!(d.spreads.iter().map(|sp| sp.items.len()).sum::<usize>(), 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autoflow_fills_every_column_of_a_following_page() {
|
||||
// A first frame across the whole live area holds twice what a column does, so autoflow
|
||||
// asks for half the frames it needs. Asked page by page, it left the later columns of a
|
||||
// page out of the thread and went on to the next page.
|
||||
let dir = std::env::temp_dir().join(format!("dc-place-under-{}", 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); 30].join("\n")).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"preset": "Letter", "pages": 6, "facingPages": false, "columns": 2, "gutter": 18, "margins": 54})).unwrap();
|
||||
let m = s.doc().unwrap().doc.page(0).unwrap().margin_rect();
|
||||
let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "rect": [m.x0, m.y0, m.x1, m.y1], "autoflow": true})).unwrap();
|
||||
assert_eq!(r["overset"], false, "{r}");
|
||||
assert_eq!(r["pagesAdded"], 0, "{r}");
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let frames = &d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames;
|
||||
assert_eq!(d.item(frames[0]).unwrap().bounds(), m);
|
||||
assert!(d.page_of_item(*frames.last().unwrap()).unwrap() >= 3, "{} frames", frames.len());
|
||||
for (k, f) in frames[1..].iter().enumerate() {
|
||||
let page = d.page_of_item(*f).unwrap();
|
||||
assert_eq!(page, 1 + k / 2, "frame {}", k + 1);
|
||||
assert_eq!(d.item(*f).unwrap().bounds(), d.page(page).unwrap().column_rects()[k % 2], "frame {}", k + 1);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autoflow_leaves_no_empty_frame_on_the_pages_that_follow() {
|
||||
// A small first frame makes autoflow ask for more frames than the columns need; the ones
|
||||
// it set on the following pages past the end of the text go again, the pages stay.
|
||||
let dir = std::env::temp_dir().join(format!("dc-place-small-{}", 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); 12].join("\n")).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"preset": "Letter", "pages": 8, "facingPages": false, "margins": 54})).unwrap();
|
||||
let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "rect": [100, 100, 200, 130], "autoflow": true})).unwrap();
|
||||
assert_eq!(r["overset"], false, "{r}");
|
||||
assert_eq!(r["pagesAdded"], 0, "{r}");
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.page_count(), 8);
|
||||
let sid = StoryId(r["story"].as_u64().unwrap());
|
||||
let frames = &d.story(sid).unwrap().frames;
|
||||
assert!(frames.len() >= 2, "{r}");
|
||||
let cs = designcraft_compose::compose_story(d, sid, &Default::default());
|
||||
assert!(frames.iter().all(|f| cs.frame(*f).is_some_and(|ft| !ft.range.is_empty())), "{} frames", frames.len());
|
||||
assert_eq!(d.spreads.iter().map(|sp| sp.items.len()).sum::<usize>(), frames.len());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autoflow_adds_only_the_pages_breaks_ask_for() {
|
||||
// Regression (#262): a frame a break ends early made autoflow take it for a frame that
|
||||
|
||||
@@ -17,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Print…",
|
||||
["File"],
|
||||
Some("Cmd+P"),
|
||||
"{printer?, copies?: 1, pages?: \"1-3,5\", spreads?, marks?, bleed?, dryRun?: bool (export and preview submission without printing)} → {printer, copies, pages, command, bytes}",
|
||||
"{printer?, copies?: 1, pages?: \"1-3,5\", spreads?, marks?, bleed?, dryRun?: bool (export and preview submission without printing), created?: int (Unix seconds, as file.exportPdf)} → {printer, copies, pages, command, bytes}",
|
||||
has_doc,
|
||||
print
|
||||
),
|
||||
@@ -95,7 +95,8 @@ mod tests {
|
||||
fn print_submits_the_selected_export_and_reports_refusal() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 4})).unwrap();
|
||||
let params = json!({"printer": "Office (east)", "copies": 2, "pages": "2-3", "marks": true, "bleed": true});
|
||||
// A fixed creation date: both exports would otherwise take the clock's second.
|
||||
let params = json!({"printer": "Office (east)", "copies": 2, "pages": "2-3", "marks": true, "bleed": true, "created": 1_700_000_000});
|
||||
let expected = {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let options = super::super::export::options(¶ms, d.page_count()).unwrap();
|
||||
@@ -118,5 +119,6 @@ mod tests {
|
||||
assert!(refusal.to_string().contains("printer offline"));
|
||||
print_with(&mut s, &json!({"dryRun": true}), |_, _| panic!("dry runs must not spool")).unwrap();
|
||||
assert!(print_with(&mut s, &json!({"pages": "9"}), |_, _| panic!("invalid ranges must not spool")).is_err());
|
||||
assert!(print_with(&mut s, &json!({"created": "today"}), |_, _| panic!("an invalid date must not spool")).is_err());
|
||||
}
|
||||
}
|
||||
@@ -297,7 +297,7 @@ fn table_specs() -> Vec<CommandSpec> {
|
||||
"Cell Options",
|
||||
[],
|
||||
None,
|
||||
"{fill?: swatch, tint?: 0..1, insets?: n | [t,l,b,r], vj? | verticalJustification?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|fixed|emboxHeight, firstBaselineMin?: pt, text?, stroke?: {weight?, color?: swatch, tint?: 0..1, type?: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed | {kind:…}, gapColor?: swatch, gapTint?: 0..1, edges?: name | [names] (all|outer|inner|top|left|bottom|right|innerHorizontal|innerVertical; default all)}, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — top/left/bottom/right are the target range's outer sides, innerHorizontal/innerVertical the lines between its rows/columns",
|
||||
"{fill?: swatch, tint?: 0..1, insets?: n | [t,l,b,r], vj? | verticalJustification?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|fixed|emboxHeight, firstBaselineMin?: pt, rotation?: 0|90|180|270 (Cell Options › Text › Rotation; kept and written back to IDML, composed horizontally for now), text?, stroke?: {weight?, color?: swatch, tint?: 0..1, type?: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed | {kind:…}, gapColor?: swatch, gapTint?: 0..1, edges?: name | [names] (all|outer|inner|top|left|bottom|right|innerHorizontal|innerVertical; default all)}, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — top/left/bottom/right are the target range's outer sides, innerHorizontal/innerVertical the lines between its rows/columns",
|
||||
in_table,
|
||||
set_cell
|
||||
),
|
||||
@@ -971,6 +971,15 @@ fn set_cell(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => None,
|
||||
};
|
||||
let (first_baseline, first_baseline_min) = first_baseline_params(p, ID)?;
|
||||
// Cell Options › Text › Rotation. The value is kept and written back to IDML; only 0 is
|
||||
// composed so far, which the IDML import reports (#1468).
|
||||
let rotation = match p.get("rotation").filter(|v| !v.is_null()) {
|
||||
Some(v) => match v.as_f64().map(|d| d.rem_euclid(360.0)) {
|
||||
Some(d) if d == 0.0 || d == 90.0 || d == 180.0 || d == 270.0 => Some(d),
|
||||
_ => return Err(bad(ID, "rotation: 0, 90, 180 or 270 degrees")),
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
let text = str_param(p, "text").map(|_| super::text_param(p, "text"));
|
||||
let stroke = match p.get("stroke").filter(|v| !v.is_null()) {
|
||||
Some(sv) => Some((StrokeEdit::parse(doc, sv, ID)?, EdgeSel::parse(sv.get("edges"), ID)?)),
|
||||
@@ -1021,6 +1030,9 @@ fn set_cell(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = first_baseline_min {
|
||||
cell.first_baseline_min = v;
|
||||
}
|
||||
if let Some(v) = rotation {
|
||||
cell.rotation = v;
|
||||
}
|
||||
if let Some(tx) = &text {
|
||||
let len = cell.text.len();
|
||||
cell.text.replace(0..len, tx);
|
||||
@@ -1393,14 +1405,14 @@ fn parse_alt(v: &Value) -> Option<AltFills> {
|
||||
}
|
||||
let d = AltFills::default();
|
||||
Some(AltFills {
|
||||
first: v.get("first").and_then(Value::as_u64).map_or(d.first, |x| x as u32),
|
||||
first: v.get("first").and_then(Value::as_u64).map_or(d.first, |x| u32::try_from(x).unwrap_or(u32::MAX)),
|
||||
first_color: color_param(v, "firstColor").unwrap_or(d.first_color),
|
||||
first_tint: v.get("firstTint").and_then(Value::as_f64).map_or(d.first_tint, |x| x as f32),
|
||||
next: v.get("next").and_then(Value::as_u64).map_or(d.next, |x| x as u32),
|
||||
next: v.get("next").and_then(Value::as_u64).map_or(d.next, |x| u32::try_from(x).unwrap_or(u32::MAX)),
|
||||
next_color: color_param(v, "nextColor").unwrap_or(d.next_color),
|
||||
next_tint: v.get("nextTint").and_then(Value::as_f64).map_or(d.next_tint, |x| x as f32),
|
||||
skip_first: v.get("skipFirst").and_then(Value::as_u64).map_or(0, |x| x as u32),
|
||||
skip_last: v.get("skipLast").and_then(Value::as_u64).map_or(0, |x| x as u32),
|
||||
skip_first: v.get("skipFirst").and_then(Value::as_u64).map_or(0, |x| u32::try_from(x).unwrap_or(u32::MAX)),
|
||||
skip_last: v.get("skipLast").and_then(Value::as_u64).map_or(0, |x| u32::try_from(x).unwrap_or(u32::MAX)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1548,7 +1560,7 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.filter_map(|(r, c)| {
|
||||
let cell = t.cell(r, c)?;
|
||||
Some(json!({"row": r, "col": c, "rowSpan": cell.row_span, "colSpan": cell.col_span, "text": cell.text.text, "fill": cell.fill,
|
||||
"insets": cell.insets, "vj": cell.vj, "firstBaseline": cell.first_baseline, "firstBaselineMin": cell.first_baseline_min, "style": cell.style}))
|
||||
"insets": cell.insets, "vj": cell.vj, "firstBaseline": cell.first_baseline, "firstBaselineMin": cell.first_baseline_min, "rotation": cell.rotation, "style": cell.style}))
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({
|
||||
@@ -1832,6 +1844,16 @@ mod style_tests {
|
||||
let cell = got["cells"].as_array().unwrap().iter().find(|c| c["row"] == 0 && c["col"] == 0).unwrap().clone();
|
||||
assert_eq!((cell["firstBaseline"].as_str(), cell["firstBaselineMin"].as_f64()), (Some("capHeight"), Some(3.0)));
|
||||
assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaseline": "middle"})).is_err());
|
||||
// Cell Options › Text › Rotation: carried and written back, composed horizontally so far,
|
||||
// which the IDML import reports (#1468).
|
||||
for (given, want) in [(90, 90.0), (270, 270.0), (0, 0.0), (-90, 270.0), (450, 90.0)] {
|
||||
s.execute("table.setCell", &json!({"row": 0, "col": 0, "rotation": given})).unwrap_or_else(|e| panic!("{given}: {e}"));
|
||||
let cell = s.execute("table.get", &json!({})).unwrap()["cells"][0].clone();
|
||||
assert_eq!(cell["rotation"].as_f64(), Some(want), "{given}");
|
||||
}
|
||||
for bad in [45, 1, 100] {
|
||||
assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "rotation": bad})).is_err(), "{bad}");
|
||||
}
|
||||
assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaselineMin": -1})).is_err());
|
||||
s.execute("style.cell.create", &json!({"name": "Leading", "firstBaseline": "leading"})).unwrap();
|
||||
s.execute("style.cell.apply", &json!({"name": "Leading", "row": 1, "col": 1})).unwrap();
|
||||
|
||||
@@ -1502,12 +1502,29 @@ fn ruby_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// A character fill must name a swatch of the document.
|
||||
pub(crate) fn validate_char_fill(document: &designcraft_doc::Document, attrs: &CharAttrs, command_id: &str) -> Result<()> {
|
||||
validate_char_size(attrs, command_id)?;
|
||||
match &attrs.fill {
|
||||
Some(fill) => super::object::known_swatch(document, fill, command_id, "fill"),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Type size is 0.1 to 1296 pt, the range this engine already applies to a ruby annotation's
|
||||
/// size. Nothing checked it for ordinary text, so `{"size": 0}` set type with no size, a negative
|
||||
/// size was accepted as well, and 1e9 pt asked the composer for a line a million times the page.
|
||||
pub(crate) fn validate_char_size(attrs: &CharAttrs, command_id: &str) -> Result<()> {
|
||||
match attrs.size {
|
||||
Some(size) if !(MIN_TYPE_SIZE..=MAX_TYPE_SIZE).contains(&size) => {
|
||||
Err(bad(command_id, format!("size {size}: {MIN_TYPE_SIZE} to {MAX_TYPE_SIZE} pt")))
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Type size limits (InDesign's, and the ones `type.ruby` already uses).
|
||||
pub(crate) const MIN_TYPE_SIZE: f64 = 0.1;
|
||||
pub(crate) const MAX_TYPE_SIZE: f64 = 1296.0;
|
||||
|
||||
/// Byte `pos` is the hidden paragraph mark of an *empty* paragraph: the only character on that
|
||||
/// line, and so the one a line-affecting attribute like leading can land on right away instead of
|
||||
/// waiting for the next typed character (otherwise an empty line could never get its own leading).
|
||||
@@ -2088,7 +2105,7 @@ fn open_type_features(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
continue;
|
||||
}
|
||||
let names: std::collections::BTreeSet<&String> = fonts.values().filter_map(|f| f.stylistic_sets.get(&set)).collect();
|
||||
let name = if names.len() == 1 { names.first().map(|n| n.to_string()) } else { None };
|
||||
let name = if names.len() == 1 { names.first().map(ToString::to_string) } else { None };
|
||||
let (applied, available) = measure(&tag);
|
||||
sets.push(json!({"set": set, "tag": tag, "name": name, "applied": applied, "available": available}));
|
||||
}
|
||||
@@ -3911,3 +3928,42 @@ mod pick_up_format_tests {
|
||||
let _ = ItemId(rect);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod size_bounds_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Type size was not checked anywhere: `{"size": 0}` set type with no size at all, a negative
|
||||
/// size was accepted the same way, and 1e9 pt asked the composer for a line a million times
|
||||
/// the height of the page. The range is the one `type.ruby` already applies, 0.1 to 1296 pt.
|
||||
#[test]
|
||||
fn a_type_size_outside_the_range_is_refused() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 1})).unwrap();
|
||||
let f = s.execute("frame.create", &json!({"rect": [20, 20, 400, 300], "content": "text", "text": "Hello world"})).unwrap();
|
||||
let select = |s: &mut Session| {
|
||||
s.execute("text.select", &json!({"story": f["story"].clone(), "anchor": 0, "focus": 5})).unwrap();
|
||||
};
|
||||
for size in [0.0, -12.0, 0.05, 1297.0, 1e9] {
|
||||
select(&mut s);
|
||||
let e = s.execute("type.char", &json!({"size": size})).expect_err("refused").to_string();
|
||||
assert!(e.contains("0.1 to 1296 pt"), "{size}: {e}");
|
||||
}
|
||||
// The sizes in range still apply, including the ends.
|
||||
for size in [0.1, 12.0, 1296.0] {
|
||||
select(&mut s);
|
||||
s.execute("type.char", &json!({"size": size})).unwrap_or_else(|e| panic!("{size}: {e}"));
|
||||
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"].as_f64(), Some(size));
|
||||
}
|
||||
// And the styles that carry character attributes are checked the same way.
|
||||
for (command, params) in [
|
||||
("style.character.create", json!({"name": "Tiny", "chars": {"size": 0}})),
|
||||
("style.paragraph.create", json!({"name": "Huge", "chars": {"size": 2000}})),
|
||||
] {
|
||||
let e = s.execute(command, ¶ms).expect_err("refused").to_string();
|
||||
assert!(e.contains("0.1 to 1296 pt"), "{command}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Thread Text Frames",
|
||||
[],
|
||||
None,
|
||||
"{from?, to?, rect?: [x0,y0,x1,y1] (spread coords), spread?, autoflow?: bool} — threads frame `to` right after frame `from` (between `from` and the frame that followed it, if any). `to` is an empty frame or a text frame nothing threads into yet; its text joins the end of the story. Give `rect` in place of `from` or `to` to thread a new text frame made there on `spread`: `{from, rect}` adds it after `from`, `{rect, to}` in front of `to`. autoflow: then add pages with threaded frames until the story fits. Selects the frame that joined the thread. → {story, from, to, overset, pagesAdded}",
|
||||
"{from?, to?, rect?: [x0,y0,x1,y1] (spread coords), spread?, autoflow?: bool} — threads frame `to` right after frame `from` (between `from` and the frame that followed it, if any). `to` is an empty frame or a text frame nothing threads into yet; its text joins the end of the story. Give `rect` in place of `from` or `to` to thread a new text frame made there on `spread`: `{from, rect}` adds it after `from`, `{rect, to}` in front of `to`. autoflow: then thread a frame per column on the pages that follow, adding pages at the end of the document until the story fits. Selects the frame that joined the thread. → {story, from, to, overset, pagesAdded}",
|
||||
has_doc,
|
||||
thread
|
||||
),
|
||||
|
||||
@@ -22,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"New Hyperlink Destination…",
|
||||
["Type", "Hyperlinks & Cross-References"],
|
||||
None,
|
||||
"{name} — a text anchor at the insertion point",
|
||||
"{name} — a text anchor at the insertion point; the name is the document's, so it must not be one another anchor already has",
|
||||
in_story_text,
|
||||
create_anchor
|
||||
),
|
||||
@@ -103,6 +103,14 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn create_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("anchor.create", "missing name"))?.to_string();
|
||||
if name.trim().is_empty() {
|
||||
return Err(bad("anchor.create", "a text anchor needs a name (a cross-reference and a hyperlink find it by that name)"));
|
||||
}
|
||||
// Two anchors of the same name made every reference to it ambiguous: `xref.insert` and
|
||||
// `hyperlink.create` take the first they find, and a file writes both under one name.
|
||||
if s.doc()?.doc.stories.values().any(|st| st.anchors.iter().any(|a| a.name == name)) {
|
||||
return Err(bad("anchor.create", format!("a text anchor named `{name}` is already in this document (anchor.list names them)")));
|
||||
}
|
||||
s.edit(|d, sel| {
|
||||
let t = sel.text.ok_or_else(|| bad("anchor.create", "no insertion point"))?;
|
||||
let st = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
|
||||
@@ -215,6 +223,44 @@ mod tests {
|
||||
assert_eq!(s.execute("xref.list", &json!({})).unwrap()[0]["text"], "(p. 3)");
|
||||
}
|
||||
|
||||
/// Two text anchors of the same name made every reference to it ambiguous: `xref.insert
|
||||
/// {"anchor": "A"}` and `hyperlink.create {"anchor": "A"}` take the first they find, and an
|
||||
/// exported file writes both destinations under one name.
|
||||
#[test]
|
||||
fn a_text_anchor_name_is_the_documents_so_it_is_not_reused() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap();
|
||||
let a = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Photo one here"})).unwrap();
|
||||
let select = |s: &mut Session, story: serde_json::Value, from: usize| {
|
||||
s.execute("text.select", &json!({"story": story, "anchor": from, "focus": from})).unwrap();
|
||||
};
|
||||
select(&mut s, a["story"].clone(), 0);
|
||||
s.execute("anchor.create", &json!({"name": "Photo 1"})).unwrap();
|
||||
|
||||
// The same name again, in the same story and in another one.
|
||||
select(&mut s, a["story"].clone(), 10);
|
||||
let e = s.execute("anchor.create", &json!({"name": "Photo 1"})).expect_err("refused").to_string();
|
||||
assert!(e.contains("already in this document"), "{e}");
|
||||
let sp = s.doc().unwrap().doc.page_loc(1).unwrap().0;
|
||||
let b = s.execute("frame.create", &json!({"spread": sp, "rect": [72, 72, 400, 200], "content": "text", "text": "Photo two"})).unwrap();
|
||||
select(&mut s, b["story"].clone(), 0);
|
||||
assert!(s.execute("anchor.create", &json!({"name": "Photo 1"})).is_err(), "not in another story either");
|
||||
// A name of spaces is not a name.
|
||||
assert!(s.execute("anchor.create", &json!({"name": " "})).is_err());
|
||||
assert!(s.execute("anchor.create", &json!({"name": ""})).is_err());
|
||||
|
||||
// One anchor, so a reference by name can only mean that one.
|
||||
let anchors = s.execute("anchor.list", &json!({})).unwrap();
|
||||
assert_eq!(anchors.as_array().unwrap().len(), 1, "{anchors}");
|
||||
// Another name is fine.
|
||||
s.execute("anchor.create", &json!({"name": "Photo 2"})).unwrap();
|
||||
assert_eq!(s.execute("anchor.list", &json!({})).unwrap().as_array().unwrap().len(), 2);
|
||||
// The check is on the name, not on where it is: a name that differs only in case is a
|
||||
// different name, as it is everywhere else in the document.
|
||||
select(&mut s, b["story"].clone(), 0);
|
||||
s.execute("anchor.create", &json!({"name": "photo 1"})).expect("a different name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_reference_into_a_story_with_a_running_header_composes() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -21,8 +21,7 @@ use std::path::{Path, PathBuf};
|
||||
/// refused path is never handed to the file system; callers treat it as a missing file.
|
||||
pub fn document_path(stored: &str, document: Option<&Path>) -> Result<PathBuf, Refused> {
|
||||
let document = document.map(|d| std::path::absolute(d).unwrap_or_else(|_| d.to_path_buf()));
|
||||
let path = craft_docpath::resolve(stored, document.as_deref())?;
|
||||
if path.is_absolute() { Ok(path) } else { Err(Refused::Invalid) }
|
||||
craft_docpath::resolve_absolute(stored, document.as_deref())
|
||||
}
|
||||
|
||||
/// [`document_path`] for a path stored in a file inside `folder` (a data file's image names).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -333,7 +333,7 @@ impl Parser<'_> {
|
||||
'+' => Node::Sym("+".into(), Class::Bin),
|
||||
'-' => Node::Sym("−".into(), Class::Bin),
|
||||
'*' => Node::Sym("∗".into(), Class::Bin),
|
||||
'=' | '<' | '>' => Node::Sym(c.to_string(), Class::Rel),
|
||||
'<'..='>' => Node::Sym(c.to_string(), Class::Rel),
|
||||
',' | ';' => Node::Sym(c.to_string(), Class::Punct),
|
||||
'\'' => Node::Sym("′".into(), Class::Plain),
|
||||
c => Node::Sym(c.to_string(), Class::Plain),
|
||||
@@ -390,7 +390,7 @@ impl Parser<'_> {
|
||||
if self.peek().is_none() {
|
||||
break;
|
||||
}
|
||||
if self.s[self.i..].starts_with(&['\\', 'r', 'i', 'g', 'h', 't']) && !self.s.get(self.i + 6).is_some_and(|c| c.is_ascii_alphabetic()) {
|
||||
if self.s[self.i..].starts_with(&['\\', 'r', 'i', 'g', 'h', 't']) && !self.s.get(self.i + 6).is_some_and(char::is_ascii_alphabetic) {
|
||||
self.i += 6;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -408,7 +408,7 @@ fn remove_emptied(dir: &Path) {
|
||||
let path = e.path();
|
||||
let name = e.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
let numbered = path.file_stem().and_then(|s| s.to_str()).is_some_and(|s| s.parse::<u64>().is_ok());
|
||||
let numbered = path.file_stem().and_then(std::ffi::OsStr::to_str).is_some_and(|s| s.parse::<u64>().is_ok());
|
||||
// Metadata whose document has gone, and a file a crash left half written.
|
||||
if (numbered && name.ends_with(".json")) || (name.starts_with(".designcraft-recovery-") && name.ends_with(".tmp")) {
|
||||
left.push(path);
|
||||
@@ -726,8 +726,8 @@ fn occupied_uids(dir: &Path) -> Result<std::collections::HashSet<u64>> {
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
|
||||
let path = entry.path();
|
||||
if matches!(path.extension().and_then(|s| s.to_str()), Some("json" | "designcraft"))
|
||||
&& let Some(uid) = path.file_stem().and_then(|s| s.to_str()).and_then(|s| s.parse().ok())
|
||||
if matches!(path.extension().and_then(std::ffi::OsStr::to_str), Some("json" | "designcraft"))
|
||||
&& let Some(uid) = path.file_stem().and_then(std::ffi::OsStr::to_str).and_then(|s| s.parse().ok())
|
||||
{
|
||||
ids.insert(uid);
|
||||
}
|
||||
@@ -756,10 +756,10 @@ pub fn list(dir: &Path) -> Vec<(u64, Value)> {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return out };
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
if p.extension().and_then(|x| x.to_str()) != Some("designcraft") {
|
||||
if p.extension().and_then(std::ffi::OsStr::to_str) != Some("designcraft") {
|
||||
continue;
|
||||
}
|
||||
let Some(uid) = p.file_stem().and_then(|s| s.to_str()).and_then(|s| s.parse::<u64>().ok()) else { continue };
|
||||
let Some(uid) = p.file_stem().and_then(std::ffi::OsStr::to_str).and_then(|s| s.parse::<u64>().ok()) else { continue };
|
||||
// Metadata is a file on disk: anything but an object (corrupt, hand-edited) counts as none.
|
||||
let meta = std::fs::read_to_string(p.with_extension("json"))
|
||||
.ok()
|
||||
|
||||
+107
-1
@@ -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();
|
||||
@@ -1650,3 +1651,108 @@ fn a_line_fits_when_its_baseline_does() {
|
||||
assert!(!overset(&mut s, 78.5), "the line is set; its descenders hang below the frame");
|
||||
assert!(overset(&mut s, 50.0), "a baseline below the frame still oversets the line");
|
||||
}
|
||||
|
||||
/// Issue #1637: with several objects selected, a click on one of them (no drag) makes it the key
|
||||
/// object, and Align to Key Object leaves it in place. A second click on it drops the key.
|
||||
#[test]
|
||||
fn a_click_in_the_selection_makes_the_key_object() {
|
||||
use designcraft_tools::{PointerEvent, PointerKind};
|
||||
let mut s = session();
|
||||
let a = s.execute("frame.create", &json!({"rect": [50.0, 100.0, 100.0, 150.0], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
|
||||
let b = s.execute("frame.create", &json!({"rect": [200.0, 120.0, 300.0, 170.0], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("selection.set", &json!({"ids": [a, b]})).unwrap();
|
||||
s.set_tool("selection");
|
||||
let v = ViewInfo::at_zoom(1.0);
|
||||
// On the part of the key object that the aligned object never covers, away from the handles.
|
||||
let click = |s: &mut Session| {
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 280.0, 160.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 280.0, 160.0), v).unwrap();
|
||||
};
|
||||
click(&mut s);
|
||||
assert_eq!(s.doc().unwrap().selection.items.len(), 2, "the click keeps both selected");
|
||||
// The key is the right-hand object, so aligning left edges to the selection would move it.
|
||||
s.execute("object.align", &json!({"edge": "left", "to": "keyObject"})).unwrap();
|
||||
let x0 = |s: &Session, id: u64| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds().x0;
|
||||
assert!((x0(&s, b) - 200.0).abs() < 1e-6, "the key object moved to {}", x0(&s, b));
|
||||
assert!((x0(&s, a) - 200.0).abs() < 1e-6, "the other object is at {}", x0(&s, a));
|
||||
// The Align panel and the Control panel send no target: the key object is the default one.
|
||||
s.execute("object.align", &json!({"edge": "top"})).unwrap();
|
||||
let y0 = |s: &Session, id: u64| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds().y0;
|
||||
assert!((y0(&s, b) - 120.0).abs() < 1e-6, "the key object moved to {}", y0(&s, b));
|
||||
assert!((y0(&s, a) - 120.0).abs() < 1e-6, "the other object is at {}", y0(&s, a));
|
||||
click(&mut s);
|
||||
assert_eq!(s.doc().unwrap().selection.key, None, "a click on the key object drops the key");
|
||||
}
|
||||
|
||||
/// Dragging one of several selected objects moves them all and makes no key object.
|
||||
#[test]
|
||||
fn dragging_the_selection_makes_no_key_object() {
|
||||
use designcraft_tools::{PointerEvent, PointerKind};
|
||||
let mut s = session();
|
||||
let a = s.execute("frame.create", &json!({"rect": [50.0, 100.0, 100.0, 150.0], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
|
||||
let b = s.execute("frame.create", &json!({"rect": [200.0, 100.0, 260.0, 150.0], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("selection.set", &json!({"ids": [a, b]})).unwrap();
|
||||
s.set_tool("selection");
|
||||
let v = ViewInfo::at_zoom(1.0);
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 230.0, 125.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 250.0, 135.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 250.0, 135.0), v).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.key, None);
|
||||
}
|
||||
|
||||
/// Shift-clicking the key object out of the selection drops the key.
|
||||
#[test]
|
||||
fn deselecting_the_key_object_drops_the_key() {
|
||||
let mut s = session();
|
||||
let a = s.execute("frame.create", &json!({"rect": [50.0, 100.0, 100.0, 150.0]})).unwrap()["id"].as_u64().unwrap();
|
||||
let b = s.execute("frame.create", &json!({"rect": [200.0, 100.0, 260.0, 150.0]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("selection.set", &json!({"ids": [a, b]})).unwrap();
|
||||
s.execute("selection.key", &json!({"id": b})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.key, Some(designcraft_doc::ItemId(b)));
|
||||
s.execute("selection.toggle", &json!({"id": b})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.key, None);
|
||||
}
|
||||
|
||||
/// A key object needs another object to align to: with one object selected there is nothing to key
|
||||
/// against, and the stored key would turn that object into the target as soon as a second one joins.
|
||||
#[test]
|
||||
fn a_single_selected_object_cannot_be_the_key_object() {
|
||||
let mut s = session();
|
||||
let a = s.execute("frame.create", &json!({"rect": [50.0, 100.0, 100.0, 150.0]})).unwrap()["id"].as_u64().unwrap();
|
||||
let b = s.execute("frame.create", &json!({"rect": [200.0, 120.0, 300.0, 170.0]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("selection.set", &json!({"ids": [a]})).unwrap();
|
||||
assert!(s.execute("selection.key", &json!({"id": a})).is_err());
|
||||
assert_eq!(s.doc().unwrap().selection.key, None);
|
||||
s.execute("selection.toggle", &json!({"id": b})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.key, None, "the second object joining must not find a key already set");
|
||||
}
|
||||
|
||||
/// With the selection down to the key object alone, Align no longer has a key object: a single
|
||||
/// object aligns to the page, as before.
|
||||
#[test]
|
||||
fn a_lone_key_object_aligns_to_the_page() {
|
||||
let mut s = session();
|
||||
let a = s.execute("frame.create", &json!({"rect": [50.0, 100.0, 100.0, 150.0]})).unwrap()["id"].as_u64().unwrap();
|
||||
let b = s.execute("frame.create", &json!({"rect": [200.0, 120.0, 300.0, 170.0]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("selection.set", &json!({"ids": [a, b]})).unwrap();
|
||||
s.execute("selection.key", &json!({"id": b})).unwrap();
|
||||
s.execute("selection.toggle", &json!({"id": a})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.key, None);
|
||||
s.execute("object.align", &json!({"edge": "left"})).unwrap();
|
||||
let x0 = s.doc().unwrap().doc.item(designcraft_doc::ItemId(b)).unwrap().bounds().x0;
|
||||
assert!(x0.abs() < 1e-6, "the object is at {x0}, not at the page's left edge");
|
||||
}
|
||||
|
||||
/// Aligning one object alone (`ids`) while it is the key object of a larger selection also takes
|
||||
/// the page.
|
||||
#[test]
|
||||
fn aligning_the_key_object_alone_takes_the_page() {
|
||||
let mut s = session();
|
||||
let a = s.execute("frame.create", &json!({"rect": [50.0, 100.0, 100.0, 150.0]})).unwrap()["id"].as_u64().unwrap();
|
||||
let b = s.execute("frame.create", &json!({"rect": [200.0, 120.0, 300.0, 170.0]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("selection.set", &json!({"ids": [a, b]})).unwrap();
|
||||
s.execute("selection.key", &json!({"id": b})).unwrap();
|
||||
s.execute("object.align", &json!({"edge": "left", "ids": [b]})).unwrap();
|
||||
let x0 = s.doc().unwrap().doc.item(designcraft_doc::ItemId(b)).unwrap().bounds().x0;
|
||||
assert!(x0.abs() < 1e-6, "the object is at {x0}, not at the page's left edge");
|
||||
}
|
||||
@@ -105,3 +105,73 @@ fn a_relative_link_resolves_beside_the_document() {
|
||||
assert!(seen.iter().all(|p| p.starts_with(&dir)), "{seen:?}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_relative_link_in_an_unsaved_document_is_missing() {
|
||||
// With no document path there is nothing to resolve against; the working directory is not used.
|
||||
let mut s = document_with_links(Path::new("doc.designcraft"), &["logo.png", "img/logo.png"]);
|
||||
s.doc_mut().unwrap().path = None;
|
||||
let (rows, seen) = crate::io::touched(|| s.execute("links.list", &json!({})).unwrap());
|
||||
assert!(seen.is_empty(), "{seen:?}");
|
||||
for row in rows.as_array().unwrap() {
|
||||
assert_eq!(row["status"], "missing", "{row}");
|
||||
assert_eq!(row["refused"], "the path is not valid", "{row}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn relink_folder_never_leaves_the_chosen_folder() {
|
||||
// A stored name with no separator can still be a `file:` URL that decodes to a path elsewhere.
|
||||
let dir = folder("relink-outside");
|
||||
let chosen = dir.join("chosen");
|
||||
std::fs::create_dir_all(&chosen).unwrap();
|
||||
let outside = dir.join("outside.png");
|
||||
std::fs::write(&outside, png()).unwrap();
|
||||
let encoded = format!("file:{}", outside.to_string_lossy().replace('/', "%2F"));
|
||||
let mut s = document_with_links(&dir.join("doc.designcraft"), &[&encoded]);
|
||||
let (out, seen) =
|
||||
crate::io::touched(|| s.execute("links.relinkFolder", &json!({"dir": chosen.to_string_lossy(), "missingOnly": false})).unwrap());
|
||||
assert_eq!(out["relinked"], 0, "{out}");
|
||||
assert!(!seen.contains(&outside), "{seen:?}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn relink_folder_takes_the_chosen_folder_as_one_folder() {
|
||||
// `a\b` is one folder name on Unix; read as a stored path it would be `a/b`.
|
||||
let dir = folder("relink-backslash");
|
||||
let chosen = dir.join("a\\b");
|
||||
std::fs::create_dir_all(&chosen).unwrap();
|
||||
std::fs::create_dir_all(dir.join("a/b")).unwrap();
|
||||
let elsewhere = dir.join("a/b/x.png");
|
||||
std::fs::write(&elsewhere, png()).unwrap();
|
||||
let mut s = document_with_links(&dir.join("doc.designcraft"), &["x.png"]);
|
||||
let (out, seen) =
|
||||
crate::io::touched(|| s.execute("links.relinkFolder", &json!({"dir": chosen.to_string_lossy(), "missingOnly": false})).unwrap());
|
||||
assert_eq!(out["relinked"], 0, "{out}");
|
||||
assert!(!seen.contains(&elsewhere), "{seen:?}");
|
||||
|
||||
// A folder given with `..` is still that folder.
|
||||
let real = dir.join("real");
|
||||
std::fs::create_dir_all(&real).unwrap();
|
||||
std::fs::write(real.join("x.png"), png()).unwrap();
|
||||
let roundabout = real.join("..").join("real");
|
||||
let out = s.execute("links.relinkFolder", &json!({"dir": roundabout.to_string_lossy(), "missingOnly": false})).unwrap();
|
||||
assert_eq!(out["relinked"], 1, "{out}");
|
||||
|
||||
// Through a symlink, `alias/..` is the link target's parent, not the folder holding `alias`.
|
||||
let inner = dir.join("inner");
|
||||
std::fs::create_dir_all(inner.join("sub")).unwrap();
|
||||
std::os::unix::fs::symlink(inner.join("sub"), dir.join("alias")).unwrap();
|
||||
let beside_alias = dir.join("x.png");
|
||||
std::fs::write(&beside_alias, png()).unwrap();
|
||||
let mut s = document_with_links(&dir.join("doc.designcraft"), &["x.png"]);
|
||||
let (out, seen) = crate::io::touched(|| {
|
||||
s.execute("links.relinkFolder", &json!({"dir": dir.join("alias").join("..").to_string_lossy(), "missingOnly": false})).unwrap()
|
||||
});
|
||||
assert_eq!(out["relinked"], 0, "{out}");
|
||||
assert!(!seen.contains(&beside_alias), "{seen:?}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -1097,3 +1097,26 @@ fn convert_rows_keeps_merged_cells_on_one_side() {
|
||||
assert!(!convert_enabled(&s, "Body"));
|
||||
check(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternating_fill_counts_saturate_instead_of_wrapping() {
|
||||
let (mut s, _, _) = session_with_frame();
|
||||
s.execute("table.insert", &json!({"rows": 3, "cols": 2})).unwrap();
|
||||
s.execute("table.options", &json!({"altRows": {"first": 4294967297u64, "next": 1, "skipFirst": 4294967296u64, "skipLast": 9999999999u64}}))
|
||||
.unwrap();
|
||||
let t = s.execute("table.get", &json!({})).unwrap();
|
||||
let a = &t["options"]["altRows"];
|
||||
assert_eq!(a["first"], u32::MAX);
|
||||
assert_eq!(a["skipFirst"], u32::MAX);
|
||||
assert_eq!(a["skipLast"], u32::MAX);
|
||||
assert_eq!(a["next"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_next_paragraphs_saturates_instead_of_wrapping() {
|
||||
let (mut s, _, _) = session_with_frame();
|
||||
s.execute("index.addReference", &json!({"topic": "Intro", "range": "nextParagraphs", "paragraphs": 4294967296u64})).unwrap();
|
||||
let l = s.execute("index.list", &json!({})).unwrap();
|
||||
assert_eq!(l[0]["range"]["type"], "nextParagraphs");
|
||||
assert_eq!(l[0]["range"]["value"], u32::MAX);
|
||||
}
|
||||
@@ -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;
|
||||
File renamed without changes.
File renamed without changes.
-2
@@ -1,5 +1,3 @@
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use designcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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;
|
||||
File renamed without changes.
@@ -0,0 +1,351 @@
|
||||
//! 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();
|
||||
// Commands now reject unrewritable choices. Simulate stale persisted state after relinking.
|
||||
let id: designcraft_doc::ItemId = serde_json::from_value(placed["id"].clone()).unwrap();
|
||||
std::sync::Arc::make_mut(&mut session.doc_mut().unwrap().doc).item_mut(id).unwrap().pdf_hidden_layers = vec!["Red".into()];
|
||||
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);
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
-2
@@ -1,5 +1,3 @@
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -12,3 +12,4 @@ workspace = true
|
||||
designcraft-doc = { workspace = true }
|
||||
designcraft-color = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
+69
-73
@@ -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);
|
||||
}
|
||||
|
||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,2 @@
|
||||
mod export_tag_styles;
|
||||
mod grouped_reading_order;
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Optional craft-fonts build input (https://github.com/storytold/craft-fonts, recipe from its
|
||||
//! `docs/integration.md`). With `CRAFT_FONTS_DIR=<checkout>` the fonts in its
|
||||
//! `fonts/manifest.txt` are embedded as `CRAFT_FONTS`; unset, `CRAFT_FONTS` is empty. Web
|
||||
//! (wasm32) builds embed only the UI face, BIZ UDPGothic Regular, to stay within the web size
|
||||
//! (wasm32) builds embed only the UI faces, BIZ UDPGothic Regular and Noto Sans Georgian Regular, to stay within the web size
|
||||
//! budget. It only reads the local checkout: no network.
|
||||
use std::fmt::Write as _;
|
||||
use std::path::PathBuf;
|
||||
@@ -38,7 +38,7 @@ fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
|
||||
let [family, style, file, scripts, ..] = f.as_slice() else {
|
||||
return Err(format!("malformed manifest line: {line}"));
|
||||
};
|
||||
if wasm && !(*family == "BIZ UDPGothic" && *style == "Regular") {
|
||||
if wasm && !matches!((*family, *style), ("BIZ UDPGothic" | "Noto Sans Georgian", "Regular")) {
|
||||
continue;
|
||||
}
|
||||
let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?;
|
||||
|
||||
+30
-30
@@ -3,7 +3,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
use std::sync::{Arc, Mutex, PoisonError, RwLock};
|
||||
|
||||
use kurbo::BezPath;
|
||||
use skrifa::instance::{Location, LocationRef, Size};
|
||||
@@ -150,10 +150,10 @@ impl FaceRef {
|
||||
pub fn of(face: &Arc<FontFace>) -> FaceRef {
|
||||
static LEAKED: RwLock<Vec<Option<&'static FontFace>>> = RwLock::new(Vec::new());
|
||||
let id = face.id as usize;
|
||||
if let Some(Some(f)) = LEAKED.read().unwrap_or_else(|e| e.into_inner()).get(id) {
|
||||
if let Some(Some(f)) = LEAKED.read().unwrap_or_else(PoisonError::into_inner).get(id) {
|
||||
return FaceRef(f);
|
||||
}
|
||||
let mut w = LEAKED.write().unwrap_or_else(|e| e.into_inner());
|
||||
let mut w = LEAKED.write().unwrap_or_else(PoisonError::into_inner);
|
||||
if w.len() <= id {
|
||||
w.resize(id + 1, None);
|
||||
}
|
||||
@@ -1037,7 +1037,7 @@ pub fn same_style(a: &str, b: &str) -> bool {
|
||||
}
|
||||
|
||||
fn norm(s: &str) -> String {
|
||||
s.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect()
|
||||
s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect()
|
||||
}
|
||||
|
||||
/// Weight implied by a style name.
|
||||
@@ -1107,7 +1107,7 @@ impl FontDb {
|
||||
pub fn set_system_fallback(&self, on: bool) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
self.sys.lock().unwrap_or_else(|e| e.into_inner()).enabled = on;
|
||||
self.sys.lock().unwrap_or_else(PoisonError::into_inner).enabled = on;
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = on;
|
||||
@@ -1123,20 +1123,20 @@ impl FontDb {
|
||||
}
|
||||
|
||||
fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec<Arc<FontFace>>> {
|
||||
self.faces.read().unwrap_or_else(|e| e.into_inner())
|
||||
self.faces.read().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// The system font catalog, scanned first if it hasn't been yet.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_catalog(&self) -> std::sync::RwLockReadGuard<'_, Vec<CatalogEntry>> {
|
||||
self.ensure_catalog();
|
||||
self.catalog.read().unwrap_or_else(|e| e.into_inner())
|
||||
self.catalog.read().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// What the document with font scope `scope` sees: its own fonts ahead of the shared ones.
|
||||
/// Scope 0 (a document without fonts of its own) or a closed scope sees the shared fonts.
|
||||
pub fn scoped(&self, scope: u32) -> ScopedFonts<'_> {
|
||||
let own = if scope == 0 { None } else { self.scopes.read().unwrap_or_else(|e| e.into_inner()).get(&scope).cloned() };
|
||||
let own = if scope == 0 { None } else { self.scopes.read().unwrap_or_else(PoisonError::into_inner).get(&scope).cloned() };
|
||||
ScopedFonts { db: self, own }
|
||||
}
|
||||
|
||||
@@ -1187,7 +1187,7 @@ impl FontDb {
|
||||
/// family's bold without its regular), skipping a family and style another thread has added
|
||||
/// meanwhile. Returns the number added.
|
||||
fn insert_faces(&self, new: Vec<FontFace>) -> usize {
|
||||
let mut faces = self.faces.write().unwrap_or_else(|e| e.into_inner());
|
||||
let mut faces = self.faces.write().unwrap_or_else(PoisonError::into_inner);
|
||||
let mut added = 0;
|
||||
for f in new {
|
||||
if !has_face(&faces, &f.family, &f.style) {
|
||||
@@ -1238,7 +1238,7 @@ impl FontDb {
|
||||
let count = first.unwrap_or_else(|| self.scan_font_dirs());
|
||||
// Retry characters and CJK chain families that newly installed fonts can now cover.
|
||||
// Reset outside scan_font_dirs: a chain lookup can hold sys during the initial scan.
|
||||
let mut sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut sys = self.sys.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
sys.generation = sys.generation.wrapping_add(1);
|
||||
sys.misses.clear();
|
||||
sys.chain_tried.clear();
|
||||
@@ -1257,7 +1257,7 @@ impl FontDb {
|
||||
// Rescan first: a lookup after the faces are dropped finds the new files.
|
||||
let cataloged = self.load_system_fonts();
|
||||
let changed: Vec<std::path::PathBuf> = {
|
||||
let mut files = self.installed_files.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut files = self.installed_files.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let mut changed: Vec<_> = files.iter().filter(|(p, stamp)| file_stamp(p).as_ref() != Some(*stamp)).map(|(p, _)| p.clone()).collect();
|
||||
changed.sort();
|
||||
for p in &changed {
|
||||
@@ -1268,15 +1268,15 @@ impl FontDb {
|
||||
if !changed.is_empty() {
|
||||
let stale = |f: &FontFace| matches!(&f.source, FontSource::Installed(p) if changed.contains(p));
|
||||
let mut dropped = std::collections::HashSet::new();
|
||||
self.faces.write().unwrap_or_else(|e| e.into_inner()).retain(|f| {
|
||||
self.faces.write().unwrap_or_else(PoisonError::into_inner).retain(|f| {
|
||||
let keep = !stale(f);
|
||||
if !keep {
|
||||
dropped.insert(f.id);
|
||||
}
|
||||
keep
|
||||
});
|
||||
self.instances.write().unwrap_or_else(|e| e.into_inner()).retain(|(base, _), f| !dropped.contains(base) && !stale(f));
|
||||
self.outlines.lock().unwrap_or_else(|e| e.into_inner()).retain(|(face, _), _| !dropped.contains(face));
|
||||
self.instances.write().unwrap_or_else(PoisonError::into_inner).retain(|(base, _), f| !dropped.contains(base) && !stale(f));
|
||||
self.outlines.lock().unwrap_or_else(PoisonError::into_inner).retain(|(face, _), _| !dropped.contains(face));
|
||||
log::debug!("reloading {} changed font files ({} faces)", changed.len(), dropped.len());
|
||||
}
|
||||
ReloadedFonts { cataloged, changed }
|
||||
@@ -1291,7 +1291,7 @@ impl FontDb {
|
||||
fn read_installed(&self, path: &std::path::Path) -> Option<Vec<u8>> {
|
||||
let stamp = file_stamp(path)?;
|
||||
let data = std::fs::read(path).ok()?;
|
||||
self.installed_files.lock().unwrap_or_else(|e| e.into_inner()).insert(path.to_path_buf(), stamp);
|
||||
self.installed_files.lock().unwrap_or_else(PoisonError::into_inner).insert(path.to_path_buf(), stamp);
|
||||
Some(data)
|
||||
}
|
||||
|
||||
@@ -1351,7 +1351,7 @@ impl FontDb {
|
||||
}
|
||||
let n = found.len();
|
||||
log::debug!("cataloged {n} system font faces");
|
||||
*self.catalog.write().unwrap_or_else(|e| e.into_inner()) = found;
|
||||
*self.catalog.write().unwrap_or_else(PoisonError::into_inner) = found;
|
||||
n
|
||||
}
|
||||
|
||||
@@ -1421,7 +1421,7 @@ impl FontDb {
|
||||
let name = path.file_name().map_or_else(|| path.display().to_string(), |n| n.to_string_lossy().into_owned());
|
||||
// The listed length skips oversized files unread.
|
||||
let meta = std::fs::metadata(path).ok();
|
||||
let listed = meta.as_ref().map_or(0, |m| m.len());
|
||||
let listed = meta.as_ref().map_or(0, std::fs::Metadata::len);
|
||||
let faces = if listed > MAX_DOCUMENT_FONT_BYTES {
|
||||
Err(too_large())
|
||||
} else if total.saturating_add(listed) > MAX_DOCUMENT_FONTS_TOTAL {
|
||||
@@ -1449,7 +1449,7 @@ impl FontDb {
|
||||
if !own.is_empty() {
|
||||
out.scope = NEXT_SCOPE.fetch_add(1, Ordering::Relaxed);
|
||||
out.faces = own.len();
|
||||
self.scopes.write().unwrap_or_else(|e| e.into_inner()).insert(out.scope, own.into());
|
||||
self.scopes.write().unwrap_or_else(PoisonError::into_inner).insert(out.scope, own.into());
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -1466,7 +1466,7 @@ impl FontDb {
|
||||
room: u64,
|
||||
) -> Result<(Vec<Arc<FontFace>>, u64), String> {
|
||||
use std::io::Read;
|
||||
if let Some((len, m, faces)) = self.document_files.lock().unwrap_or_else(|e| e.into_inner()).get(path)
|
||||
if let Some((len, m, faces)) = self.document_files.lock().unwrap_or_else(PoisonError::into_inner).get(path)
|
||||
&& (*len, *m) == (listed, modified)
|
||||
{
|
||||
return Ok((faces.clone(), listed));
|
||||
@@ -1494,14 +1494,14 @@ impl FontDb {
|
||||
if faces.is_empty() {
|
||||
return Err("not a font DesignCraft can read".into());
|
||||
}
|
||||
self.document_files.lock().unwrap_or_else(|e| e.into_inner()).insert(path.to_path_buf(), (listed, modified, faces.clone()));
|
||||
self.document_files.lock().unwrap_or_else(PoisonError::into_inner).insert(path.to_path_buf(), (listed, modified, faces.clone()));
|
||||
Ok((faces, len))
|
||||
}
|
||||
|
||||
/// Close a document's font scope: its fonts are no longer found (in it or anywhere). Their
|
||||
/// files stay read, for the document opening again.
|
||||
pub fn close_scope(&self, scope: u32) {
|
||||
self.scopes.write().unwrap_or_else(|e| e.into_inner()).remove(&scope);
|
||||
self.scopes.write().unwrap_or_else(PoisonError::into_inner).remove(&scope);
|
||||
}
|
||||
|
||||
/// The shared fonts' face for `family` + `style` (see [`ScopedFonts::face`]).
|
||||
@@ -1542,7 +1542,7 @@ impl FontDb {
|
||||
return false;
|
||||
}
|
||||
let generation = {
|
||||
let sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let sys = self.sys.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if !sys.enabled || sys.misses.contains(&c) {
|
||||
return false;
|
||||
}
|
||||
@@ -1558,12 +1558,12 @@ impl FontDb {
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut paths: Vec<std::path::PathBuf> = self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter().map(|e| e.path.clone()).collect();
|
||||
let mut paths: Vec<std::path::PathBuf> = self.catalog.read().unwrap_or_else(PoisonError::into_inner).iter().map(|e| e.path.clone()).collect();
|
||||
paths.sort();
|
||||
paths.dedup();
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
{
|
||||
let hook = self.fallback_snapshot_hook.lock().unwrap_or_else(|e| e.into_inner()).take();
|
||||
let hook = self.fallback_snapshot_hook.lock().unwrap_or_else(PoisonError::into_inner).take();
|
||||
if let Some(hook) = hook {
|
||||
let _ = hook.reached.send(());
|
||||
hook.resume.wait();
|
||||
@@ -1580,7 +1580,7 @@ impl FontDb {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
let mut sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut sys = self.sys.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if sys.generation == generation {
|
||||
sys.misses.insert(c);
|
||||
}
|
||||
@@ -1619,12 +1619,12 @@ impl FontDb {
|
||||
/// disappears.
|
||||
pub fn outline(&self, face: &FontFace, gid: u32) -> Arc<BezPath> {
|
||||
let key = (face.id, gid);
|
||||
if let Some(p) = self.outlines.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
|
||||
if let Some(p) = self.outlines.lock().unwrap_or_else(PoisonError::into_inner).get(&key) {
|
||||
return p.clone();
|
||||
}
|
||||
let drawn = font_outline(face, gid);
|
||||
let p = Arc::new(if gid == 0 && drawn.elements().is_empty() { self.missing_box(face).unwrap_or(drawn) } else { drawn });
|
||||
let mut cache = self.outlines.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut cache = self.outlines.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if cache.len() >= OUTLINE_CACHE_MAX {
|
||||
cache.clear();
|
||||
}
|
||||
@@ -1986,7 +1986,7 @@ impl ScopedFonts<'_> {
|
||||
return Some(base);
|
||||
}
|
||||
let key = (base.id, style.to_string());
|
||||
if let Some(f) = self.db.instances.read().unwrap_or_else(|e| e.into_inner()).get(&key) {
|
||||
if let Some(f) = self.db.instances.read().unwrap_or_else(PoisonError::into_inner).get(&key) {
|
||||
return Some(f.clone());
|
||||
}
|
||||
let mut coords = base.coords.clone();
|
||||
@@ -2004,7 +2004,7 @@ impl ScopedFonts<'_> {
|
||||
}
|
||||
}
|
||||
let f = make_face(base.bytes.clone(), base.source.clone(), base.index, base.family.clone(), style.to_string(), coords)?;
|
||||
Some(self.db.instances.write().unwrap_or_else(|e| e.into_inner()).entry(key).or_insert_with(|| Arc::new(f)).clone())
|
||||
Some(self.db.instances.write().unwrap_or_else(PoisonError::into_inner).entry(key).or_insert_with(|| Arc::new(f)).clone())
|
||||
}
|
||||
|
||||
fn find(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
|
||||
@@ -2077,7 +2077,7 @@ impl ScopedFonts<'_> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if !self.db.is_loaded(family) {
|
||||
// Held while loading, so a parallel lookup waits for the font instead of passing it by.
|
||||
let mut sys = self.db.sys.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut sys = self.db.sys.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if sys.enabled && sys.chain_tried.insert(family) {
|
||||
self.db.load_cataloged(family);
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -286,6 +286,8 @@ pub fn format_measure_prec(pt: f64, unit: Unit, decimals: usize) -> String {
|
||||
whole += 1.0;
|
||||
rest = 0.0;
|
||||
}
|
||||
// Decide the sign from the rounded value so float noise doesn't print "-0p0".
|
||||
let neg = neg && (whole > 0.0 || rest > 0.0);
|
||||
let sep = if unit == Unit::Picas { 'p' } else { 'c' };
|
||||
format!("{}{}{}{}", if neg { "-" } else { "" }, whole as i64, sep, trim(rest))
|
||||
}
|
||||
@@ -304,6 +306,14 @@ mod tests {
|
||||
(a - b).abs() < 1e-6
|
||||
}
|
||||
|
||||
/// Tiny negatives that round to zero must not print a sign.
|
||||
#[test]
|
||||
fn tiny_negative_picas_ciceros_have_no_sign() {
|
||||
assert_eq!(format_measure(-0.0004, Unit::Picas), "0p0");
|
||||
assert_eq!(format_measure(-0.0004, Unit::Ciceros), "0c0");
|
||||
assert_eq!(format_measure(-12.0, Unit::Picas), "-1p0");
|
||||
}
|
||||
|
||||
/// Inches show four decimals, as InDesign's fields do; other units three.
|
||||
#[test]
|
||||
fn inches_show_four_decimals() {
|
||||
|
||||
Loaded 100 of 217 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user