mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-31 07:39:17 +00:00
Merge remote-tracking branch 'origin/main' into find-change-find-format
# Conflicts: # crates/engine/src/cmd/find.rs
This commit is contained in:
commit
c5b3c59cd1
194 files changed
+6411
-2263
No files matched your search
+13
-51
@@ -1,10 +1,7 @@
|
||||
# The CLAUDE.md quality gate (`cargo xtask ci`: fmt, clippy -D warnings, tests, assets, layers,
|
||||
# wasm) on every pull request and every push to main. Today only path-filtered workflows run on
|
||||
# pull requests, so most PRs land with no test run at all.
|
||||
#
|
||||
# Ported from wordcraft#800. Linux only; standard GitHub-hosted runner, no secrets.
|
||||
name: CI
|
||||
|
||||
# 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]
|
||||
@@ -14,54 +11,19 @@ on:
|
||||
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.ref }}
|
||||
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
# Same craft-fonts commit as release.yml, so the font tests see what a release build embeds.
|
||||
CRAFT_FONTS_REF: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
name: cargo xtask ci
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUST_BACKTRACE: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Linux build dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev \
|
||||
libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
|
||||
|
||||
- name: Install Rust (stable + clippy, rustfmt, wasm32)
|
||||
run: |
|
||||
rustup toolchain install stable --profile minimal --component clippy,rustfmt \
|
||||
--target wasm32-unknown-unknown
|
||||
rustup default stable
|
||||
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Quality gate
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
run: cargo xtask ci
|
||||
# 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
|
||||
@@ -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 }}
|
||||
|
||||
@@ -11,7 +11,8 @@ permissions:
|
||||
|
||||
concurrency:
|
||||
group: sonarcloud-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
# 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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
Generated
+28
-29
@@ -841,7 +841,7 @@ 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=89dcbb3060377a26c99a6d80caa16084366704c4#89dcbb3060377a26c99a6d80caa16084366704c4"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"hayro",
|
||||
@@ -942,7 +942,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"designcraft-engine",
|
||||
@@ -964,7 +964,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-cli"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"designcraft-color",
|
||||
"designcraft-compose",
|
||||
@@ -980,7 +980,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-color"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"craft-cms",
|
||||
"kurbo",
|
||||
@@ -992,7 +992,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-compose"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"designcraft-color",
|
||||
"designcraft-doc",
|
||||
@@ -1012,7 +1012,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-doc"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"designcraft-color",
|
||||
@@ -1025,7 +1025,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-engine"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"caseless",
|
||||
"craft-atomic",
|
||||
@@ -1064,7 +1064,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-epub"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"designcraft-color",
|
||||
@@ -1074,7 +1074,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-fonts"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"core-text",
|
||||
"harfrust 0.13.3",
|
||||
@@ -1087,7 +1087,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-format"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"designcraft-doc",
|
||||
"designcraft-images",
|
||||
@@ -1099,7 +1099,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-geom"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"flo_curves",
|
||||
"kurbo",
|
||||
@@ -1112,7 +1112,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-idml"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"designcraft-color",
|
||||
"designcraft-compose",
|
||||
@@ -1127,7 +1127,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-images"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"craft-cms",
|
||||
"designcraft-color",
|
||||
@@ -1142,7 +1142,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-mcp"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"designcraft-engine",
|
||||
"designcraft-geom",
|
||||
@@ -1155,7 +1155,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-pdf"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-pdf",
|
||||
@@ -1178,7 +1178,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-pptx"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"designcraft-color",
|
||||
@@ -1196,7 +1196,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-render"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"craft-pdf",
|
||||
"designcraft-color",
|
||||
@@ -1215,7 +1215,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-textimport"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"designcraft-doc",
|
||||
"quick-xml",
|
||||
@@ -1224,7 +1224,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-tools"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"designcraft-color",
|
||||
"designcraft-compose",
|
||||
@@ -1236,7 +1236,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-ui-egui"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"designcraft-color",
|
||||
@@ -1263,7 +1263,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-web"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"designcraft-engine",
|
||||
"designcraft-format",
|
||||
@@ -2123,7 +2123,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "hayro"
|
||||
version = "0.7.1"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=89dcbb3060377a26c99a6d80caa16084366704c4#89dcbb3060377a26c99a6d80caa16084366704c4"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"hayro-interpret",
|
||||
@@ -2143,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",
|
||||
@@ -2153,7 +2152,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "hayro-interpret"
|
||||
version = "0.7.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=89dcbb3060377a26c99a6d80caa16084366704c4#89dcbb3060377a26c99a6d80caa16084366704c4"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"hayro-cmap",
|
||||
@@ -2171,7 +2170,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "hayro-jbig2"
|
||||
version = "0.3.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=89dcbb3060377a26c99a6d80caa16084366704c4#89dcbb3060377a26c99a6d80caa16084366704c4"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
|
||||
dependencies = [
|
||||
"fearless_simd",
|
||||
"hayro-ccitt",
|
||||
@@ -2195,7 +2194,7 @@ checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa"
|
||||
[[package]]
|
||||
name = "hayro-syntax"
|
||||
version = "0.7.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=89dcbb3060377a26c99a6d80caa16084366704c4#89dcbb3060377a26c99a6d80caa16084366704c4"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hayro-ccitt",
|
||||
@@ -2559,7 +2558,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=89dcbb3060377a26c99a6d80caa16084366704c4#89dcbb3060377a26c99a6d80caa16084366704c4"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bumpalo",
|
||||
@@ -5814,7 +5813,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+13
-7
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -12,7 +12,7 @@ repository = "https://github.com/storytold/designcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
# Shared reader and writer; publish the immutable revision before this consumer.
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.1.0", default-features = false }
|
||||
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" }
|
||||
@@ -103,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
|
||||
@@ -117,9 +122,10 @@ debug = 0
|
||||
|
||||
# Opt-in cancelable macOS termination on the pinned winit 0.30 line.
|
||||
[patch.crates-io]
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.8.2" }
|
||||
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 = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.7.1" }
|
||||
hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.7.0" }
|
||||
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.7.2" }
|
||||
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.3.0" }
|
||||
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" }
|
||||
@@ -92,14 +92,15 @@ No Adobe icons, images or artwork are included.
|
||||
|
||||
PDF backend source
|
||||
|
||||
The patched hayro 0.7.1, hayro-interpret 0.7.0, hayro-syntax 0.7.2 and hayro-jbig2 0.3.0
|
||||
are fetched from https://github.com/storytold/craft-libs at revision
|
||||
89dcbb3060377a26c99a6d80caa16084366704c4. The four source snapshots were transferred
|
||||
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, then patched in craft-libs. Their upstream project is
|
||||
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/89dcbb3060377a26c99a6d80caa16084366704c4/vendor/README.craft-reader.md.
|
||||
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.
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
+22
-1
@@ -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**
|
||||
@@ -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,11 +358,22 @@ 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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -263,7 +263,7 @@ impl Export {
|
||||
/// A name with another extension is an error: it used to get a PNG of the page. One with no
|
||||
/// extension still does (`--export /dev/stdout`).
|
||||
fn of(out: &str) -> Result<Self, String> {
|
||||
let ext = std::path::Path::new(out).extension().and_then(|e| e.to_str()).map(str::to_lowercase);
|
||||
let ext = std::path::Path::new(out).extension().and_then(std::ffi::OsStr::to_str).map(str::to_lowercase);
|
||||
Ok(match ext.as_deref() {
|
||||
None | Some("png" | "jpg" | "jpeg") => Export::Image,
|
||||
Some("pdf") => Export::Pdf,
|
||||
@@ -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())?;
|
||||
|
||||
@@ -335,7 +335,7 @@ fn row(rows: &mut Vec<Row>, name: impl Into<String>, ms: f64, budget: Option<f64
|
||||
/// Measure `doc`. `runs` = repetitions per row (median).
|
||||
fn measure(doc: &Document, runs: usize) -> Result<Vec<Row>, String> {
|
||||
let mut rows = vec![];
|
||||
let cores = std::thread::available_parallelism().map_or(1, |c| c.get());
|
||||
let cores = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
|
||||
let chars: usize = doc.stories.values().map(|s| s.text.len()).sum();
|
||||
// Budget: 250 ms per 300k characters (MT), scaled to this document.
|
||||
let compose_budget = 250.0 * (chars as f64 / 300_000.0).max(1.0);
|
||||
@@ -532,7 +532,7 @@ fn print(title: &str, rows: &[Row], noisy: bool) -> Result<(), String> {
|
||||
}
|
||||
|
||||
fn header() -> (usize, bool) {
|
||||
let cores = std::thread::available_parallelism().map_or(1, |c| c.get());
|
||||
let cores = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
|
||||
let load = load_average();
|
||||
let noisy = load.is_some_and(|l| l > cores as f64 * 0.75);
|
||||
outln!(
|
||||
|
||||
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 })
|
||||
|
||||
@@ -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"] }],
|
||||
@@ -255,7 +256,7 @@ const ALL_FILES: &str = if cfg!(target_os = "macos") { "public.data" } else { "*
|
||||
/// The extension of the file name a save dialog suggests (`Brochure.designcraft`), if it has one.
|
||||
/// The dialog filters on it, so the macOS and Windows dialogs add it to a name typed without it.
|
||||
fn suggested_extension(name: &str) -> Option<&str> {
|
||||
std::path::Path::new(name).extension().and_then(|e| e.to_str()).filter(|e| !e.is_empty() && !e.contains(char::is_whitespace))
|
||||
std::path::Path::new(name).extension().and_then(std::ffi::OsStr::to_str).filter(|e| !e.is_empty() && !e.contains(char::is_whitespace))
|
||||
}
|
||||
|
||||
/// The file a save dialog's answer writes: `picked` with the suggested extension added when it has
|
||||
@@ -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))]
|
||||
|
||||
+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
|
||||
}
|
||||
}
|
||||
|
||||
+53
-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;
|
||||
@@ -1982,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);
|
||||
@@ -1993,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)
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -4004,7 +4040,7 @@ fn hyphenation_points(
|
||||
// Words repeat a lot: remember their points per set of limits.
|
||||
static CACHE: Mutex<Vec<(LimitsKey, HashMap<(hyphen::Lang, Box<str>), Box<[usize]>>)>> = Mutex::new(Vec::new());
|
||||
let key = (lim.min_word, lim.after_first, lim.before_last, lim.capitalized);
|
||||
let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut guard = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let idx = match guard.iter().position(|e| e.0 == key) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
@@ -4505,7 +4541,7 @@ pub fn adjacent_row(l: &Line, x: f64, caret_y: f64, up: bool) -> Option<f64> {
|
||||
// count as still inside the note.
|
||||
let over = |r: &(f64, f64, f64)| x >= r.1 - 0.01 && x < r.2 - 0.01;
|
||||
let candidates = rows.iter().filter(|r| over(r)).filter(|r| if up { r.0 < caret_y - ROW_Y } else { r.0 > caret_y + ROW_Y });
|
||||
if up { candidates.map(|r| r.0).max_by(|a, b| a.total_cmp(b)) } else { candidates.map(|r| r.0).min_by(|a, b| a.total_cmp(b)) }
|
||||
if up { candidates.map(|r| r.0).max_by(f64::total_cmp) } else { candidates.map(|r| r.0).min_by(f64::total_cmp) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -4627,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 {
|
||||
|
||||
@@ -122,6 +122,45 @@ pub(crate) fn draw(out: &mut ComposedStory, boxes: &[Option<ParaBox>], frames: &
|
||||
}
|
||||
}
|
||||
|
||||
/// The shading box for one part of a paragraph run, if it has any area.
|
||||
fn shading_deco(s: &Shading, part: &Part<'_>, frames: &[FrameSpec], first_part: bool, last_part: bool) -> Option<Deco> {
|
||||
let r = inner_rect(&part.lines, s.width, s.top, s.bottom)?;
|
||||
let [t, l, bo, rr] = s.offsets;
|
||||
let mut rect = Rect::new(r.x0 - l, r.y0 - t, r.x1 + rr, r.y1 + bo);
|
||||
if s.clip
|
||||
&& let Some(f) = frames.get(part.fi)
|
||||
{
|
||||
rect = rect.intersect(frame_rect(f));
|
||||
}
|
||||
if rect.width() > 0.0 && rect.height() > 0.0 {
|
||||
// Corners stay square where the box is cut by a split.
|
||||
let corners = cut_corners(s.corners, !first_part, !last_part);
|
||||
let path = (!corners.iter().all(Corner::is_none)).then(|| box_outline(rect, &corners, [true; 4]));
|
||||
return Some(Deco { rect, color: s.color.clone(), tint: s.tint, path, nonprinting: s.nonprinting });
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The border strokes (and plain edges) for one part of a paragraph run, pushed onto `decos`.
|
||||
fn border_decos(bd: &Border, part: &Part<'_>, first_part: bool, last_part: bool, decos: &mut Vec<(usize, bool, Deco)>) {
|
||||
let Some(r) = inner_rect(&part.lines, bd.width, bd.top, bd.bottom) else { return };
|
||||
let [t, l, bo, rr] = bd.offsets;
|
||||
let o = Rect::new(r.x0 - l, r.y0 - t, r.x1 + rr, r.y1 + bo);
|
||||
let open_top = !first_part && !bd.display_if_splits;
|
||||
let open_bottom = !last_part && !bd.display_if_splits;
|
||||
for (path, gap) in border_paths(bd, o, open_top, open_bottom) {
|
||||
let (color, tint) = if gap { (bd.gap_color.clone(), bd.gap_tint) } else { (bd.color.clone(), bd.tint) };
|
||||
decos.push((part.fi, false, Deco { rect: path.bounding_box(), color, tint, path: Some(path), nonprinting: false }));
|
||||
}
|
||||
if is_plain(bd, open_top, open_bottom) {
|
||||
decos.extend(
|
||||
plain_edges(bd, o, open_top, open_bottom)
|
||||
.into_iter()
|
||||
.map(|rect| (part.fi, false, Deco { rect, color: bd.color.clone(), tint: bd.tint, path: None, nonprinting: false })),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The decorations of paragraphs `paras` sharing box `b`: (frame, drawn under the rest, deco).
|
||||
fn group_decos(out: &ComposedStory, paras: std::ops::RangeInclusive<usize>, b: &ParaBox, frames: &[FrameSpec]) -> Vec<(usize, bool, Deco)> {
|
||||
let mut parts: Vec<Part<'_>> = Vec::new();
|
||||
@@ -136,40 +175,12 @@ fn group_decos(out: &ComposedStory, paras: std::ops::RangeInclusive<usize>, b: &
|
||||
for (k, part) in parts.iter().enumerate() {
|
||||
let (first_part, last_part) = (k == 0, k + 1 == n);
|
||||
if let Some(s) = &b.shading
|
||||
&& let Some(r) = inner_rect(&part.lines, s.width, s.top, s.bottom)
|
||||
&& let Some(deco) = shading_deco(s, part, frames, first_part, last_part)
|
||||
{
|
||||
let [t, l, bo, rr] = s.offsets;
|
||||
let mut rect = Rect::new(r.x0 - l, r.y0 - t, r.x1 + rr, r.y1 + bo);
|
||||
if s.clip
|
||||
&& let Some(f) = frames.get(part.fi)
|
||||
{
|
||||
rect = rect.intersect(frame_rect(f));
|
||||
}
|
||||
if rect.width() > 0.0 && rect.height() > 0.0 {
|
||||
// Corners stay square where the box is cut by a split.
|
||||
let corners = cut_corners(s.corners, !first_part, !last_part);
|
||||
let path = (!corners.iter().all(Corner::is_none)).then(|| box_outline(rect, &corners, [true; 4]));
|
||||
decos.push((part.fi, true, Deco { rect, color: s.color.clone(), tint: s.tint, path, nonprinting: s.nonprinting }));
|
||||
}
|
||||
decos.push((part.fi, true, deco));
|
||||
}
|
||||
if let Some(bd) = &b.border
|
||||
&& let Some(r) = inner_rect(&part.lines, bd.width, bd.top, bd.bottom)
|
||||
{
|
||||
let [t, l, bo, rr] = bd.offsets;
|
||||
let o = Rect::new(r.x0 - l, r.y0 - t, r.x1 + rr, r.y1 + bo);
|
||||
let open_top = !first_part && !bd.display_if_splits;
|
||||
let open_bottom = !last_part && !bd.display_if_splits;
|
||||
for (path, gap) in border_paths(bd, o, open_top, open_bottom) {
|
||||
let (color, tint) = if gap { (bd.gap_color.clone(), bd.gap_tint) } else { (bd.color.clone(), bd.tint) };
|
||||
decos.push((part.fi, false, Deco { rect: path.bounding_box(), color, tint, path: Some(path), nonprinting: false }));
|
||||
}
|
||||
if is_plain(bd, open_top, open_bottom) {
|
||||
decos.extend(
|
||||
plain_edges(bd, o, open_top, open_bottom)
|
||||
.into_iter()
|
||||
.map(|rect| (part.fi, false, Deco { rect, color: bd.color.clone(), tint: bd.tint, path: None, nonprinting: false })),
|
||||
);
|
||||
}
|
||||
if let Some(bd) = &b.border {
|
||||
border_decos(bd, part, first_part, last_part, &mut decos);
|
||||
}
|
||||
}
|
||||
decos
|
||||
|
||||
+31
-25
@@ -71,6 +71,36 @@ fn tcy_char(c: char, roman: bool) -> bool {
|
||||
c.is_ascii_digit() || (roman && c.is_ascii_alphabetic())
|
||||
}
|
||||
|
||||
/// Tate-chu-yoko in vertical ruby: each short run of digits (and Latin letters) is set in one
|
||||
/// ruby em across the line, scaled down to fit when the spec asks for it.
|
||||
fn apply_tcy(glyphs: &mut [RubyGlyph], spec: &RubySpec, size: f64) {
|
||||
let roman = spec.auto_tcy_include_roman;
|
||||
let mut i = 0;
|
||||
while i < glyphs.len() {
|
||||
let j = i + glyphs.get(i..).unwrap_or_default().iter().take_while(|g| tcy_char(g.ch, roman)).count();
|
||||
if j == i {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if u32::try_from(j - i).is_ok_and(|n| n <= spec.auto_tcy_digits)
|
||||
&& let Some(run) = glyphs.get_mut(i..j)
|
||||
{
|
||||
let total: f64 = run.iter().map(|g| g.adv).sum();
|
||||
let f = if spec.auto_tcy_auto_scale && total > size && total > 0.0 { size / total } else { 1.0 };
|
||||
let mut across = -total * f / 2.0;
|
||||
for (n, g) in run.iter_mut().enumerate() {
|
||||
g.tcy = Some([size / 2.0, across + g.x * f, size]);
|
||||
across += g.adv * f;
|
||||
g.sx *= f;
|
||||
g.x = 0.0;
|
||||
g.adv = if n == 0 { size } else { 0.0 };
|
||||
g.joined = n > 0;
|
||||
}
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
}
|
||||
|
||||
/// Shape `text` as the ruby of parent text `parent_size` points big.
|
||||
fn shape_ruby(db: &ScopedFonts<'_>, parent: FaceRef, text: &str, spec: &RubySpec, parent_size: f64, fallback: bool, vertical: bool) -> Shaped {
|
||||
let size = spec.size_for(parent_size);
|
||||
@@ -96,31 +126,7 @@ fn shape_ruby(db: &ScopedFonts<'_>, parent: FaceRef, text: &str, spec: &RubySpec
|
||||
.collect();
|
||||
// Tate-chu-yoko: short runs of digits (and Latin letters) take one ruby em along the line.
|
||||
if vertical && spec.auto_tcy_digits > 0 {
|
||||
let roman = spec.auto_tcy_include_roman;
|
||||
let mut i = 0;
|
||||
while i < glyphs.len() {
|
||||
let j = i + glyphs.get(i..).unwrap_or_default().iter().take_while(|g| tcy_char(g.ch, roman)).count();
|
||||
if j == i {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if u32::try_from(j - i).is_ok_and(|n| n <= spec.auto_tcy_digits)
|
||||
&& let Some(run) = glyphs.get_mut(i..j)
|
||||
{
|
||||
let total: f64 = run.iter().map(|g| g.adv).sum();
|
||||
let f = if spec.auto_tcy_auto_scale && total > size && total > 0.0 { size / total } else { 1.0 };
|
||||
let mut across = -total * f / 2.0;
|
||||
for (n, g) in run.iter_mut().enumerate() {
|
||||
g.tcy = Some([size / 2.0, across + g.x * f, size]);
|
||||
across += g.adv * f;
|
||||
g.sx *= f;
|
||||
g.x = 0.0;
|
||||
g.adv = if n == 0 { size } else { 0.0 };
|
||||
g.joined = n > 0;
|
||||
}
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
apply_tcy(&mut glyphs, spec, size);
|
||||
}
|
||||
// Pen positions: a joined glyph sits at its group's start.
|
||||
let mut pen = 0.0;
|
||||
|
||||
@@ -950,10 +950,10 @@ static WORD_CACHE: RwLock<Vec<(WordKey, Arc<RwLock<WordMap>>)>> = RwLock::new(Ve
|
||||
const WORD_CACHE_MAX: usize = 50_000;
|
||||
|
||||
fn word_map(key: WordKey) -> Arc<RwLock<WordMap>> {
|
||||
if let Some((_, m)) = WORD_CACHE.read().unwrap_or_else(|e| e.into_inner()).iter().find(|e| e.0 == key) {
|
||||
if let Some((_, m)) = WORD_CACHE.read().unwrap_or_else(std::sync::PoisonError::into_inner).iter().find(|e| e.0 == key) {
|
||||
return m.clone();
|
||||
}
|
||||
let mut w = WORD_CACHE.write().unwrap_or_else(|e| e.into_inner());
|
||||
let mut w = WORD_CACHE.write().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some((_, m)) = w.iter().find(|e| e.0 == key) {
|
||||
return m.clone();
|
||||
}
|
||||
|
||||
+27
-22
@@ -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.
|
||||
@@ -342,6 +343,26 @@ pub(crate) fn place_table(
|
||||
true
|
||||
}
|
||||
|
||||
/// A cell's text cut to the lines that fit `clip_height`, with where the overset text begins.
|
||||
/// Uses the same baseline/bounds test as auto-growth, and retains a prefix: overset text cannot
|
||||
/// reappear after a line that did not fit.
|
||||
fn clip_overset(lay: &CellLayout, clip_height: f64) -> Arc<ComposedStory> {
|
||||
let Some(f) = lay.text.frames.first() else { return lay.text.clone() };
|
||||
let keep = lay.line_heights.iter().take_while(|h| **h <= clip_height + 0.01).count();
|
||||
let overset_at = f.lines.get(keep).map(|l| l.range.start);
|
||||
let mut t = (*lay.text).clone();
|
||||
if let Some(frame) = t.frames.first_mut() {
|
||||
frame.lines.truncate(keep);
|
||||
frame.objects.retain(|o| o.line < keep);
|
||||
frame.tables.retain(|table| table.line < keep);
|
||||
frame.range.end = frame.lines.last().map_or(frame.range.start, |l| l.range.end);
|
||||
}
|
||||
if let Some(at) = overset_at {
|
||||
t.overset_at = Some(at);
|
||||
}
|
||||
Arc::new(t)
|
||||
}
|
||||
|
||||
/// Build one fragment from its rows and push it (with its glyph-less line) to the current frame.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn emit(
|
||||
@@ -406,24 +427,7 @@ fn emit(
|
||||
_ => 0.0,
|
||||
};
|
||||
let overset = space < -0.01 || lay.text.is_overset();
|
||||
let mut text = lay.text.clone();
|
||||
if overset && let Some(f) = text.frames.first() {
|
||||
// Use the same baseline/bounds test as auto-growth, and retain a prefix:
|
||||
// overset text cannot reappear after a line that did not fit.
|
||||
let keep = lay.line_heights.iter().take_while(|h| **h <= clip.height() + 0.01).count();
|
||||
let overset_at = f.lines.get(keep).map(|l| l.range.start);
|
||||
let mut t = (*text).clone();
|
||||
if let Some(frame) = t.frames.first_mut() {
|
||||
frame.lines.truncate(keep);
|
||||
frame.objects.retain(|o| o.line < keep);
|
||||
frame.tables.retain(|table| table.line < keep);
|
||||
frame.range.end = frame.lines.last().map_or(frame.range.start, |l| l.range.end);
|
||||
}
|
||||
if let Some(at) = overset_at {
|
||||
t.overset_at = Some(at);
|
||||
}
|
||||
text = Arc::new(t);
|
||||
}
|
||||
let text = if overset { clip_overset(lay, clip.height()) } else { lay.text.clone() };
|
||||
cells.push(PlacedCell {
|
||||
row: r,
|
||||
col: c,
|
||||
@@ -813,7 +817,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 +1065,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);
|
||||
|
||||
+142
-1
@@ -1150,7 +1150,7 @@ fn nonbreaking_space_alone_fills_a_justified_line() {
|
||||
/// Story of `n` one-line filler paragraphs followed by `extra` paragraphs, in a 2-column frame.
|
||||
fn keep_doc(fillers: usize, extra: &[&str], height: f64) -> (Document, StoryId, Vec<usize>) {
|
||||
let mut parts: Vec<String> = (0..fillers).map(|k| format!("Filler {k}")).collect();
|
||||
parts.extend(extra.iter().map(|s| s.to_string()));
|
||||
parts.extend(extra.iter().map(ToString::to_string));
|
||||
let text = parts.join("\n");
|
||||
let (mut d, sid, fid) = doc_with(&text, Rect::new(0.0, 0.0, 400.0, height), ParaAttrs::default());
|
||||
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2;
|
||||
@@ -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]
|
||||
@@ -7310,3 +7377,77 @@ fn rule_above_offset_is_measured_from_the_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]));
|
||||
}
|
||||
@@ -93,6 +93,21 @@ fn line_count(len: usize, want: usize, before: usize, after: usize) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
/// The per-line counts recovered from the split table, or `None` when they don't cover all `m`.
|
||||
fn trace_counts(take: &[Vec<usize>], n: usize, m: usize) -> Option<Vec<usize>> {
|
||||
let mut counts = Vec::with_capacity(n);
|
||||
let mut s = 0_usize;
|
||||
for lines in (1..=n).rev() {
|
||||
let t = take[lines][s];
|
||||
if t == 0 || s + t > m {
|
||||
return None;
|
||||
}
|
||||
counts.push(t);
|
||||
s += t;
|
||||
}
|
||||
(s == m).then_some(counts)
|
||||
}
|
||||
|
||||
/// Glyph counts for `n` lines. Minimises the widest line, and the first and last lines keep their
|
||||
/// break minimums. One line when no split satisfies them.
|
||||
fn split_even(advs: &[f64], n: usize, before: usize, after: usize) -> Vec<usize> {
|
||||
@@ -142,17 +157,7 @@ fn split_even(advs: &[f64], n: usize, before: usize, after: usize) -> Vec<usize>
|
||||
if !best[n][0].is_finite() {
|
||||
return vec![m];
|
||||
}
|
||||
let mut counts = Vec::with_capacity(n);
|
||||
let mut s = 0_usize;
|
||||
for lines in (1..=n).rev() {
|
||||
let t = take[lines][s];
|
||||
if t == 0 || s + t > m {
|
||||
return vec![m];
|
||||
}
|
||||
counts.push(t);
|
||||
s += t;
|
||||
}
|
||||
if s != m { vec![m] } else { counts }
|
||||
trace_counts(&take, n, m).unwrap_or_else(|| vec![m])
|
||||
}
|
||||
|
||||
fn restore(line: &mut [PlacedGlyph], members: &[usize], saved: &[(f64, f64, f64, f64)]) {
|
||||
@@ -217,13 +222,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 +265,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 +290,6 @@ fn layout(styles: &[RunStyle], line: &mut [PlacedGlyph], members: &[usize]) -> f
|
||||
g.x = anchor.1 + (g.x - anchor.0) * scale;
|
||||
}
|
||||
}
|
||||
delta
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -371,7 +371,7 @@ fn locale_code(words: &[&str]) -> Option<String> {
|
||||
/// from documents, so at most [`MAX_TAGS`] are kept; past that a new tag is unknown (`None`).
|
||||
fn intern_tag(tag: String) -> Option<&'static str> {
|
||||
const MAX_TAGS: usize = 512;
|
||||
static TAGS: std::sync::Mutex<Vec<&'static str>> = std::sync::Mutex::new(Vec::new());
|
||||
static TAGS: std::sync::Mutex<Vec<&str>> = std::sync::Mutex::new(Vec::new());
|
||||
let mut tags = TAGS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some(t) = tags.iter().find(|t| **t == tag) {
|
||||
return Some(t);
|
||||
|
||||
+20
-15
@@ -100,6 +100,25 @@ fn sort_key(s: &str) -> String {
|
||||
if k.is_empty() { s.to_lowercase() } else { k }
|
||||
}
|
||||
|
||||
/// Record one reference's see-target or page on its topic node.
|
||||
fn record_ref(n: &mut Node, res: &Resolved) {
|
||||
match &res.r.range {
|
||||
IndexRange::See(t) => n.see.push(t.clone()),
|
||||
IndexRange::SeeAlso(t) => {
|
||||
n.see_also.push(t.clone());
|
||||
if let Some(p) = res.pages {
|
||||
n.pages.push(p);
|
||||
}
|
||||
}
|
||||
IndexRange::SuppressPageRange => {}
|
||||
_ => {
|
||||
if let Some(p) = res.pages {
|
||||
n.pages.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The index as paragraphs `(style, text)`; `page_name` formats an absolute page.
|
||||
pub fn build(spec: &IndexSpec, refs: &[Resolved], page_name: &dyn Fn(usize) -> String) -> Vec<(String, String)> {
|
||||
let mut root: BTreeMap<String, Node> = BTreeMap::new();
|
||||
@@ -114,21 +133,7 @@ pub fn build(spec: &IndexSpec, refs: &[Resolved], page_name: &dyn Fn(usize) -> S
|
||||
.map(|(i, t)| (format!("{}\u{0}{}", sort_key(res.r.sort.get(i).filter(|s| !s.is_empty()).unwrap_or(t)), t.trim()), t.trim().to_string()))
|
||||
.collect();
|
||||
let n = descend(&mut root, &keys);
|
||||
match &res.r.range {
|
||||
IndexRange::See(t) => n.see.push(t.clone()),
|
||||
IndexRange::SeeAlso(t) => {
|
||||
n.see_also.push(t.clone());
|
||||
if let Some(p) = res.pages {
|
||||
n.pages.push(p);
|
||||
}
|
||||
}
|
||||
IndexRange::SuppressPageRange => {}
|
||||
_ => {
|
||||
if let Some(p) = res.pages {
|
||||
n.pages.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
record_ref(n, res);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
if !spec.title.is_empty() {
|
||||
|
||||
@@ -53,7 +53,7 @@ pub fn figures(list: &[String]) -> &'static str {
|
||||
pub fn set_figures(list: &mut Vec<String>, id: &str) -> bool {
|
||||
let Some((_, _, tags, _)) = FIGURES.iter().find(|f| f.0 == id) else { return false };
|
||||
list.retain(|t| !matches!(t.as_str(), "tnum" | "pnum" | "lnum" | "onum"));
|
||||
list.extend(tags.iter().map(|t| t.to_string()));
|
||||
list.extend(tags.iter().map(ToString::to_string));
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
@@ -1040,6 +1040,15 @@ impl Table {
|
||||
if self.rows[h..self.nrows() - f].iter().any(|r| r.kind != RowKind::Body) {
|
||||
return Err(format!("table {}: header/footer rows out of order", self.id));
|
||||
}
|
||||
self.check_spans()?;
|
||||
for cell in &self.cells {
|
||||
cell.text.check()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every merged cell stays inside the grid and no two merged cells overlap.
|
||||
fn check_spans(&self) -> Result<(), String> {
|
||||
let mut seen = vec![false; self.cells.len()];
|
||||
for r in 0..self.nrows() {
|
||||
for c in 0..self.ncols() {
|
||||
@@ -1064,9 +1073,6 @@ impl Table {
|
||||
}
|
||||
}
|
||||
}
|
||||
for cell in &self.cells {
|
||||
cell.text.check()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -130,7 +130,7 @@ impl ChapterBatch {
|
||||
fn writable_chapter(s: &Session, path: &str, command: &'static str) -> Result<()> {
|
||||
use std::path::Path;
|
||||
|
||||
if Path::new(path).extension().and_then(|ext| ext.to_str()).is_some_and(|ext| ext.eq_ignore_ascii_case("idml")) {
|
||||
if Path::new(path).extension().and_then(std::ffi::OsStr::to_str).is_some_and(|ext| ext.eq_ignore_ascii_case("idml")) {
|
||||
return Err(bad(command, format!("cannot change imported IDML chapter {path}; convert it to a native DesignCraft document first")));
|
||||
}
|
||||
let target = crate::io::canonicalize(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
|
||||
@@ -254,7 +254,7 @@ fn find_markers(text: &str) -> Vec<(usize, usize, String)> {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
i += text[i..].chars().next().map(|c| c.len_utf8()).unwrap_or(1);
|
||||
i += text[i..].chars().next().map(char::len_utf8).unwrap_or(1);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -617,7 +617,7 @@ fn marker_ranges(text: &str, marker: &str) -> Vec<(usize, usize)> {
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
i = i.saturating_add(text[i..].chars().next().map(|c| c.len_utf8()).unwrap_or(1));
|
||||
i = i.saturating_add(text[i..].chars().next().map(char::len_utf8).unwrap_or(1));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -379,7 +379,7 @@ fn sniff(name: &str, text: &str) -> Delimiter {
|
||||
}
|
||||
|
||||
pub fn extension(name: &str) -> String {
|
||||
Path::new(name).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase()
|
||||
Path::new(name).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase()
|
||||
}
|
||||
|
||||
struct HeaderDelimiterCandidate {
|
||||
|
||||
@@ -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 {
|
||||
@@ -327,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());
|
||||
@@ -538,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()
|
||||
})
|
||||
}
|
||||
@@ -1326,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());
|
||||
}
|
||||
}
|
||||
@@ -166,7 +166,7 @@ fn save_bytes(d: &Document, preview: Option<&[u8]>) -> Result<Vec<u8>> {
|
||||
/// the file opens from the file manager. A path without a file name is left for the write to fail.
|
||||
pub fn with_document_extension(path: &str) -> String {
|
||||
let p = std::path::Path::new(path);
|
||||
let ext = p.extension().and_then(|e| e.to_str());
|
||||
let ext = p.extension().and_then(std::ffi::OsStr::to_str);
|
||||
if p.file_name().is_none() || ext.is_some_and(|e| e.eq_ignore_ascii_case(designcraft_format::EXTENSION)) {
|
||||
return path.to_string();
|
||||
}
|
||||
@@ -469,7 +469,7 @@ fn open_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let bytes = crate::io::read_file(path).map_err(EngineError::Other)?;
|
||||
// The content decides first: a file's extension may be missing or wrong. The extension
|
||||
// then routes files whose content isn't recognised to the reader that explains why.
|
||||
let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or_default().to_ascii_lowercase();
|
||||
let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or_default().to_ascii_lowercase();
|
||||
let native = designcraft_format::is_designcraft(&bytes) || bytes.trim_ascii_start().starts_with(b"{");
|
||||
if designcraft_idml::is_idml(&bytes) || (!native && (super::interchange::is_indd(path, &bytes) || ext == "idml")) {
|
||||
return super::interchange::open_idml(s, p);
|
||||
@@ -679,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);
|
||||
|
||||
+104
-47
@@ -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, Story, StoryId, Styles, TextSel};
|
||||
use designcraft_doc::{CellAddr, Document, Selection, Story, StoryId, Styles, 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?, findFormat?: {charStyle?, paraStyle?, chars?: resolved character attributes} (Find Format: only text with it all through; with an empty find, each stretch of text with it), change?|first?|attrs?|charStyle?|paraStyle?: 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?, findFormat?: {charStyle?, paraStyle?, chars?: resolved character attributes} (Find Format: only text with it all through; with an empty find, each stretch of text with it), change?|first?|attrs?|charStyle?|paraStyle?: 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",
|
||||
@@ -236,23 +236,24 @@ impl Search {
|
||||
self.format.as_ref().map_or(Ok(()), |f| f.check(styles))
|
||||
}
|
||||
|
||||
/// Where the Find Format holds in target `r` of story `sid` (`None`: no Find Format).
|
||||
fn spans(&self, d: &Document, sid: StoryId, r: &Range<usize>) -> Option<Vec<Range<usize>>> {
|
||||
/// Where the Find Format holds in target `r` of story `sid` (of its table cell `cell`, if any;
|
||||
/// `None`: no Find Format).
|
||||
fn spans(&self, d: &Document, sid: StoryId, cell: Option<CellAddr>, r: &Range<usize>) -> Option<Vec<Range<usize>>> {
|
||||
let f = self.format.as_ref()?;
|
||||
Some(d.story(sid).map(|story| f.spans(&d.styles, story, r)).unwrap_or_default())
|
||||
Some(d.text_story(sid, cell).map(|story| f.spans(&d.styles, story, r)).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Up to `limit` matches in `text`, target `r` of story `sid` (offsets in `text`).
|
||||
fn find_all(&self, d: &Document, sid: StoryId, r: &Range<usize>, text: &str, limit: usize) -> Result<Vec<Range<usize>>> {
|
||||
let spans = self.spans(d, sid, r);
|
||||
fn find_all(&self, d: &Document, sid: StoryId, cell: Option<CellAddr>, r: &Range<usize>, text: &str, limit: usize) -> Result<Vec<Range<usize>>> {
|
||||
let spans = self.spans(d, sid, cell, r);
|
||||
let Some(re) = &self.re else { return Ok(spans.unwrap_or_default().into_iter().take(limit).collect()) };
|
||||
let Some(spans) = spans else { return re.find_all(text, limit).map_err(|e| bad("find", e)) };
|
||||
Ok(re.find_all(text, usize::MAX).map_err(|e| bad("find", e))?.into_iter().filter(|m| within(&spans, m)).take(limit).collect())
|
||||
}
|
||||
|
||||
/// The first match in `text` (target `r` of story `sid`) at or after byte `from`.
|
||||
fn find_at(&self, d: &Document, sid: StoryId, r: &Range<usize>, text: &str, from: usize) -> Result<Option<Range<usize>>> {
|
||||
let spans = self.spans(d, sid, r);
|
||||
fn find_at(&self, d: &Document, sid: StoryId, cell: Option<CellAddr>, r: &Range<usize>, text: &str, from: usize) -> Result<Option<Range<usize>>> {
|
||||
let spans = self.spans(d, sid, cell, r);
|
||||
let Some(re) = &self.re else {
|
||||
// From inside a stretch, the rest of it.
|
||||
return Ok(spans.unwrap_or_default().into_iter().find(|s| s.end > from).map(|s| s.start.max(from)..s.end));
|
||||
@@ -278,6 +279,7 @@ impl Search {
|
||||
&self,
|
||||
d: &Document,
|
||||
sid: StoryId,
|
||||
cell: Option<CellAddr>,
|
||||
r: &Range<usize>,
|
||||
text: &str,
|
||||
rep: &str,
|
||||
@@ -285,7 +287,7 @@ impl Search {
|
||||
first_only: bool,
|
||||
) -> Result<Vec<(Range<usize>, String)>> {
|
||||
let limit = if first_only { 1 } else { usize::MAX };
|
||||
match (&self.re, self.spans(d, sid, r)) {
|
||||
match (&self.re, self.spans(d, sid, cell, r)) {
|
||||
(Some(re), None) if grep => re.replacements(text, rep, first_only).map_err(|e| bad("find.change", e)),
|
||||
(Some(re), Some(spans)) if grep => Ok(re
|
||||
.replacements(text, rep, false)
|
||||
@@ -294,7 +296,7 @@ impl Search {
|
||||
.filter(|(m, _)| within(&spans, m))
|
||||
.take(limit)
|
||||
.collect()),
|
||||
_ => Ok(self.find_all(d, sid, r, text, limit)?.into_iter().map(|m| (m, rep.to_string())).collect()),
|
||||
_ => Ok(self.find_all(d, sid, cell, r, text, limit)?.into_iter().map(|m| (m, rep.to_string())).collect()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -346,6 +348,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)]
|
||||
@@ -356,7 +361,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,
|
||||
@@ -377,21 +382,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"));
|
||||
}
|
||||
@@ -402,7 +411,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) {
|
||||
@@ -415,8 +424,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())
|
||||
@@ -439,7 +448,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()) {
|
||||
@@ -448,10 +457,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),
|
||||
@@ -479,10 +499,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 m in re.find_all(&st.doc, *sid, r, text, MAX_MATCHES.saturating_sub(out.len()))? {
|
||||
out.push(json!({"story": sid.0, "start": r.start + m.start, "end": r.start + m.end, "text": text.get(m)}));
|
||||
for (sid, cell, r) in &context.targets {
|
||||
let text = target_text(&st.doc, *sid, *cell, r)?;
|
||||
for m in re.find_all(&st.doc, *sid, *cell, r, text, MAX_MATCHES.saturating_sub(out.len()))? {
|
||||
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);
|
||||
@@ -525,16 +549,16 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit(|d, sel| {
|
||||
let styles = d.styles.clone();
|
||||
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)> = re
|
||||
.replacements(d, sid, &r, text, &rep, grep, first_only)?
|
||||
.replacements(d, sid, cell, &r, text, &rep, grep, first_only)?
|
||||
.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() {
|
||||
let changed = if format_only {
|
||||
range.clone()
|
||||
@@ -555,7 +579,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);
|
||||
@@ -583,13 +607,13 @@ 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"));
|
||||
}
|
||||
if let Some(m) = re.find_at(&st.doc, sid, &r, text, from)? {
|
||||
if let Some(m) = re.find_at(&st.doc, sid, cell, &r, text, from)? {
|
||||
let (a, b) = (r.start + m.start, r.start + m.end);
|
||||
// Match find_iter/captures_iter: suppress an empty match adjacent to the
|
||||
// previous match's end, but keep adjacent nonempty matches.
|
||||
@@ -597,7 +621,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);
|
||||
@@ -608,17 +632,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)]
|
||||
@@ -1341,6 +1370,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);
|
||||
@@ -1385,12 +1439,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() },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -161,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()}))
|
||||
@@ -2497,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")]
|
||||
{
|
||||
@@ -480,7 +480,7 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
continue;
|
||||
}
|
||||
let old = std::path::PathBuf::from(a.link.clone().unwrap_or_else(|| a.name.clone()));
|
||||
let mut name = old.file_name().map(|n| n.to_os_string()).unwrap_or_else(|| a.name.clone().into());
|
||||
let mut name = old.file_name().map(std::ffi::OsStr::to_os_string).unwrap_or_else(|| a.name.clone().into());
|
||||
if let Some(e) = &ext {
|
||||
name = std::path::Path::new(&name).with_extension(e).into_os_string();
|
||||
}
|
||||
@@ -490,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 {
|
||||
|
||||
@@ -201,9 +201,9 @@ fn plan(doc: &Document, src: &Document, paragraph: Option<&[String]>, character:
|
||||
Ok((para, chars))
|
||||
}
|
||||
|
||||
fn load(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let src = source(p, ID)?;
|
||||
let conflict = match p.get("conflicts") {
|
||||
/// The `conflicts` parameter: how a style that differs under the same name is handled.
|
||||
fn conflict_param(p: &Value) -> Result<Conflict> {
|
||||
Ok(match p.get("conflicts") {
|
||||
None | Some(Value::Null) => Conflict::Redefine,
|
||||
Some(v) => match v.as_str() {
|
||||
Some("redefine") => Conflict::Redefine,
|
||||
@@ -211,7 +211,37 @@ fn load(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some("autoRename") => Conflict::AutoRename,
|
||||
_ => return Err(bad(ID, "`conflicts` must be redefine, useExisting or autoRename")),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Give the loaded style `ours` the export tag the source had for `theirs` (`kind` is `p` or `c`).
|
||||
fn carry_export_tag(src: &Styles, dst: &mut Styles, kind: char, theirs: &str, ours: &str) {
|
||||
let tag = format!("{kind}:{ours}");
|
||||
match src.export_tags.get(&format!("{kind}:{theirs}")) {
|
||||
Some(t) => dst.export_tags.insert(tag, t.clone()),
|
||||
None => dst.export_tags.remove(&tag),
|
||||
};
|
||||
}
|
||||
|
||||
/// A loaded style and one the document keeps can name each other as their base.
|
||||
fn check_based_on_cycles(styles: &Styles, loaded: &[(bool, String)]) -> Result<()> {
|
||||
for (is_para, name) in loaded {
|
||||
let looped = if *is_para {
|
||||
styles.para(name).and_then(|s| s.based_on.as_deref()).is_some_and(|b| styles.para_based_on_cycles(name, b))
|
||||
} else {
|
||||
styles.char_style(name).and_then(|s| s.based_on.as_deref()).is_some_and(|b| styles.char_based_on_cycles(name, b))
|
||||
};
|
||||
if looped {
|
||||
let kind = if *is_para { "paragraph" } else { "character" };
|
||||
return Err(bad(ID, format!("{kind} style `{name}` would be based on itself; load the style it is based on as well")));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let src = source(p, ID)?;
|
||||
let conflict = conflict_param(p)?;
|
||||
let (paragraph, character) = (names(p, "paragraph")?, names(p, "character")?);
|
||||
let doc = s.doc()?.doc.clone();
|
||||
let (mut para, mut chars) = plan(&doc, &src, paragraph.as_deref(), character.as_deref(), conflict)?;
|
||||
@@ -231,11 +261,7 @@ fn load(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
p.nested_line_styles.iter_mut().flatten().for_each(|l| chars.follow(&mut l.style));
|
||||
swatches.extend(char_swatches(&style.chars).chain(para_swatches(&style.para)).cloned());
|
||||
lists.extend(style.para.list_name.iter().cloned());
|
||||
let tag = format!("p:{}", style.name);
|
||||
match src.styles.export_tags.get(&format!("p:{}", theirs.name)) {
|
||||
Some(t) => styles.export_tags.insert(tag, t.clone()),
|
||||
None => styles.export_tags.remove(&tag),
|
||||
};
|
||||
carry_export_tag(&src.styles, &mut styles, 'p', &theirs.name, &style.name);
|
||||
loaded.push((true, style.name.clone()));
|
||||
match styles.para_mut(&style.name) {
|
||||
Some(m) if action == Action::Replace => {
|
||||
@@ -256,11 +282,7 @@ fn load(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
chars.follow(&mut style.name);
|
||||
style.based_on.iter_mut().for_each(|n| chars.follow(n));
|
||||
swatches.extend(char_swatches(&style.chars).cloned());
|
||||
let tag = format!("c:{}", style.name);
|
||||
match src.styles.export_tags.get(&format!("c:{}", theirs.name)) {
|
||||
Some(t) => styles.export_tags.insert(tag, t.clone()),
|
||||
None => styles.export_tags.remove(&tag),
|
||||
};
|
||||
carry_export_tag(&src.styles, &mut styles, 'c', &theirs.name, &style.name);
|
||||
loaded.push((false, style.name.clone()));
|
||||
match styles.char_style_mut(&style.name) {
|
||||
Some(m) if action == Action::Replace => {
|
||||
@@ -275,18 +297,7 @@ fn load(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// A loaded style and one the document keeps can name each other as their base.
|
||||
for (is_para, name) in &loaded {
|
||||
let looped = if *is_para {
|
||||
styles.para(name).and_then(|s| s.based_on.as_deref()).is_some_and(|b| styles.para_based_on_cycles(name, b))
|
||||
} else {
|
||||
styles.char_style(name).and_then(|s| s.based_on.as_deref()).is_some_and(|b| styles.char_based_on_cycles(name, b))
|
||||
};
|
||||
if looped {
|
||||
let kind = if *is_para { "paragraph" } else { "character" };
|
||||
return Err(bad(ID, format!("{kind} style `{name}` would be based on itself; load the style it is based on as well")));
|
||||
}
|
||||
}
|
||||
check_based_on_cycles(&styles, &loaded)?;
|
||||
|
||||
let swatches = missing_swatches(&doc, &src, swatches);
|
||||
let lists: Vec<NumberedList> = {
|
||||
|
||||
@@ -17,6 +17,7 @@ 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;
|
||||
|
||||
@@ -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,16 +270,33 @@ 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")));
|
||||
}
|
||||
// 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);
|
||||
@@ -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)?;
|
||||
}
|
||||
@@ -568,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);
|
||||
@@ -1261,6 +1273,35 @@ fn resize_track(sizes: &[f64], index: usize, delta: f64, keep: bool) -> Option<V
|
||||
out.iter().all(|v| v.is_finite()).then_some(out)
|
||||
}
|
||||
|
||||
/// The frame a border drag names must hold the table's story and be editable.
|
||||
fn check_border_frame(st: &crate::DocState, p: &Value, story: designcraft_doc::StoryId) -> Result<()> {
|
||||
const ID: &str = "table.resizeBorder";
|
||||
if let Some(fid) = super::id_param(p, "frame") {
|
||||
let it = st.doc.item(fid).ok_or_else(|| bad(ID, format!("no frame {}", fid.0)))?;
|
||||
if it.text_frame().map(|f| f.story) != Some(story) {
|
||||
return Err(bad(ID, "the table is not in that frame's story"));
|
||||
}
|
||||
if it.locked || st.doc.layer(it.layer).is_some_and(|l| l.locked) {
|
||||
return Err(bad(ID, "the frame or its layer is locked"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The change in track size: `delta`, or `size` measured from the current size `cur`.
|
||||
fn border_delta(p: &Value, cur: f64) -> Result<f64> {
|
||||
const ID: &str = "table.resizeBorder";
|
||||
let delta = match (p.get("delta").and_then(Value::as_f64), p.get("size").and_then(Value::as_f64)) {
|
||||
(Some(d), _) => d,
|
||||
(None, Some(sz)) => sz - cur,
|
||||
(None, None) => return Err(bad(ID, "`delta` or `size` required")),
|
||||
};
|
||||
if !delta.is_finite() {
|
||||
return Err(bad(ID, "`delta`/`size` must be a finite number"));
|
||||
}
|
||||
Ok(delta)
|
||||
}
|
||||
|
||||
fn resize_border(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "table.resizeBorder";
|
||||
let column = match str_param(p, "axis") {
|
||||
@@ -1272,15 +1313,7 @@ fn resize_border(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let keep = p.get("keepTableSize").and_then(Value::as_bool).unwrap_or(false);
|
||||
let g = target(s, p, ID)?;
|
||||
let st = s.doc()?;
|
||||
if let Some(fid) = super::id_param(p, "frame") {
|
||||
let it = st.doc.item(fid).ok_or_else(|| bad(ID, format!("no frame {}", fid.0)))?;
|
||||
if it.text_frame().map(|f| f.story) != Some(g.story) {
|
||||
return Err(bad(ID, "the table is not in that frame's story"));
|
||||
}
|
||||
if it.locked || st.doc.layer(it.layer).is_some_and(|l| l.locked) {
|
||||
return Err(bad(ID, "the frame or its layer is locked"));
|
||||
}
|
||||
}
|
||||
check_border_frame(st, p, g.story)?;
|
||||
let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad(ID, "no table"))?;
|
||||
let sizes: Vec<f64> = if column {
|
||||
t.columns.iter().map(|c| c.width).collect()
|
||||
@@ -1289,14 +1322,7 @@ fn resize_border(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(0..t.nrows()).map(|r| composed_row_height(&cs, t, r).unwrap_or(MIN_TRACK)).collect()
|
||||
};
|
||||
let cur = *sizes.get(index).ok_or_else(|| bad(ID, format!("index {index} out of range")))?;
|
||||
let delta = match (p.get("delta").and_then(Value::as_f64), p.get("size").and_then(Value::as_f64)) {
|
||||
(Some(d), _) => d,
|
||||
(None, Some(sz)) => sz - cur,
|
||||
(None, None) => return Err(bad(ID, "`delta` or `size` required")),
|
||||
};
|
||||
if !delta.is_finite() {
|
||||
return Err(bad(ID, "`delta`/`size` must be a finite number"));
|
||||
}
|
||||
let delta = border_delta(p, cur)?;
|
||||
let new = resize_track(&sizes, index, delta, keep).ok_or_else(|| bad(ID, "invalid table geometry"))?;
|
||||
let size = new.get(index).copied().unwrap_or(cur);
|
||||
let scaled = keep && index + 1 == sizes.len();
|
||||
@@ -1393,14 +1419,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 +1574,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 +1858,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();
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::{EngineError, Result, Session};
|
||||
pub fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
|
||||
payload
|
||||
.downcast_ref::<&str>()
|
||||
.map(|s| s.to_string())
|
||||
.map(ToString::to_string)
|
||||
.or_else(|| payload.downcast_ref::<String>().cloned())
|
||||
.unwrap_or_else(|| "unknown panic".into())
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
+30
-20
@@ -542,17 +542,7 @@ impl Session {
|
||||
// passes that its command's description doesn't name fails the test.
|
||||
#[cfg(test)]
|
||||
check_params(id, params)?;
|
||||
if let Err(e) = (spec.enabled)(self) {
|
||||
// A command that takes `ids` acts on the objects the call names, so the selection
|
||||
// doesn't matter: with a text caret, nothing selected, or a selection the command
|
||||
// can't act on, `{"ids": [5]}` still runs.
|
||||
let named = if cmd::selection_reason(&e) { cmd::named_targets(spec, params) } else { None };
|
||||
let Some(ids) = named else { return Err(EngineError::Disabled(id.into(), e)) };
|
||||
let st = self.doc()?;
|
||||
if let Some(gone) = ids.iter().find(|i| st.doc.item(**i).is_none()) {
|
||||
return Err(EngineError::Disabled(id.into(), format!("no object with id {}", gone.0)));
|
||||
}
|
||||
}
|
||||
self.check_enabled(spec, id, params)?;
|
||||
// Preview is temporary. Save, merge, and every other edit restore the template first.
|
||||
// Queries stay read-only so the panel can poll fields during a preview. `data.preview`
|
||||
// itself is the one command that fills the live document.
|
||||
@@ -598,11 +588,38 @@ impl Session {
|
||||
st.doc = Arc::new(d);
|
||||
}
|
||||
}
|
||||
// Auto-size frames fit the text the command changed, in the same undo step.
|
||||
self.autosize_changed(spec, before.as_ref());
|
||||
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()));
|
||||
}
|
||||
self.discard_stale_find_scope();
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Fails with `Disabled` unless the command is enabled or names objects it can act on.
|
||||
fn check_enabled(&self, spec: &CommandSpec, id: &str, params: &Value) -> Result<()> {
|
||||
if let Err(e) = (spec.enabled)(self) {
|
||||
// A command that takes `ids` acts on the objects the call names, so the selection
|
||||
// doesn't matter: with a text caret, nothing selected, or a selection the command
|
||||
// can't act on, `{"ids": [5]}` still runs.
|
||||
let named = if cmd::selection_reason(&e) { cmd::named_targets(spec, params) } else { None };
|
||||
let Some(ids) = named else { return Err(EngineError::Disabled(id.into(), e)) };
|
||||
let st = self.doc()?;
|
||||
if let Some(gone) = ids.iter().find(|i| st.doc.item(**i).is_none()) {
|
||||
return Err(EngineError::Disabled(id.into(), format!("no object with id {}", gone.0)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Auto-size frames fit the text the command changed, in the same undo step.
|
||||
fn autosize_changed(&mut self, spec: &CommandSpec, before: Option<&(u64, Arc<Document>, Selection)>) {
|
||||
if spec.undoable
|
||||
&& let Some(st) = self.active_mut()
|
||||
{
|
||||
let prev = before.as_ref().filter(|(uid, _, _)| *uid == st.uid).map(|(_, d, _)| d.as_ref());
|
||||
let prev = before.filter(|(uid, _, _)| *uid == st.uid).map(|(_, d, _)| d.as_ref());
|
||||
if prev.is_none_or(|p| !std::ptr::eq(p, st.doc.as_ref())) {
|
||||
let todo = autosize::candidates(&st.doc, prev);
|
||||
if !todo.is_empty() {
|
||||
@@ -614,13 +631,6 @@ 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()));
|
||||
}
|
||||
self.discard_stale_find_scope();
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Drops selected guides the command `id` made stale, against the document as it will be
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1651,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);
|
||||
}
|
||||
@@ -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.
@@ -207,7 +207,9 @@ fn failed_layer_rewrite_aborts_export_instead_of_revealing_hidden_content() {
|
||||
session.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap();
|
||||
let placed =
|
||||
session.execute("file.place", &json!({"base64": base64_encode(&bytes), "name": "missing-layers.pdf", "x": 0, "y": 0, "width": 100})).unwrap();
|
||||
session.execute("object.layerOptions", &json!({"ids": [placed["id"]], "hidden": ["Red"]})).unwrap();
|
||||
// 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");
|
||||
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.
+34
-26
@@ -785,6 +785,39 @@ impl<'a, 'f> StoryWriter<'a, 'f> {
|
||||
close_blocks(&mut list, &mut pre, out);
|
||||
}
|
||||
|
||||
/// The markup for one run's text `raw` (starting at byte `a` of the story): anchored objects
|
||||
/// and notes where their marks are.
|
||||
fn run_markup(&mut self, st: &Story, raw: &str, a: usize, depth: usize, after: &mut String) -> String {
|
||||
let doc = self.doc;
|
||||
let mut t = String::new();
|
||||
let mut seg_start = 0;
|
||||
// Index of the next anchored object: marks count from the story start, as
|
||||
// `Story::object_at` counts them (counted once, at the run's first mark).
|
||||
let mut mark: Option<usize> = None;
|
||||
for (i, c) in raw.char_indices() {
|
||||
if c != FOOTNOTE_REF && c != ENDNOTE_REF && c != OBJECT_MARK {
|
||||
continue;
|
||||
}
|
||||
t.push_str(&run_text(doc, raw.get(seg_start..i).unwrap_or_default()));
|
||||
seg_start = i + c.len_utf8();
|
||||
if c == OBJECT_MARK {
|
||||
let k = mark.unwrap_or_else(|| st.text.get(..a).map_or(0, |t| t.matches(OBJECT_MARK).count()));
|
||||
mark = Some(k.saturating_add(1));
|
||||
if let Some(o) = st.objects.get(k) {
|
||||
t.push_str(&self.object(o));
|
||||
}
|
||||
} else if depth < MAX_DEPTH {
|
||||
if c == FOOTNOTE_REF {
|
||||
self.footnote(st, a + i, depth, &mut t, after);
|
||||
} else {
|
||||
self.endnote(st, a + i, depth, &mut t);
|
||||
}
|
||||
}
|
||||
}
|
||||
t.push_str(&run_text(doc, raw.get(seg_start..).unwrap_or_default()));
|
||||
t
|
||||
}
|
||||
|
||||
/// The runs of paragraph `para` of `st`; footnotes go to `after`.
|
||||
fn runs(&mut self, st: &Story, para: std::ops::Range<usize>, depth: usize, out: &mut String, after: &mut String) {
|
||||
let doc = self.doc;
|
||||
@@ -799,32 +832,7 @@ impl<'a, 'f> StoryWriter<'a, 'f> {
|
||||
continue;
|
||||
}
|
||||
let Some(raw) = st.text.get(a..b) else { continue };
|
||||
let mut t = String::new();
|
||||
let mut seg_start = 0;
|
||||
// Index of the next anchored object: marks count from the story start, as
|
||||
// `Story::object_at` counts them (counted once, at the run's first mark).
|
||||
let mut mark: Option<usize> = None;
|
||||
for (i, c) in raw.char_indices() {
|
||||
if c != FOOTNOTE_REF && c != ENDNOTE_REF && c != OBJECT_MARK {
|
||||
continue;
|
||||
}
|
||||
t.push_str(&run_text(doc, raw.get(seg_start..i).unwrap_or_default()));
|
||||
seg_start = i + c.len_utf8();
|
||||
if c == OBJECT_MARK {
|
||||
let k = mark.unwrap_or_else(|| st.text.get(..a).map_or(0, |t| t.matches(OBJECT_MARK).count()));
|
||||
mark = Some(k.saturating_add(1));
|
||||
if let Some(o) = st.objects.get(k) {
|
||||
t.push_str(&self.object(o));
|
||||
}
|
||||
} else if depth < MAX_DEPTH {
|
||||
if c == FOOTNOTE_REF {
|
||||
self.footnote(st, a + i, depth, &mut t, after);
|
||||
} else {
|
||||
self.endnote(st, a + i, depth, &mut t);
|
||||
}
|
||||
}
|
||||
}
|
||||
t.push_str(&run_text(doc, raw.get(seg_start..).unwrap_or_default()));
|
||||
let t = self.run_markup(st, raw, a, depth, after);
|
||||
let (ctag, cls) = if f.style != story::NO_CHAR_STYLE {
|
||||
let (tag, class) = export_mapping(&doc.styles, &f.style, true, &self.cc);
|
||||
(tag, format!(" class=\"{}\"", xml_attr(&class)))
|
||||
|
||||
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}"))?;
|
||||
|
||||
+34
-34
@@ -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};
|
||||
@@ -148,12 +148,12 @@ pub struct FaceRef(&'static FontFace);
|
||||
impl FaceRef {
|
||||
/// The handle for a loaded face (memoised per face).
|
||||
pub fn of(face: &Arc<FontFace>) -> FaceRef {
|
||||
static LEAKED: RwLock<Vec<Option<&'static FontFace>>> = RwLock::new(Vec::new());
|
||||
static LEAKED: RwLock<Vec<Option<&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);
|
||||
}
|
||||
@@ -579,7 +579,7 @@ fn script_chain(c: char) -> Option<&'static [&'static str]> {
|
||||
/// The Japanese fallback chain: the craft-fonts Japanese faces' families in document order
|
||||
/// (Mincho first; none without craft-fonts), then [`JAPANESE_FALLBACKS`].
|
||||
fn japanese_chain() -> &'static [&'static str] {
|
||||
static CHAIN: std::sync::OnceLock<Vec<&'static str>> = std::sync::OnceLock::new();
|
||||
static CHAIN: std::sync::OnceLock<Vec<&str>> = std::sync::OnceLock::new();
|
||||
CHAIN.get_or_init(|| {
|
||||
let mut chain: Vec<&'static str> = Vec::new();
|
||||
for f in crate::japanese_document_fonts() {
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1314,7 +1314,7 @@ impl FontDb {
|
||||
stack.push(p);
|
||||
continue;
|
||||
}
|
||||
let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
|
||||
let ext = p.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase);
|
||||
if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) {
|
||||
continue;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1399,7 +1399,7 @@ impl FontDb {
|
||||
let Ok(entries) = std::fs::read_dir(folder) else { return out };
|
||||
let mut files: Vec<std::path::PathBuf> = Vec::new();
|
||||
for path in entries.flatten().map(|e| e.path()) {
|
||||
let ext = path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
|
||||
let ext = path.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase);
|
||||
if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) {
|
||||
continue;
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -5,12 +5,9 @@ use std::collections::HashMap;
|
||||
|
||||
use crate::Point;
|
||||
|
||||
/// Closed outlines around the `true` cells of a `w`×`h` mask (row-major), simplified to within
|
||||
/// `tol` pixels; outlines enclosing less than `min_area` pixels are dropped. Holes come out
|
||||
/// with the opposite winding.
|
||||
pub fn trace(mask: &[bool], w: usize, h: usize, tol: f64, min_area: f64) -> Vec<Vec<Point>> {
|
||||
/// Pixel-border edges of the mask with the inside on the right (clockwise, y down), by start point.
|
||||
fn border_edges(mask: &[bool], w: usize, h: usize) -> HashMap<(isize, isize), Vec<(isize, isize)>> {
|
||||
let inside = |x: isize, y: isize| x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h && mask[y as usize * w + x as usize];
|
||||
// Pixel-border edges with the inside on the right (clockwise, y down).
|
||||
let mut next: HashMap<(isize, isize), Vec<(isize, isize)>> = HashMap::new();
|
||||
let mut add = |a: (isize, isize), b: (isize, isize)| next.entry(a).or_default().push(b);
|
||||
for y in 0..h as isize {
|
||||
@@ -32,16 +29,24 @@ pub fn trace(mask: &[bool], w: usize, h: usize, tol: f64, min_area: f64) -> Vec<
|
||||
}
|
||||
}
|
||||
}
|
||||
next
|
||||
}
|
||||
|
||||
/// Closed outlines around the `true` cells of a `w`×`h` mask (row-major), simplified to within
|
||||
/// `tol` pixels; outlines enclosing less than `min_area` pixels are dropped. Holes come out
|
||||
/// with the opposite winding.
|
||||
pub fn trace(mask: &[bool], w: usize, h: usize, tol: f64, min_area: f64) -> Vec<Vec<Point>> {
|
||||
let mut next = border_edges(mask, w, h);
|
||||
let mut out = Vec::new();
|
||||
let mut starts: Vec<(isize, isize)> = next.keys().copied().collect();
|
||||
starts.sort();
|
||||
for s in starts {
|
||||
while let Some(first) = next.get_mut(&s).and_then(|v| v.pop()) {
|
||||
while let Some(first) = next.get_mut(&s).and_then(Vec::pop) {
|
||||
let mut ring = vec![s];
|
||||
let mut cur = first;
|
||||
while cur != s {
|
||||
ring.push(cur);
|
||||
match next.get_mut(&cur).and_then(|v| v.pop()) {
|
||||
match next.get_mut(&cur).and_then(Vec::pop) {
|
||||
Some(n) => cur = n,
|
||||
None => break,
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+128
-74
@@ -177,7 +177,7 @@ impl<'a> Ex<'a> {
|
||||
}
|
||||
|
||||
fn assign_swatch_ids(&mut self) {
|
||||
let mut used: HashSet<String> = ["Swatch/None", "Color/Paper", "Color/Black", "Color/Registration"].iter().map(|s| s.to_string()).collect();
|
||||
let mut used: HashSet<String> = ["Swatch/None", "Color/Paper", "Color/Black", "Color/Registration"].iter().map(ToString::to_string).collect();
|
||||
self.swatch_ids.insert(swatch::NONE.into(), "Swatch/None".into());
|
||||
self.swatch_ids.insert(swatch::PAPER.into(), "Color/Paper".into());
|
||||
self.swatch_ids.insert(swatch::BLACK.into(), "Color/Black".into());
|
||||
@@ -309,28 +309,7 @@ impl<'a> Ex<'a> {
|
||||
);
|
||||
}
|
||||
for t in &d.styles.mojikumi_tables {
|
||||
let mut e = El::new("MojikumiTable").attr("Self", format!("MojikumiTable/{}", escape_id(&t.name))).attr("Name", &t.name);
|
||||
if !t.based_on.is_empty() {
|
||||
e.set("BasedOnMojikumiSet", &t.based_on);
|
||||
}
|
||||
if !t.overrides.is_empty() {
|
||||
let mut list = El::new("OverrideMojikumiAkiList");
|
||||
for r in &t.overrides {
|
||||
list.push(
|
||||
El::new("OverrideMojikumiAkiType")
|
||||
.attr("TargetMojikumiClass", r.target_class)
|
||||
.attr("SideMojikumiClass", r.side_class)
|
||||
.attr("SideIsAfterTarget", bool_s(r.after))
|
||||
.attr("Minimum", num(r.minimum))
|
||||
.attr("Desired", num(r.desired))
|
||||
.attr("Maximum", num(r.maximum))
|
||||
.attr("CompressionPriority", r.priority)
|
||||
.attr("AkiDoesNotFloat", bool_s(r.does_not_float)),
|
||||
);
|
||||
}
|
||||
e.push(El::new("Properties").child(list));
|
||||
}
|
||||
root.push(e);
|
||||
root.push(mojikumi_table_el(t));
|
||||
}
|
||||
root.push(El::new("idPkg:Graphic").attr("src", "Resources/Graphic.xml"));
|
||||
root.push(El::new("idPkg:Fonts").attr("src", "Resources/Fonts.xml"));
|
||||
@@ -364,28 +343,7 @@ impl<'a> Ex<'a> {
|
||||
}
|
||||
// IDML lists layers back to front.
|
||||
for l in d.layers.iter().rev() {
|
||||
let color = match names::layer_color_out(l.color) {
|
||||
Some(n) => p("LayerColor", "enumeration", n),
|
||||
None => {
|
||||
let mut e = El::new("LayerColor").attr("type", "list");
|
||||
for c in l.color {
|
||||
e.push(p("ListItem", "double", num(c as f64)));
|
||||
}
|
||||
e
|
||||
}
|
||||
};
|
||||
let el = El::new("Layer")
|
||||
.attr("Self", uid(l.id.0))
|
||||
.attr("Name", &l.name)
|
||||
.attr("Visible", bool_s(l.visible))
|
||||
.attr("Locked", bool_s(l.locked))
|
||||
.attr("IgnoreWrap", bool_s(l.suppress_wrap_when_hidden))
|
||||
.attr("ShowGuides", bool_s(l.show_guides))
|
||||
.attr("LockGuides", "false")
|
||||
.attr("UI", "true")
|
||||
.attr("Expendable", "true")
|
||||
.attr("Printable", bool_s(l.printable));
|
||||
root.push(with_props(el, vec![color]));
|
||||
root.push(layer_el(l));
|
||||
}
|
||||
for (name, _) in masters {
|
||||
root.push(El::new("idPkg:MasterSpread").attr("src", name));
|
||||
@@ -1828,33 +1786,7 @@ impl<'a> Ex<'a> {
|
||||
.attr("ItemTransform", format!("1 0 0 1 {} {}", num(pg.x - ox), num(-oy)))
|
||||
.child(margin_el(pg));
|
||||
let mut pe = pe;
|
||||
// Ruler guides, measured from the ruler origin written in the preferences: the
|
||||
// spread's top left, the page's, or the spine (the import reads them the same way).
|
||||
let left = sp.pages.iter().map(|p| p.x).fold(f64::INFINITY, f64::min);
|
||||
let left = if left.is_finite() { left } else { 0.0 };
|
||||
let spine = sp.pages.iter().map(|p| p.x - left).filter(|x| *x > 1e-6).fold(f64::INFINITY, f64::min);
|
||||
let spine = if spine.is_finite() && facing { spine } else { 0.0 };
|
||||
let origin_x = match d.settings.ruler_origin {
|
||||
designcraft_doc::RulerOrigin::Spread => left,
|
||||
designcraft_doc::RulerOrigin::Page => pg.x,
|
||||
designcraft_doc::RulerOrigin::Spine => left + spine,
|
||||
};
|
||||
for g in &pg.guides {
|
||||
let vertical = g.orientation == designcraft_doc::Orientation::Vertical;
|
||||
let mut ge = El::new("Guide")
|
||||
.attr("Self", format!("{}g{}", uid(pg.id.0), pe.elements().count()))
|
||||
.attr("Orientation", if vertical { "Vertical" } else { "Horizontal" })
|
||||
.attr("Location", num(if vertical { g.position - origin_x } else { g.position }))
|
||||
.attr("FitToPage", bool_s(!g.spread))
|
||||
.attr("ViewThreshold", "5")
|
||||
.attr("Locked", bool_s(g.locked))
|
||||
.attr("PageIndex", (first_page + i).to_string())
|
||||
.attr("GuideType", if g.liquid { "Liquid" } else { "Ruler" });
|
||||
if let Some(l) = g.layer {
|
||||
ge.set("ItemLayer", uid(l.0));
|
||||
}
|
||||
pe.push(ge);
|
||||
}
|
||||
push_page_guides(d, sp, pg, &mut pe, first_page + i);
|
||||
el.push(pe);
|
||||
}
|
||||
let origin = Affine::translate((-ox, -oy));
|
||||
@@ -2414,6 +2346,7 @@ impl<'a> Ex<'a> {
|
||||
object_point,
|
||||
ref_point,
|
||||
keep_within_column,
|
||||
spine_relative,
|
||||
} => El::new("AnchoredObjectSetting")
|
||||
.attr("AnchoredPosition", "Anchored")
|
||||
.attr("AnchorPoint", names::anchor_out(*object_point))
|
||||
@@ -2424,7 +2357,8 @@ impl<'a> Ex<'a> {
|
||||
// IDML measures a left-aligned object's X offset leftwards.
|
||||
.attr("AnchorXoffset", num(if *ref_point % 3 == 0 { 0.0 - *x_offset } else { *x_offset }))
|
||||
.attr("AnchorYoffset", num(*y_offset))
|
||||
.attr("PinPosition", bool_s(*keep_within_column)),
|
||||
.attr("PinPosition", bool_s(*keep_within_column))
|
||||
.attr("SpineRelative", bool_s(*spine_relative)),
|
||||
};
|
||||
e.push(setting);
|
||||
out.push(Node::El(e));
|
||||
@@ -2797,7 +2731,25 @@ impl<'a> Ex<'a> {
|
||||
ce.set(&format!("{side}StrokePriority"), priority);
|
||||
}
|
||||
}
|
||||
for psr in self.story_paras(&cell.text) {
|
||||
let mut paras = self.story_paras(&cell.text);
|
||||
// A graphic cell holds its image as an inline object in the cell's first paragraph.
|
||||
if let Some(g) = &cell.graphic
|
||||
&& let Some(object) = self.cell_graphic_el(g)
|
||||
{
|
||||
// The cell clips its graphic on screen and in PDF; IDML does so only when asked.
|
||||
ce.set("ClipContentToCell", bool_s(true));
|
||||
let mut range = self.csr_el(&CharFormat::default());
|
||||
range.push(object);
|
||||
match paras.first_mut() {
|
||||
Some(first) => first.push(range),
|
||||
None => {
|
||||
let mut psr = self.psr_el(&ParaFormat::default());
|
||||
psr.push(range);
|
||||
paras.push(psr);
|
||||
}
|
||||
}
|
||||
}
|
||||
for psr in paras {
|
||||
ce.push(psr);
|
||||
}
|
||||
el.push(ce);
|
||||
@@ -2806,6 +2758,20 @@ impl<'a> Ex<'a> {
|
||||
el
|
||||
}
|
||||
|
||||
/// The frame of a table cell's graphic: an inline `Rectangle` around the shown image, or `None`
|
||||
/// when the graphic's asset is missing.
|
||||
fn cell_graphic_el(&mut self, g: &designcraft_doc::Graphic) -> Option<El> {
|
||||
self.d.assets.get(&g.asset)?;
|
||||
let shown = g.xf.transform_rect_bbox(designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1));
|
||||
let layer = self.d.layers.first().map(|l| l.id).unwrap_or(designcraft_doc::ids::LayerId(0));
|
||||
let mut item = Item::new(designcraft_doc::ids::ItemId(0), layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(shown));
|
||||
item.content = Content::Graphic(g.clone());
|
||||
let mut el = self.item_el(&item, Affine::IDENTITY);
|
||||
el.set("Self", self.fresh());
|
||||
el.push(El::new("AnchoredObjectSetting").attr("AnchoredPosition", "InlinePosition").attr("AnchorYoffset", "0"));
|
||||
Some(el)
|
||||
}
|
||||
|
||||
fn psr_el(&mut self, pf: &ParaFormat) -> El {
|
||||
let mut el = El::new("ParagraphStyleRange").attr("AppliedParagraphStyle", names::style_self("ParagraphStyle", PARA_BUILTINS, &pf.style));
|
||||
let mut props = Vec::new();
|
||||
@@ -3163,6 +3129,90 @@ fn metadata_xmp(d: &Document) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// The page's ruler guides, measured from the ruler origin written in the preferences.
|
||||
fn push_page_guides(d: &Document, sp: &Spread, pg: &designcraft_doc::Page, pe: &mut El, page_index: usize) {
|
||||
let facing = d.settings.facing_pages;
|
||||
// Ruler guides, measured from the ruler origin written in the preferences: the
|
||||
// spread's top left, the page's, or the spine (the import reads them the same way).
|
||||
let left = sp.pages.iter().map(|p| p.x).fold(f64::INFINITY, f64::min);
|
||||
let left = if left.is_finite() { left } else { 0.0 };
|
||||
let spine = sp.pages.iter().map(|p| p.x - left).filter(|x| *x > 1e-6).fold(f64::INFINITY, f64::min);
|
||||
let spine = if spine.is_finite() && facing { spine } else { 0.0 };
|
||||
let origin_x = match d.settings.ruler_origin {
|
||||
designcraft_doc::RulerOrigin::Spread => left,
|
||||
designcraft_doc::RulerOrigin::Page => pg.x,
|
||||
designcraft_doc::RulerOrigin::Spine => left + spine,
|
||||
};
|
||||
for g in &pg.guides {
|
||||
let vertical = g.orientation == designcraft_doc::Orientation::Vertical;
|
||||
let mut ge = El::new("Guide")
|
||||
.attr("Self", format!("{}g{}", uid(pg.id.0), pe.elements().count()))
|
||||
.attr("Orientation", if vertical { "Vertical" } else { "Horizontal" })
|
||||
.attr("Location", num(if vertical { g.position - origin_x } else { g.position }))
|
||||
.attr("FitToPage", bool_s(!g.spread))
|
||||
.attr("ViewThreshold", "5")
|
||||
.attr("Locked", bool_s(g.locked))
|
||||
.attr("PageIndex", page_index.to_string())
|
||||
.attr("GuideType", if g.liquid { "Liquid" } else { "Ruler" });
|
||||
if let Some(l) = g.layer {
|
||||
ge.set("ItemLayer", uid(l.0));
|
||||
}
|
||||
pe.push(ge);
|
||||
}
|
||||
}
|
||||
|
||||
/// A `MojikumiTable` element with its spacing overrides.
|
||||
fn mojikumi_table_el(t: &designcraft_doc::cjk::MojikumiTable) -> El {
|
||||
let mut e = El::new("MojikumiTable").attr("Self", format!("MojikumiTable/{}", escape_id(&t.name))).attr("Name", &t.name);
|
||||
if !t.based_on.is_empty() {
|
||||
e.set("BasedOnMojikumiSet", &t.based_on);
|
||||
}
|
||||
if !t.overrides.is_empty() {
|
||||
let mut list = El::new("OverrideMojikumiAkiList");
|
||||
for r in &t.overrides {
|
||||
list.push(
|
||||
El::new("OverrideMojikumiAkiType")
|
||||
.attr("TargetMojikumiClass", r.target_class)
|
||||
.attr("SideMojikumiClass", r.side_class)
|
||||
.attr("SideIsAfterTarget", bool_s(r.after))
|
||||
.attr("Minimum", num(r.minimum))
|
||||
.attr("Desired", num(r.desired))
|
||||
.attr("Maximum", num(r.maximum))
|
||||
.attr("CompressionPriority", r.priority)
|
||||
.attr("AkiDoesNotFloat", bool_s(r.does_not_float)),
|
||||
);
|
||||
}
|
||||
e.push(El::new("Properties").child(list));
|
||||
}
|
||||
e
|
||||
}
|
||||
|
||||
/// A `Layer` element with its colour property.
|
||||
fn layer_el(l: &designcraft_doc::Layer) -> El {
|
||||
let color = match names::layer_color_out(l.color) {
|
||||
Some(n) => p("LayerColor", "enumeration", n),
|
||||
None => {
|
||||
let mut e = El::new("LayerColor").attr("type", "list");
|
||||
for c in l.color {
|
||||
e.push(p("ListItem", "double", num(c as f64)));
|
||||
}
|
||||
e
|
||||
}
|
||||
};
|
||||
let el = El::new("Layer")
|
||||
.attr("Self", uid(l.id.0))
|
||||
.attr("Name", &l.name)
|
||||
.attr("Visible", bool_s(l.visible))
|
||||
.attr("Locked", bool_s(l.locked))
|
||||
.attr("IgnoreWrap", bool_s(l.suppress_wrap_when_hidden))
|
||||
.attr("ShowGuides", bool_s(l.show_guides))
|
||||
.attr("LockGuides", "false")
|
||||
.attr("UI", "true")
|
||||
.attr("Expendable", "true")
|
||||
.attr("Printable", bool_s(l.printable));
|
||||
with_props(el, vec![color])
|
||||
}
|
||||
|
||||
/// A text variable definition with its type's settings element.
|
||||
fn text_variable_el(v: &designcraft_doc::vars::TextVariable) -> El {
|
||||
use designcraft_doc::vars::{Use, VarKind};
|
||||
@@ -3304,6 +3354,10 @@ impl Ex<'_> {
|
||||
if let Some(v) = a.kenten_overprint_stroke {
|
||||
el.set("KentenOverprintStroke", crate::cjk::overprint_out(v));
|
||||
}
|
||||
self.shatai_chars(el, a);
|
||||
}
|
||||
|
||||
fn shatai_chars(&self, el: &mut El, a: &CharAttrs) {
|
||||
if let Some(v) = a.shatai_magnification {
|
||||
el.set("ShataiMagnification", num(v));
|
||||
}
|
||||
|
||||
+70
-33
@@ -295,6 +295,10 @@ struct Importer<'r> {
|
||||
parents: Vec<Spread>,
|
||||
/// Things read but not kept as they were, for the import report.
|
||||
warnings: Vec<String>,
|
||||
/// Cells whose text is rotated (Cell Options › Text › Rotation): the angle is kept and
|
||||
/// written back, but only 0 is composed, so a narrow header column of vertical labels comes
|
||||
/// in horizontal and wraps mid-word (#1468).
|
||||
rotated_cells: usize,
|
||||
parent_ids: HashMap<String, SpreadId>,
|
||||
spreads: Vec<Spread>,
|
||||
page_index: HashMap<String, usize>,
|
||||
@@ -387,13 +391,10 @@ fn mojikumi_row(e: &El) -> Result<designcraft_doc::cjk::MojikumiAki> {
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
/// The numbers in a whitespace-separated list; empty when any number is NaN or infinite (JSON
|
||||
/// can't store them, and dropping just those would shift the rest).
|
||||
fn nums(s: &str) -> Vec<f64> {
|
||||
s.split_whitespace().filter_map(|v| v.parse().ok()).collect()
|
||||
}
|
||||
|
||||
/// Like [`nums`], but empty when any number is NaN or infinite.
|
||||
fn finite_nums(s: &str) -> Vec<f64> {
|
||||
let v = nums(s);
|
||||
let v: Vec<f64> = s.split_whitespace().filter_map(|v| v.parse().ok()).collect();
|
||||
if v.iter().all(|x| x.is_finite()) { v } else { Vec::new() }
|
||||
}
|
||||
|
||||
@@ -536,6 +537,7 @@ impl<'r> Importer<'r> {
|
||||
story_ids: HashMap::new(),
|
||||
parents: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
rotated_cells: 0,
|
||||
parent_ids: HashMap::new(),
|
||||
spreads: Vec::new(),
|
||||
page_index: HashMap::new(),
|
||||
@@ -791,6 +793,12 @@ impl<'r> Importer<'r> {
|
||||
include_prefix: e.get("IncludeSectionPrefix") == Some("true"),
|
||||
});
|
||||
}
|
||||
if self.rotated_cells > 0 {
|
||||
let n = self.rotated_cells;
|
||||
self.warnings.push(format!(
|
||||
"{n} table cell(s) have rotated text (Cell Options › Text › Rotation): the angle is kept and written back, but the text is composed horizontally"
|
||||
));
|
||||
}
|
||||
self.sections.sort_by_key(|s| s.start);
|
||||
self.sections.dedup_by_key(|s| s.start);
|
||||
if self.sections.first().is_none_or(|s| s.start != 0) {
|
||||
@@ -895,6 +903,15 @@ impl<'r> Importer<'r> {
|
||||
_ if v.len() >= 4 => Color::cmyk((v[0] / 100.0) as f32, (v[1] / 100.0) as f32, (v[2] / 100.0) as f32, (v[3] / 100.0) as f32),
|
||||
_ => return None,
|
||||
};
|
||||
// Huge values (1e300) overflow `f32` to infinity.
|
||||
let parts = match c {
|
||||
Color::Rgb { r, g, b } => [r, g, b, 0.0],
|
||||
Color::Cmyk { c, m, y, k } => [c, m, y, k],
|
||||
Color::Gray { k } => [k, 0.0, 0.0, 0.0],
|
||||
};
|
||||
if !parts.iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let ty = if e.get("Model") == Some("Spot") { ColorType::Spot } else { ColorType::Process };
|
||||
Some((c, ty))
|
||||
}
|
||||
@@ -947,14 +964,14 @@ impl<'r> Importer<'r> {
|
||||
let (Some(id), Some(name)) = (e.get("Self"), e.get("Name")) else { continue };
|
||||
let kind = match e.local() {
|
||||
"StripedStrokeStyle" => designcraft_doc::StrokeType::Stripes {
|
||||
bands: finite_nums(e.get("StripeArray").unwrap_or(""))
|
||||
bands: nums(e.get("StripeArray").unwrap_or(""))
|
||||
.as_chunks::<2>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|c| (c[0] / 100.0, (c[1] - c[0]) / 100.0))
|
||||
.collect(),
|
||||
},
|
||||
"DashedStrokeStyle" => designcraft_doc::StrokeType::Dashed { pattern: finite_nums(e.get("DashArray").unwrap_or("")) },
|
||||
"DashedStrokeStyle" => designcraft_doc::StrokeType::Dashed { pattern: nums(e.get("DashArray").unwrap_or("")) },
|
||||
_ => designcraft_doc::StrokeType::Dotted,
|
||||
};
|
||||
let _ = id;
|
||||
@@ -1624,28 +1641,7 @@ impl<'r> Importer<'r> {
|
||||
a.strikethrough_tint = tint;
|
||||
}
|
||||
}
|
||||
// OpenType features (only when the element sets any).
|
||||
let mut otf_list: Vec<String> = Vec::new();
|
||||
let mut any = false;
|
||||
for (tag, _, attr) in designcraft_doc::otf::TOGGLES {
|
||||
if let Some(on) = e.boolean(attr) {
|
||||
designcraft_doc::otf::set(&mut otf_list, tag, on);
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
if let Some(fig) = e.prop("OTFFigureStyle")
|
||||
&& let Some((id, ..)) = designcraft_doc::otf::FIGURES.iter().find(|f| f.3 == fig.trim())
|
||||
{
|
||||
designcraft_doc::otf::set_figures(&mut otf_list, id);
|
||||
any = true;
|
||||
}
|
||||
if let Some(m) = e.num("OTFStylisticSets") {
|
||||
designcraft_doc::otf::set_stylistic_sets(&mut otf_list, m.max(0.0) as u32);
|
||||
any = true;
|
||||
}
|
||||
if any {
|
||||
a.otf_features = Some(otf_list);
|
||||
}
|
||||
read_otf_features(e, &mut a);
|
||||
a.glyph_form = e.prop("GlyphForm");
|
||||
a.no_break = e.boolean("NoBreak");
|
||||
a.tate_chu_yoko = e.boolean("Tatechuyoko");
|
||||
@@ -2170,9 +2166,12 @@ impl<'r> Importer<'r> {
|
||||
}
|
||||
// Table-local options override the named table style. Cell styles are already
|
||||
// materialized independently, so their edges retain their higher precedence.
|
||||
let mut border_unset = style.border.is_none() && style.borders.iter().all(|b| b.is_empty());
|
||||
let mut borders = std::array::from_fn::<_, 4, _>(|i| t.options.border_for(i).clone());
|
||||
for (side, border) in ["TopBorder", "LeftBorder", "BottomBorder", "RightBorder"].iter().zip(&mut borders) {
|
||||
self.cell_stroke_attrs(e, side).apply_to(border);
|
||||
let attrs = self.cell_stroke_attrs(e, side);
|
||||
border_unset &= attrs.is_empty();
|
||||
attrs.apply_to(border);
|
||||
}
|
||||
t.options.border = borders[0].clone();
|
||||
t.options.borders = borders.map(Some);
|
||||
@@ -2235,6 +2234,9 @@ impl<'r> Importer<'r> {
|
||||
cell.first_baseline_min = min.clamp(0.0, 8640.0);
|
||||
}
|
||||
cell.rotation = ce.num("RotationAngle").unwrap_or(cell.rotation);
|
||||
if cell.rotation != 0.0 {
|
||||
self.rotated_cells += 1;
|
||||
}
|
||||
for (i, side) in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].iter().enumerate() {
|
||||
let attrs = self.cell_stroke_attrs(ce, side);
|
||||
attrs.apply_to(&mut cell.strokes[i]);
|
||||
@@ -2243,6 +2245,10 @@ impl<'r> Importer<'r> {
|
||||
cell.stroke_priorities[i] = priority;
|
||||
// Preserve style-defined edges and #102's positive-priority local overrides.
|
||||
cell.border_overrides[i] |= priority > 0;
|
||||
// No table or table style border is given anywhere, so the built-in 1 pt default must not
|
||||
// draw over an outer edge the cell explicitly hides (weight 0 or the None swatch), as InDesign draws nothing.
|
||||
let hidden = attrs.weight == Some(0.0) || attrs.color.as_deref() == Some(swatch::NONE);
|
||||
cell.border_overrides[i] |= border_unset && hidden;
|
||||
}
|
||||
if let Some(slot) = t.cell_mut(r, c) {
|
||||
*slot = cell;
|
||||
@@ -2422,6 +2428,7 @@ impl<'r> Importer<'r> {
|
||||
object_point: names::anchor_in(s("AnchorPoint")),
|
||||
ref_point: row * 3 + col,
|
||||
keep_within_column: s("PinPosition") == "true",
|
||||
spine_relative: s("SpineRelative") == "true",
|
||||
}
|
||||
}
|
||||
_ => designcraft_doc::AnchorPosition::Inline { y_offset: get("AnchorYoffset") },
|
||||
@@ -2856,7 +2863,7 @@ impl<'r> Importer<'r> {
|
||||
{
|
||||
it.fill.gradient_angle = Some(a);
|
||||
}
|
||||
let start: Option<Vec<f64>> = e.prop("GradientFillStart").map(|s| finite_nums(&s));
|
||||
let start: Option<Vec<f64>> = e.prop("GradientFillStart").map(|s| nums(&s));
|
||||
if let (Some([x, y]), Some(len)) = (start.as_deref().and_then(|v| <[f64; 2]>::try_from(v).ok()), e.num("GradientFillLength"))
|
||||
&& len > 0.0
|
||||
{
|
||||
@@ -2874,7 +2881,7 @@ impl<'r> Importer<'r> {
|
||||
{
|
||||
it.stroke.gradient_angle = Some(a);
|
||||
}
|
||||
let start: Option<Vec<f64>> = e.prop("GradientStrokeStart").map(|s| finite_nums(&s));
|
||||
let start: Option<Vec<f64>> = e.prop("GradientStrokeStart").map(|s| nums(&s));
|
||||
if let (Some([x, y]), Some(len)) = (start.as_deref().and_then(|v| <[f64; 2]>::try_from(v).ok()), e.num("GradientStrokeLength"))
|
||||
&& len > 0.0
|
||||
{
|
||||
@@ -3702,6 +3709,11 @@ fn path_capped(pg: &El, max: usize) -> PathData {
|
||||
let closed = g.get("PathOpen") != Some("true");
|
||||
let mut anchors = Vec::new();
|
||||
if let Some(arr) = g.find("PathPointArray") {
|
||||
// A NaN or infinite point drops its subpath (JSON can't store it).
|
||||
let non_finite = |s: Option<&str>| s.is_some_and(|s| s.split_whitespace().any(|v| v.parse::<f64>().is_ok_and(|x| !x.is_finite())));
|
||||
if arr.find_all("PathPointType").take(left).any(|p| ["Anchor", "LeftDirection", "RightDirection"].iter().any(|k| non_finite(p.get(k)))) {
|
||||
continue;
|
||||
}
|
||||
for p in arr.find_all("PathPointType").take(left) {
|
||||
let Some(a) = parse_point(p.get("Anchor")) else { continue };
|
||||
let hin = parse_point(p.get("LeftDirection")).unwrap_or(a);
|
||||
@@ -3856,6 +3868,31 @@ impl Importer<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// OpenType features (only when the element sets any).
|
||||
fn read_otf_features(e: &El, a: &mut CharAttrs) {
|
||||
let mut otf_list: Vec<String> = Vec::new();
|
||||
let mut any = false;
|
||||
for (tag, _, attr) in designcraft_doc::otf::TOGGLES {
|
||||
if let Some(on) = e.boolean(attr) {
|
||||
designcraft_doc::otf::set(&mut otf_list, tag, on);
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
if let Some(fig) = e.prop("OTFFigureStyle")
|
||||
&& let Some((id, ..)) = designcraft_doc::otf::FIGURES.iter().find(|f| f.3 == fig.trim())
|
||||
{
|
||||
designcraft_doc::otf::set_figures(&mut otf_list, id);
|
||||
any = true;
|
||||
}
|
||||
if let Some(m) = e.num("OTFStylisticSets") {
|
||||
designcraft_doc::otf::set_stylistic_sets(&mut otf_list, m.max(0.0) as u32);
|
||||
any = true;
|
||||
}
|
||||
if any {
|
||||
a.otf_features = Some(otf_list);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod numeric_property_tests {
|
||||
use super::*;
|
||||
|
||||
Loaded 100 of 194 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user