diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5b820d24..d0d1fd873 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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,58 +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 - uses: storytold/craft-common-actions/actions/install-apt-packages@main - with: - packages: >- - pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev - libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers - - - name: Install Rust (stable + clippy, rustfmt, wasm32) - run: | - rustup toolchain install stable --profile minimal --component clippy,rustfmt \ - --target wasm32-unknown-unknown - rustup default stable - - - uses: taiki-e/install-action@f7e5d7c961414b23f5b25b2da9294395d08513ad # v2.87.26 - with: - tool: cargo-nextest@0.9.145 - - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - save-if: ${{ github.ref == 'refs/heads/main' }} - - - name: Quality gate - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - run: cargo xtask ci + # 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 diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index e60c2bdf3..d6b897cec 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 57eb9bb53..f65960f27 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index ecee45a6d..e78fe1ea3 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -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: diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index ed7060b99..85856800b 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -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: diff --git a/Cargo.lock b/Cargo.lock index e75e8d5a5..beb1f6f8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 8a83e0463..0245708f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } @@ -122,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" } diff --git a/NOTICE b/NOTICE index f1e997156..71abff776 100644 --- a/NOTICE +++ b/NOTICE @@ -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. diff --git a/ROADMAP.md b/ROADMAP.md index 588c46923..6c3e4d531 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | |---|---|---|---| @@ -349,18 +359,42 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. - **2026-10-23** · `designcraft-cli mcp` takes `--automation-read-root` and `--automation-write-root` more than once, as the suite's other apps do (#1552): a path inside any root of its kind passes, and the roots' common parent, `..` or a link out of them and the other kind's roots stay refused. No readiness percentage change. +- **2026-10-24** · Text frames: a custom baseline grid's Relative To (Text Frame Options › Baseline Options, `object.textFrameOptions {baselineGrid: {relativeTo}}`, IDML `BaselineFrameGridRelativeOption`) measures the grid's start from the top of the page, the top margin, the top of the frame or its top inset. Text Frame Options had no custom baseline grid controls, imports dropped the setting and every frame grid started from the page top, so grid-aligned text in a frame measured from its own top sat on the wrong lines. No readiness percentage change. +- **2026-10-24** · Paragraph Style Options › Indents and Spacing has an Align to Grid menu: None, All Lines or First Line Only. It shows the style's current setting. A First Line Only style could be set only in the CJK Grid Settings section, which shows only with CJK features on, or through commands. No readiness percentage change. +- **2026-10-24** · Anchored text frames, such as marginal notes, compose where their host story places them: they align to the baseline grid at that position, and page-number markers and spine-relative alignment inside them read the host's page. They composed against a grid measured from their stored position and on no page, so a note set to Only Align First Line to Grid could sit a fraction of a line off the body text's grid line. No readiness percentage change. +- **2026-10-24** · New documents start with Hyphenate Capitalized Words, Hyphenate Last Word and Hyphenate Across Column off in [Basic Paragraph], as in InDesign. Opened and imported files keep the values they carry. No readiness percentage change. +- **2026-10-24** · The baseline grid's Relative To setting applies: with Top Margin, the grid starts at each page's own top margin in text composition, on the canvas and for snapping. It started at the page top, so grid-aligned text sat on the wrong lines and a page could fit one line fewer. No readiness percentage change. +- **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-24** · Text frames: a custom baseline grid (Text Frame Options › Baseline Options) from an IDML file sets lines from its start by its increment. The composer read the two values in the other order, so lines landed on the wrong grid. 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** · Hyphenation Settings, as in InDesign: the Paragraph panel menu's Hyphenation… (`app.hyphenationDialog`) sets the selected paragraphs' Hyphenate, Words with at Least, After First, Before Last, Hyphen Limit, Hyphenation Zone, the Better Spacing ↔ Fewer Hyphens slider (InDesign's 0–10 steps), Hyphenate Capitalized Words, Last Word and Across Column, with Preview, as one undo step; Paragraph Style Options › Hyphenation has the same form. The panel menu lists its items in InDesign's order. Replicated from a hyphenation tutorial: the same text in Minion Pro breaks into the same lines as InDesign's with hyphenation on, off, and at Fewer Hyphens. No readiness percentage change. +- **2026-10-22** · Hyphenate Across Column (Hyphenation Settings; IDML HyphenateAcrossColumns) is applied: with it off, a paragraph whose line ending in an automatic hyphen would end a column or frame is re-composed without that hyphenation point, so the word moves on whole, as in InDesign. Opened and imported files keep their own setting, so their layouts don't change. No readiness percentage change. +- **2026-10-22** · The Paragraph panel is laid out as InDesign's: the nine alignment buttons in groups; indent, spacing and drop cap fields by their icons in two columns, with Space Between Paragraphs Using Same Style (Ignore or a space); Shading and Border with the Paragraph Borders and Shading button and colour; Hyphenate with Do Not Align / Align to Baseline Grid. Its panel menu follows InDesign's order: Paragraph and Single-line Composer, Only Align First Line to Grid, Balance Ragged Lines, Optical Margin Alignment, Justification…, Keep Options…, Span Columns…, Hyphenation…, Drop Caps and Nested Styles…, GREP Styles… (these three now open for the selected paragraphs), Paragraph Rules…, Paragraph Borders and Shading…, Bullets and Numbering…, Restart/Continue Numbering, Define Lists… and the paragraph direction. Convert Bullets and Numbering to Text is listed but not available yet. No readiness percentage change. - **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change. - **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change. - **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change. - **2026-10-22** · Paragraph rules: Rule Above's offset is measured from the first line's baseline to the rule's bottom edge, as in InDesign. Rules sat one ascent too high and crossed the previous paragraph's text (reported from an imported INDD document). No readiness percentage change. - **2026-10-21** · Ruler guides are selected like objects (#1282): a click with the Selection tools selects a guide (also where it crosses an object), Shift-click adds or removes one, a new guide from the rulers is selected, a marquee that touches no object selects the guides it crosses, and Edit › Select All Guides (⌥⌘G, `guide.selectAll`) selects every unlocked guide on the spread. Selected guides draw thicker in their layer's color, drag together (all of them shown moving), nudge with the arrow keys and go with Delete, each as one undo step (`guide.select`, `guide.deselect`, `guide.moveBy`, `guide.delete {}`). With one guide selected the Control bar's X (vertical guide) or Y (horizontal guide) field and Properties › Transform show where it is, as the rulers measure, and typing a value moves it there. No readiness percentage change. +- **2026-10-17** · PDF/X-4 export warns about fonts drawing visible text without an embedded + font program, including placed PDF forms; `validate-pdf` reports the font and page (#909). + Invisible OCR text is exempt. Detection only: fonts are not embedded or outlined automatically. + Repeated forms reuse context-aware font findings; a document-wide operation limit and a form-nesting depth limit + report incomplete checks instead of allowing excessive nested-form traversal. + No readiness percentage change. - **2026-10-16** · Selection: drag a column guide to move its gutter, as in InDesign (#1096). Both guides of the gutter move together and keep its width, stop at the neighbouring guides and the margins, and snap to the document grid, guides and objects, or with Shift to the ruler's tick marks; Escape cancels and the drag is one undo step. Locked column guides (the default) are not picked. A document page takes the result as its own columns; on a parent page it passes to the pages that follow it. `layout.moveColumnGuide` does the same over the control channel and MCP. No readiness percentage change. - **2026-10-16** · Layout: autoflow, inserted pages and new parents follow dragged column guides (#1096). Autoflow sets one frame per dragged column and counts the pages it adds from them; Insert Pages and New Parent keep the custom positions with the column count and gutter; a page that moves to the other side of the spine (inserted, deleted or moved pages, Duplicate Spread, a new parent's other page) has them mirrored across its margin box, as InDesign does; `document.inspect` reports each page's gutter, column rects and whether its columns are custom. No readiness percentage change. - **2026-10-16** · Layout: Margins and Columns keeps dragged column guides unless the column count or gutter changes (#1096). The dialog sends every field on OK, so a margin-only change re-divided the columns evenly. Margin changes keep the positions; the columns follow them only while they fit the margin box. No readiness percentage change. - **2026-10-16** · Lock Guides and Lock Column Guides are commands in View ▸ Grids & Guides (`view.lockGuides`, ⌥⌘;, and `view.lockColumnGuides`), so the control channel and MCP reach them. Lock Column Guides is an undo step, is saved with the document, on in new ones, and read from and written to IDML's `ColumnGuideLocked`, which export always wrote as locked (#1096). No readiness percentage change. - **2026-10-16** · IDML: a page keeps column guides that were dragged off centre (InDesign's Custom columns). They were divided evenly on import, so the gutter sprang back to the middle; the imported positions now place the columns, with the canvas guides, snapping and text placement following them, and they are written back on export. Layout › Margins and Columns re-divides them evenly when the count or gutter changes, as InDesign's does. No readiness percentage change. - **2026-10-16** · IDML: custom column positions are checked before they are used (#1096). A set must give two edges per column, ascending from the left margin to the right one; any other (a wrong count, unsorted or non-numeric values, a list longer than 216 columns allow, or positions left behind by a margin or page-size change) is the even split, in the canvas, on import and on export, instead of columns drawn outside the margins. No readiness percentage change. +- **2026-10-21** · Properties › Character and Paragraph: ••• (More Options) expands the section in place, as InDesign's: Character adds vertical and horizontal scale, baseline shift, skew, language and OpenType, and All Caps, Small Caps, Superscript, Subscript, Underline and Strikethrough; Paragraph adds the left, right, first line and last line indents, space before and after, the drop cap's lines and characters, Align to Baseline Grid and Hyphenate. It used to open the Character or Paragraph panel; the language field moved under ••• as in InDesign. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change. +- **2026-10-21** · The text Control panel has InDesign's two modes: A (Character Formatting Controls: font and style, size and leading, All Caps/Superscript/Underline and Small Caps/Subscript/Strikethrough, kerning and tracking, vertical and horizontal scale, baseline shift and skew, character style and language) and ¶ (Paragraph Formatting Controls: the nine alignments, left/first line and right/last line indents, space before and after, the drop cap's lines and characters, paragraph style, Hyphenate and Align to Baseline Grid). It used to show a fixed mix of both. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change. +- **2026-10-20** · With the Type tool, the Control panel's font size, leading and tracking fields have a spinner and a list of common values, as in InDesign (auto leading in parentheses). No readiness percentage change. +- **2026-10-21** · The ¶ (Paragraph Formatting Controls) mode of the text Control panel has InDesign's Bulleted List and Numbered List buttons (with the default hanging indent) and Shading and Border: a checkbox, a button opening Paragraph Borders and Shading on that tab, and the colour. Found in the Learn Skills Daily course, chapter 21. No readiness percentage change. +- **2026-10-21** · The Bulleted and Numbered List buttons (Properties, and Type › Bulleted & Numbered Lists › Apply Bullets / Apply Numbers) work as InDesign's: a paragraph with no indents gets the default hanging indent (0.25 in, −0.25 in), so the text lines up after the bullet instead of at the next default tab stop, and pressing the button again removes the list and that indent (`list.bullets`, `list.numbers`). Found in the Learn Skills Daily course, chapter 21. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. @@ -368,14 +402,8 @@ Newest first. - **2026-10-17** · Object strokes reject malformed types and unknown type fields before changing the document; command help lists every stroke kind and its required fields. No readiness percentage change. - **2026-10-17** · Docs: documented a persistent source CLI install with Cargo, MCP-client PATH setup and bare-name server registration in `docs/mcp.md`. No readiness percentage change. -- **2026-10-16** · Selection: drag a column guide to move its gutter, as in InDesign (#1096). Both guides of the gutter move together and keep its width, stop at the neighbouring guides and the margins, and snap to the document grid, guides and objects, or with Shift to the ruler's tick marks; Escape cancels and the drag is one undo step. Locked column guides (the default) are not picked. A document page takes the result as its own columns; on a parent page it passes to the pages that follow it. `layout.moveColumnGuide` does the same over the control channel and MCP. No readiness percentage change. -- **2026-10-16** · Layout: Margins and Columns keeps dragged column guides unless the column count or gutter changes (#1096). The dialog sends every field on OK, so a margin-only change re-divided the columns evenly. Margin changes keep the positions; the columns follow them only while they fit the margin box. No readiness percentage change. - **2026-10-16** · Layout: autoflow, inserted pages and new parents follow dragged column guides (#1096). Autoflow sets one frame per dragged column and counts the pages it adds from them; Insert Pages and New Parent keep the custom positions with the column count and gutter; `document.inspect` reports each page's gutter, column rects and whether its columns are custom. No readiness percentage change. -- **2026-10-16** · Layout: autoflow, inserted pages and new parents follow dragged column guides (#1096). Autoflow sets one frame per dragged column and counts the pages it adds from them; Insert Pages and New Parent keep the custom positions with the column count and gutter; a page that moves to the other side of the spine (inserted, deleted or moved pages, Duplicate Spread, a new parent's other page) has them mirrored across its margin box, as InDesign does; `document.inspect` reports each page's gutter, column rects and whether its columns are custom. No readiness percentage change. -- **2026-10-16** · IDML: custom column positions are checked before they are used (#1096). A set must give two edges per column, ascending from the left margin to the right one; any other (a wrong count, unsorted or non-numeric values, a list longer than 216 columns allow, or positions left behind by a margin or page-size change) is the even split, in the canvas, on import and on export, instead of columns drawn outside the margins. No readiness percentage change. -- **2026-10-16** · IDML: a page keeps column guides that were dragged off centre (InDesign's Custom columns). They were divided evenly on import, so the gutter sprang back to the middle; the imported positions now place the columns, with the canvas guides, snapping and text placement following them, and they are written back on export. Layout › Margins and Columns re-divides them evenly when the count or gutter changes, as InDesign's does. No readiness percentage change. - **2026-10-16** · Lock Guides and Lock Column Guides are commands in View ▸ Grids & Guides (`view.lockGuides`, ⌥⌘;, and `view.lockColumnGuides`), so the control channel and MCP reach them. Lock Column Guides is saved with the document, on in new ones, and read from and written to IDML's `ColumnGuideLocked`, which export always wrote as locked (#1096). No readiness percentage change. -- **2026-10-16** · Lock Guides and Lock Column Guides are commands in View ▸ Grids & Guides (`view.lockGuides`, ⌥⌘;, and `view.lockColumnGuides`), so the control channel and MCP reach them. Lock Column Guides is an undo step, is saved with the document, on in new ones, and read from and written to IDML's `ColumnGuideLocked`, which export always wrote as locked (#1096). No readiness percentage change. - **2026-10-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change. - **2026-10-16** · Greek text hyphenates. The patterns were never compiled in, so Greek set unhyphenated however narrow the column; `"Greek"`, `"Greek: Greece"`, `el` and `el-GR` all resolve now and ανθρωπος breaks αν-θρω-πος. They are the monotonic patterns (hyph-el-monoton, Dimitrios Filippou, under MIT), which is what polytonic text hyphenates by too. Five of InDesign's ~40 hyphenation languages. No readiness percentage change. - **2026-10-16** · A swatch applied to a placed greyscale or bitmap image colours it, as in InDesign: IDML's `` is read and written, greyscale pixels become a tint of the swatch on opaque white and a bitmap's white lets the frame show through, on screen and in PDF (spot swatches print on their plate). Colour images keep their colours. Applying the swatch from the Swatches panel comes next. No readiness percentage change. diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index 99dd49afe..d631f5671 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -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 roots confine every file the agent's commands read (open, placed images, data sources, libraries) and\n write (save, export, package) to those folders, links followed. Each may be given more than once; a kind\n left out grants none of its access.\n They run the in-process server, so they conflict with --connect (a running app is not confined by them).\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli links\n designcraft-cli --version"; +const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR]... [--automation-write-root DIR]...\n the roots confine every file the agent's commands read (open, placed images, data sources, libraries) and\n write (save, export, package) to those folders, links followed. Each may be given more than once; a kind\n left out grants none of its access.\n They run the in-process server, so they conflict with --connect (a running app is not confined by them).\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli validate-pdf FILE.pdf # built-in PDF/X-4 checks (including visible unembedded fonts)\n designcraft-cli 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 } @@ -264,7 +264,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 { - 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, @@ -346,33 +346,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()?; @@ -388,6 +362,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())?; diff --git a/apps/designcraft-cli/src/perf.rs b/apps/designcraft-cli/src/perf.rs index bed57cfcc..0c665f7e7 100644 --- a/apps/designcraft-cli/src/perf.rs +++ b/apps/designcraft-cli/src/perf.rs @@ -335,7 +335,7 @@ fn row(rows: &mut Vec, name: impl Into, ms: f64, budget: Option Result, 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!( diff --git a/apps/designcraft/src/gpu/backend.rs b/apps/designcraft/src/gpu/backend.rs index 0a3cda618..6aa250dd4 100644 --- a/apps/designcraft/src/gpu/backend.rs +++ b/apps/designcraft/src/gpu/backend.rs @@ -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 }) diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index 13416e4e0..195a2dc6e 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -256,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 @@ -277,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, 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 { +fn pick_save(suggested: &str) -> Option { + 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]); } @@ -606,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))] diff --git a/crates/color/src/ase.rs b/crates/color/src/ase.rs index 6cfd72034..f44e3dba8 100644 --- a/crates/color/src/ase.rs +++ b/crates/color/src/ase.rs @@ -128,8 +128,14 @@ pub fn write(swatches: &[Swatch]) -> Vec { continue; } let mut b = Vec::new(); - let units: Vec = 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 = 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(); diff --git a/crates/color/src/cms/mod.rs b/crates/color/src/cms/mod.rs index 3be294ae5..1a1374cd3 100644 --- a/crates/color/src/cms/mod.rs +++ b/crates/color/src/cms/mod.rs @@ -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>> = RwLock::new(Vec::new()); fn builtin_rgb_cache(name: &str) -> Option> { static CACHE: Mutex>> = 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> { } fn user_profile(name: &str) -> Option> { - 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 { .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) -> Result Arc { 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)>> = 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>) -> Option<[f32; 4]> { @@ -704,10 +704,10 @@ pub fn generation() -> u64 { /// The active colour-management context (Edit → Color Settings). pub fn active() -> Arc { - 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); } diff --git a/crates/compose/src/breaker.rs b/crates/compose/src/breaker.rs index 0dff34702..85b3a0dc2 100644 --- a/crates/compose/src/breaker.rs +++ b/crates/compose/src/breaker.rs @@ -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) } diff --git a/crates/compose/src/cache.rs b/crates/compose/src/cache.rs index 77bb30f95..66122ccf4 100644 --- a/crates/compose/src/cache.rs +++ b/crates/compose/src/cache.rs @@ -3,11 +3,12 @@ //! An entry is valid while the inputs it was composed from are the *same allocations*: the story //! `Arc` (and its revision), the styles `Arc`, the frame items' `Arc`s and the `Arc`s of every //! wrapping item on the frames' spreads, and the same for every story whose anchored objects wrap -//! its text ([`crate::wrap::neighbours`]). Entries hold clones of those `Arc`s, so the pointers +//! its text ([`crate::wrap::neighbours`]). An anchored frame's entry also records where its host +//! story's composition places it. Entries hold clones of those `Arc`s, so the pointers //! 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}; @@ -99,11 +100,12 @@ impl Cache { /// [`Cache::get`] for any [`ShownOn`]. pub fn get_on(&self, doc: &Document, sid: StoryId, on: ShownOn) -> Arc { - self.get_with(doc, sid, on, true) + self.get_with(doc, sid, on, true, 0) } - /// [`Cache::get_on`], wrapped by other stories' anchored objects only when `cross` is set. - fn get_with(&self, doc: &Document, sid: StoryId, on: ShownOn, cross: bool) -> Arc { + /// [`Cache::get_on`], wrapped by other stories' anchored objects only when `cross` is set, for + /// a story `depth` hosts down from the story asked for. + fn get_with(&self, doc: &Document, sid: StoryId, on: ShownOn, cross: bool, depth: usize) -> Arc { let Some(story) = doc.stories.get(&sid) else { return Arc::new(ComposedStory { story: sid, ..Default::default() }) }; let (mut sig, mut keep_items) = signature(doc, story); let neighbours = if cross { crate::wrap::neighbours(doc, sid) } else { Vec::new() }; @@ -117,6 +119,24 @@ impl Cache { keep_stories.push(ns.clone()); } } + // Anchored frames compose where their host story places them, so the host is composed + // first and the placements are part of the signature. + let placed = if depth < crate::placed::MAX_DEPTH { + crate::placed::placements(doc, sid, &mut |host| self.get_with(doc, host, ShownOn::Own, true, depth + 1)) + } else { + Vec::new() + }; + for (id, p) in &placed { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (id, p.spread, p.xf.as_coeffs().map(f64::to_bits)).hash(&mut h); + // The placed spread's pages give the frame its page, side and page rect. + for pg in doc.spread(p.spread).map_or(&[][..], |sp| &sp.pages[..]) { + let m = &pg.margins; + (pg.side as u8, [pg.x, pg.width, pg.height, m.top, m.bottom, m.inside, m.outside].map(f64::to_bits)).hash(&mut h); + } + sig.push(h.finish() as usize); + } let key = (sid, on, !neighbours.is_empty()); // The key holds the page's index, which a renumbering doesn't change: its name (section // start number, style, prefix) is part of the signature. @@ -186,7 +206,7 @@ impl Cache { } // Advanced Type sizes super/subscripts. let a = doc.settings.advanced_type; - sig.extend([a.superscript_size, a.superscript_position, a.subscript_size, a.subscript_position].map(|v| v.to_bits() as usize)); + sig.extend([a.superscript_size, a.superscript_position, a.subscript_size, a.subscript_position].into_iter().flat_map(bits)); // Missing glyphs: fallback fonts or the font's box. sig.push(usize::from(doc.settings.glyph_fallback)); // Named lists continue from earlier stories. @@ -196,7 +216,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) @@ -209,14 +229,14 @@ impl Cache { // Parent-page items are composed per page: markers show its name, variables need its index. let page = page.filter(|_| with_vars); let running = running.then(|| self.running_index(doc)); - let others: Vec> = neighbours.iter().map(|n| self.neighbour(doc, *n)).collect(); + let others: Vec> = neighbours.iter().map(|n| self.neighbour(doc, *n, depth)).collect(); let out = Arc::new(crate::wrap::compose_among( doc, sid, - &ComposeOptions { page_name, section_marker, page, running, label: None, xrefs: with_xrefs.then(|| self.xref_index(doc)) }, + &ComposeOptions { page_name, section_marker, page, running, label: None, xrefs: with_xrefs.then(|| self.xref_index(doc)), placed }, &others.iter().map(|o| &**o).collect::>(), )); - 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 +248,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 +261,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 +270,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()) } @@ -295,49 +315,56 @@ impl Cache { /// Story `sid` composed for the wrap of its anchored objects in other stories: without other /// stories' anchored objects (no cycles), and outside the cache when it needs the document-wide /// indexes (they compose every story). - fn neighbour(&self, doc: &Document, sid: StoryId) -> Arc { + fn neighbour(&self, doc: &Document, sid: StoryId, depth: usize) -> Arc { let indexed = doc.stories.get(&sid).is_some_and(|st| crate::xref::has_xrefs(st) || (crate::vars::has_vars(st) && crate::vars::has_running(doc, st))); if indexed { Arc::new(crate::wrap::compose_among(doc, sid, &ComposeOptions::default(), &[])) } else { - self.get_with(doc, sid, ShownOn::Own, false) + self.get_with(doc, sid, ShownOn::Own, false, depth) } } 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 { 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 { 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 } } +/// Both halves of `v`'s bits: a 32-bit `usize` (wasm) would drop the high half, where whole +/// numbers keep their differences. +fn bits(v: f64) -> [usize; 2] { + let b = v.to_bits(); + [(b & 0xffff_ffff) as usize, (b >> 32) as usize] +} + fn signature(doc: &Document, story: &Arc) -> (Vec, Vec>) { // The document's font scope: another document's fonts of the same name set it differently. // Sections name the pages under the frames (page-number and section markers). @@ -348,9 +375,9 @@ fn signature(doc: &Document, story: &Arc) -> (Vec, Vec>) Arc::as_ptr(&doc.styles) as usize, sections_hash(doc) as usize, doc.font_scope as usize, - g.start.to_bits() as usize, - g.increment.to_bits() as usize, + usize::from(g.relative_to == designcraft_doc::GridRelative::TopMargin), ]; + sig.extend([g.start, g.increment].into_iter().flat_map(bits)); let mut keep = Vec::new(); let mut spreads = Vec::new(); for f in &story.frames { @@ -373,6 +400,12 @@ fn signature(doc: &Document, story: &Arc) -> (Vec, Vec>) sig.push(loc.top()); if !spreads.contains(&loc.spread) { spreads.push(loc.spread); + // The page under a frame (found by x) gives its grid origin, page rect and side. + for p in &sp.pages { + let m = &p.margins; + sig.extend([p.x, p.width, p.height, m.top, m.bottom, m.inside, m.outside].into_iter().flat_map(bits)); + sig.push(p.side as usize); + } for it in &sp.items { let mut wraps = false; it.walk(&mut |i| wraps |= i.has_wrap()); diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 89ea89bd3..687f4c7ea 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -23,6 +23,7 @@ pub mod optical; mod outline; mod overlay; mod parabox; +mod placed; mod ruby; pub mod shape; pub mod shatai; @@ -50,6 +51,7 @@ use crate::notes::Notes; pub use crate::notes::{find_note, hit_note, hit_note_with, note_caret}; pub use crate::outline::FrameOutline; use crate::parabox::ParaBox; +pub use crate::placed::Placement; use crate::shape::{Glyph, StyleTable, SubstCtx}; pub use crate::table::{BorderHit, PlacedCell, StrokeSeg, TableFrag, cell_caret, find_cell, hit_border, hit_cell, hit_cell_with}; @@ -165,6 +167,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 +585,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)>, } @@ -610,6 +629,9 @@ pub struct ComposeOptions { pub label: Option, /// Anchor pages for cross-references (needed when the story contains cross-references). pub xrefs: Option>, + /// Where the story's anchored frames sit on their spreads; an anchored frame not listed is + /// composed where it is stored, on no page. + pub placed: Vec<(ItemId, Placement)>, } /// Nesting depth past which wrap obstacles inside groups are not collected. @@ -642,18 +664,34 @@ fn wrap_obstacles(it: &Item, parent: Affine, inv: Affine, skip: ItemId, left_pag /// Build the frame specs of a story from the document (geometry, wrap, page names, grid). pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { + frame_specs_placed(doc, sid, &[]) +} + +/// [`frame_specs`] with anchored frames where their host story placed them (`placed`). +pub(crate) fn frame_specs_placed(doc: &Document, sid: StoryId, placed: &[(ItemId, Placement)]) -> Vec { let Some(st) = doc.story(sid) else { return vec![] }; let mut out = Vec::with_capacity(st.frames.len()); for &fid in &st.frames { 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; + // An anchored frame takes its page, page side, page rect and grid from where it is placed. + let placement = if loc.is_none() { placed.iter().find(|(id, _)| *id == fid).map(|(_, p)| *p) } else { None }; + let parent = match (&loc, placement) { + (Some(l), _) => doc.parent_xf(l), + (None, Some(p)) => p.xf, + (None, None) => 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); + let spread_ref = loc.as_ref().map(|l| l.spread).or(placement.map(|p| p.spread)); let spread = spread_ref.and_then(|r| doc.spread(r)); + // Page items wrap the text of frames on the spread, not of anchored frames. if !tf.options.ignore_wrap + && loc.is_some() && let Some(sp) = spread { let left = |x: f64| sp.page_at_x(x).and_then(|pi| sp.pages.get(pi)).is_some_and(|p| p.side == designcraft_doc::PageSide::Left); @@ -672,34 +710,45 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { 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) - .and_then(|pi| sp.pages.get(pi)) - .map(|pg| (inv.transform_rect_bbox(pg.bounds()), inv.transform_rect_bbox(pg.margin_rect()))), - _ => None, - }; + // The page under the frame, and its bounds and margins in inner space (bounding boxes). + let under = spread.and_then(|sp| sp.page_at_x(center_x).and_then(|pi| sp.pages.get(pi))); + let page_rect = under.map(|pg| (inv.transform_rect_bbox(pg.bounds()), inv.transform_rect_bbox(pg.margin_rect()))); let g = &doc.settings.baseline_grid; - let (inc, start) = tf.options.baseline_grid.unwrap_or((g.increment, g.start)); // Grid lines are at spread y = start + n·inc (page tops are y = 0); map into inner space (translation only). let ty = xf.translation().y; - let grid = (inc > 0.0).then_some((start - ty, inc)); + let (start, inc) = match tf.options.baseline_grid { + None => (under.map_or(g.start, |pg| g.origin(pg)), g.increment), + Some(f) => { + use designcraft_doc::FrameGridRelative as R; + // The frame's own edges are in inner space; `ty` takes them to spread y as it takes + // the grid back, so a frame-relative grid keeps its place in a turned frame. + let from = match f.relative_to { + R::TopOfPage => 0.0, + R::TopMargin => under.map_or(0.0, |pg| pg.margins.top), + R::TopOfFrame => ty + item.inner_bounds().y0, + R::TopInset => ty + item.text_area().y0, + }; + (from + f.start, f.increment) + } + }; + let grid = (inc > 0.0 && inc.is_finite() && start.is_finite()).then_some((start - ty, inc)); // Type on a path: one line as long as the path (from the start offset). let (area, opts) = match &tf.options.path { Some(pt) => { @@ -757,6 +806,7 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { page, grid, left_page, + right_page, page_rect, }); } @@ -765,10 +815,23 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { /// Compose a story of `doc` through its frames, with the text wrap of page items and of anchored /// objects ([`wrap`]). +/// Anchored frames are composed where their host story places them (unless `opts` places them). pub fn compose_story(doc: &Document, sid: StoryId, opts: &ComposeOptions) -> ComposedStory { + compose_story_at(doc, sid, opts, 0) +} + +/// [`compose_story`] for a story `depth` hosts down from the story asked for. +fn compose_story_at(doc: &Document, sid: StoryId, opts: &ComposeOptions, depth: usize) -> ComposedStory { let others: Vec = wrap::neighbours(doc, sid).into_iter().map(|o| wrap::compose_among(doc, o, &ComposeOptions::default(), &[])).collect(); - wrap::compose_among(doc, sid, opts, &others.iter().collect::>()) + let others: Vec<&ComposedStory> = others.iter().collect(); + if opts.placed.is_empty() && depth < placed::MAX_DEPTH { + let placed = placed::placements(doc, sid, &mut |host| Arc::new(compose_story_at(doc, host, &ComposeOptions::default(), depth + 1))); + if !placed.is_empty() { + return wrap::compose_among(doc, sid, &ComposeOptions { placed, ..opts.clone() }, &others); + } + } + wrap::compose_among(doc, sid, opts, &others) } const DEFAULT_TAB: f64 = 36.0; @@ -845,6 +908,12 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let mut info: Vec = Vec::with_capacity(np); let mut force_col = vec![false; np]; let mut line_cap: Vec> = vec![None; np]; + // Hyphenate Across Column off: the hyphenation points (story bytes) a paragraph is re-laid + // without, because a line ending at one of them ended a column or frame. Each re-lay leaves + // out one more point of the paragraph, so they are bounded by its points; the cap only guards + // against keep options clearing them over and over. + let mut no_hyph: Vec> = vec![Vec::new(); np]; + let mut hyph_relays = 0usize; let mut restores = 0usize; let mut pi = 0; let var_story = vars::has_vars(story); @@ -884,7 +953,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C && let Some(j) = trial_failed(&mut trial, &mut limits, balance_runs < balance_budget) { balance_runs += 1; - rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); + rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph); pi = j; continue 'paras; } @@ -894,7 +963,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C && let Some(j) = balance_end(&mut trial, &mut limits, &cur, &cols, frames, &out, np, balance_runs < balance_budget) { balance_runs += 1; - rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); + rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph); pi = j; continue 'paras; } @@ -948,7 +1017,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C if !table::place_table(doc, t, anchor, pi, &pp, frames, &cols, &mut cur, &mut out, opts) { if let Some(j) = trial_failed(&mut trial, &mut limits, balance_runs < balance_budget) { balance_runs += 1; - rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); + rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph); pi = j; continue 'paras; } @@ -1083,7 +1152,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C }; apply_desired_spacing(&mut gl, &pp); mojikumi::apply(&mut gl, &doc.styles, &pp.mojikumi); - let hy = hyphenation_points(&story.text, &gl, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars)); + let mut hy = hyphenation_points(&story.text, &gl, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars)); + suppress_hyphens(&mut hy, &gl, no_hyph.get(pi).map_or(&[], Vec::as_slice)); let breaks = if pp.composer.single_line() || gl.iter().any(|g| g.ch == '\t') || gl.len() > 4000 { let tab = |j: usize, x: f64, i: usize| { let ind = pp.left_indent + if j == 0 { pp.first_line_indent } else { 0.0 }; @@ -1218,7 +1288,9 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C bidi::resolve_mirroring(&mut glyphs, &bidi_info); apply_desired_spacing(&mut glyphs, &pp); mojikumi::apply(&mut glyphs, &doc.styles, &pp.mojikumi); - let hyph_after = hyphenation_points(&story.text, &glyphs, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars)); + let mut hyph_after = + hyphenation_points(&story.text, &glyphs, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars)); + suppress_hyphens(&mut hyph_after, &glyphs, no_hyph.get(pi).map_or(&[], Vec::as_slice)); let base_size = base_chars.size; let spacing = spacing_for(&pp, base_size); // An empty paragraph takes its height from its paragraph mark's character format. @@ -1325,7 +1397,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C if !t.done { trial = Some(t); balance_runs += 1; - rewind(key.1, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); + rewind( + key.1, + &snaps, + ¬e_snaps, + &mut notes, + &mut out, + &mut cur, + &mut list_counter, + &mut force_col, + &mut line_cap, + &mut no_hyph, + ); pi = key.1; continue 'paras; } @@ -1361,6 +1444,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let mut line_no = 0usize; // Paragraph line number of the first line in the current column. let mut col_first_line = 0usize; + // The story byte at which the last line set ended at an automatic hyphenation point. + let mut hyphen_at: Option = None; let has_tabs = glyphs.iter().any(|g| g.ch == '\t'); let mut first_line_rect: Option<(usize, f64, f64, f64)> = None; // frame, baseline, ascent, x-span let rtl = pp.direction == designcraft_doc::TextDirection::RightToLeft; @@ -1378,7 +1463,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C if cur.fi >= frames.len() { if let Some(j) = trial_failed(&mut trial, &mut limits, balance_runs < balance_budget) { balance_runs += 1; - rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); + rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph); pi = j; continue 'paras; } @@ -1605,6 +1690,17 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C 0.0 }; let fits = baseline + fit_descent <= col.y1 - reserve + 0.01 && !capped; + if !fits + && !pp.hyph_across_column + && line_no > col_first_line + && let Some(at) = hyphen_at + && hyph_relays < story.text.len().saturating_add(64).saturating_mul(restores.saturating_add(1)) + { + hyph_relays += 1; + no_hyph[pi].push(at); + rewind(pi, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph); + continue 'paras; + } if !fits { if cur.can_next_sub() { g0 = s; @@ -1626,7 +1722,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C }; if let Some(j) = j { split_runs += 1; - rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); + rewind( + j, + &snaps, + ¬e_snaps, + &mut notes, + &mut out, + &mut cur, + &mut list_counter, + &mut force_col, + &mut line_cap, + &mut no_hyph, + ); pi = j; continue 'paras; } @@ -1659,7 +1766,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C j } }; - rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); + rewind( + j, + &snaps, + ¬e_snaps, + &mut notes, + &mut out, + &mut cur, + &mut list_counter, + &mut force_col, + &mut line_cap, + &mut no_hyph, + ); pi = j; continue 'paras; } @@ -1674,10 +1792,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; @@ -1765,7 +1889,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C if notes.earlier_columns_collide(&out.frames[cur.fi].lines, cur.fi, cur.col, &cols[cur.fi], r) && let Some(j) = out.frames[cur.fi].lines.first().map(|l| l.para) { - rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); + rewind( + j, + &snaps, + ¬e_snaps, + &mut notes, + &mut out, + &mut cur, + &mut list_counter, + &mut force_col, + &mut line_cap, + &mut no_hyph, + ); pi = j; continue 'paras; } @@ -1797,6 +1932,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C cur.last_descent = desc; cur.pending = 0.0; cur.grid_next = grid_end.map(|end| (baseline, end)); + hyphen_at = + e.checked_sub(1).filter(|&i| b.hyphen && hyph_after.get(i) == Some(&true)).and_then(|i| glyphs.get(i)).map(|g| g.byte + g.len); line_no += 1; // A column / frame / page break inside the paragraph: the rest of it continues in the // new column/frame/page, re-broken there. (A break that ends the paragraph moves the @@ -1924,7 +2061,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C if !t.done { split_trial = Some(t); split_runs += 1; - rewind(key.2, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); + rewind( + key.2, + &snaps, + ¬e_snaps, + &mut notes, + &mut out, + &mut cur, + &mut list_counter, + &mut force_col, + &mut line_cap, + &mut no_hyph, + ); pi = key.2; continue 'paras; } @@ -1982,6 +2130,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 +2142,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) } }; } @@ -2282,9 +2437,11 @@ fn rewind( list_counter: &mut ListCounter, force_col: &mut [bool], line_cap: &mut [Option], + no_hyph: &mut [Vec], ) { force_col.iter_mut().skip(j + 1).for_each(|f| *f = false); line_cap.iter_mut().skip(j + 1).for_each(|c| *c = None); + no_hyph.iter_mut().skip(j + 1).for_each(Vec::clear); if let Some(s) = snaps.get(j) { s.restore(out, cur, list_counter); } @@ -3984,6 +4141,18 @@ fn foreign_ranges( .collect() } +/// Leave out the hyphenation points after the glyphs that end at story bytes `at`. +fn suppress_hyphens(hy: &mut [bool], glyphs: &[Glyph], at: &[usize]) { + if at.is_empty() { + return; + } + for (h, g) in hy.iter_mut().zip(glyphs) { + if *h && at.contains(&(g.byte + g.len)) { + *h = false; + } + } +} + fn hyphenation_points( text: &str, glyphs: &[Glyph], @@ -4004,7 +4173,7 @@ fn hyphenation_points( // Words repeat a lot: remember their points per set of limits. static CACHE: Mutex), 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 +4674,7 @@ pub fn adjacent_row(l: &Line, x: f64, caret_y: f64, up: bool) -> Option { // 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 +4796,7 @@ mod column_allocation_safety_tests { page: None, grid: None, left_page: false, + right_page: false, page_rect: None, outline: None, } diff --git a/crates/compose/src/notes.rs b/crates/compose/src/notes.rs index e72ecde02..9b3e7e40b 100644 --- a/crates/compose/src/notes.rs +++ b/crates/compose/src/notes.rs @@ -72,7 +72,7 @@ pub(crate) struct Notes { pub key: Option, pub placed: Vec, /// Composed footnote text by (index, label, width). - cache: HashMap<(usize, String, u64), (Arc, NoteMetrics)>, + cache: HashMap<(usize, String, u64, bool, bool), (Arc, NoteMetrics)>, notes: Vec>, /// 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, 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 { @@ -178,6 +180,7 @@ impl Notes { running: opts.running.clone(), label: Some(label), xrefs: opts.xrefs.clone(), + placed: Vec::new(), }; let cs = crate::compose(doc, &self.notes[k].text, std::slice::from_ref(&spec), &nopts); let lines = cs.frames.first().map(|ft| ft.lines.as_slice()).unwrap_or_default(); diff --git a/crates/compose/src/parabox.rs b/crates/compose/src/parabox.rs index b8b58cb44..f6b90e127 100644 --- a/crates/compose/src/parabox.rs +++ b/crates/compose/src/parabox.rs @@ -122,6 +122,45 @@ pub(crate) fn draw(out: &mut ComposedStory, boxes: &[Option], 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 { + 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, b: &ParaBox, frames: &[FrameSpec]) -> Vec<(usize, bool, Deco)> { let mut parts: Vec> = Vec::new(); @@ -136,40 +175,12 @@ fn group_decos(out: &ComposedStory, paras: std::ops::RangeInclusive, 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 diff --git a/crates/compose/src/placed.rs b/crates/compose/src/placed.rs new file mode 100644 index 000000000..200ff6e3c --- /dev/null +++ b/crates/compose/src/placed.rs @@ -0,0 +1,84 @@ +//! Where anchored text frames sit on their spread. +//! +//! An anchored frame is not a spread item: its host story's composition places it on a line. Its +//! own story is composed against that placement (page, page side, page rect, baseline grid), so +//! the host story is composed first. + +use std::sync::Arc; + +use designcraft_doc::{Document, Item, ItemId, SpreadRef, StoryId}; +use designcraft_geom::Affine; + +use crate::{ComposedStory, FrameText}; + +/// Hosts nested deeper than this (a note anchored in a note anchored in …, or stories anchored in +/// each other in a cycle) are composed where they are stored. +pub(crate) const MAX_DEPTH: usize = 8; + +/// An anchored item's parent space on its spread, where its host story's composition placed it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Placement { + pub spread: SpreadRef, + /// The item's parent space → spread space. + pub xf: Affine, +} + +/// The placements of `sid`'s anchored frames, from their host stories composed by `host`. +pub(crate) fn placements(doc: &Document, sid: StoryId, host: &mut dyn FnMut(StoryId) -> Arc) -> Vec<(ItemId, Placement)> { + let Some(st) = doc.story(sid) else { return Vec::new() }; + let mut anchored = None; + let mut out = Vec::new(); + for &fid in &st.frames { + if doc.find(fid).is_some() { + continue; + } + let items = anchored.get_or_insert_with(|| doc.anchored_items()); + // A frame anchored in its own story would need its own composition to place it. + let Some(owner) = items.iter().find_map(|(owner, it)| (it.id == fid).then_some(*owner)).filter(|o| *o != sid) else { continue }; + if let Some(p) = locate(doc, &host(owner), fid) { + out.push((fid, p)); + } + } + out +} + +/// Where `cs` (a host story's composition) places item `fid`: in its frames' anchored objects, +/// footnotes and table cells. +fn locate(doc: &Document, cs: &ComposedStory, fid: ItemId) -> Option { + cs.frames.iter().find_map(|ft| { + let loc = doc.find(ft.frame)?; + let frame = doc.item_at(&loc)?; + let xf = in_frame(ft, doc.parent_xf(&loc) * doc.text_xf(frame), fid, 0)?; + Some(Placement { spread: loc.spread, xf }) + }) +} + +/// The parent space of `fid` among the objects of `ft`, whose text space maps to the spread by +/// `xf`. +fn in_frame(ft: &FrameText, xf: Affine, fid: ItemId, depth: usize) -> Option { + if depth > MAX_DEPTH { + return None; + } + let objects = ft.objects.iter().find_map(|o| in_item(&o.object.item, xf * Affine::translate(o.origin.to_vec2()), fid, 0)); + objects + .or_else(|| ft.notes.iter().find_map(|n| in_frame(n.text.frames.first()?, xf * Affine::translate(n.origin.to_vec2()), fid, depth + 1))) + .or_else(|| { + ft.tables + .iter() + .flat_map(|t| &t.cells) + .find_map(|c| in_frame(c.text.frames.first()?, xf * Affine::translate(c.origin.to_vec2()), fid, depth + 1)) + }) +} + +/// The parent space of `fid` in `it` (it or a group member), `it`'s parent space mapping to the +/// spread by `parent`. +fn in_item(it: &Item, parent: Affine, fid: ItemId, depth: usize) -> Option { + if it.id == fid { + return Some(parent); + } + if depth > crate::MAX_WRAP_DEPTH { + return None; + } + let xf = parent * it.xf; + it.children().iter().find_map(|c| in_item(c, xf, fid, depth + 1)) +} diff --git a/crates/compose/src/ruby.rs b/crates/compose/src/ruby.rs index 1afea6b0d..47c48a728 100644 --- a/crates/compose/src/ruby.rs +++ b/crates/compose/src/ruby.rs @@ -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; diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index ffbc4857d..0f739e16e 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -950,10 +950,10 @@ static WORD_CACHE: RwLock>)>> = RwLock::new(Ve const WORD_CACHE_MAX: usize = 50_000; fn word_map(key: WordKey) -> Arc> { - 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(); } diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs index 940e0aa77..9a41a546e 100644 --- a/crates/compose/src/table.rs +++ b/crates/compose/src/table.rs @@ -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> = 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 { + 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); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 15c412ada..cd4783520 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1013,6 +1013,92 @@ fn hyphenation_zone_limits_ragged_hyphens() { assert!(free > 0 && zoned == free && huge == free, "{c:?}: {free} {zoned} {huge}"); } +#[test] +fn hyphenate_across_column_off_ends_no_column_with_a_hyphen() { + let text = CORPUS.join(" "); + // Column-end lines that are hyphenated, over frame heights that end the first column at each + // line in turn. + let ends = |across: bool, composer: designcraft_doc::Composer| { + let mut ends = 0; + let mut lines = 0; + for h in (30..400).step_by(3) { + let para = ParaAttrs { + align: Some(Align::Left), + hyph_weight: Some(0.0), + hyph_across_column: Some(across), + composer: Some(composer), + ..Default::default() + }; + let (mut d, sid, fid) = doc_with(&text, Rect::new(0.0, 0.0, 240.0, h as f64), para); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let col0: Vec<&Line> = all_lines(&cs).into_iter().filter(|l| l.column == 0).collect(); + ends += usize::from(col0.last().is_some_and(|l| l.hyphenated)); + lines += col0.len(); + // The frame's last line too, when the text goes on (overset). + let last = all_lines(&cs).into_iter().last(); + ends += usize::from(cs.is_overset() && last.is_some_and(|l| l.hyphenated && !l.last_in_para)); + } + (ends, lines) + }; + for c in [designcraft_doc::Composer::Paragraph, designcraft_doc::Composer::SingleLine] { + let (on, lines_on) = ends(true, c); + let (off, lines_off) = ends(false, c); + // With it on, columns do end in hyphens here; with it off, none does, and the columns + // still fill. + assert!(on > 3, "{c:?}: {on}"); + assert_eq!(off, 0, "{c:?}"); + assert!(lines_off * 10 >= lines_on * 9, "{c:?}: {lines_off} {lines_on}"); + } +} + +#[test] +fn hyphenate_across_column_off_keeps_nested_line_styles_on_their_lines() { + // The first line in a character style of another colour (the same widths): when its hyphen is + // left out at a column end, the style still covers exactly the first line as set. + let text = CORPUS.join(" "); + let compose = |w: f64, h: f64, across: bool| { + let para = ParaAttrs { + align: Some(Align::Left), + hyph_weight: Some(0.0), + hyph_across_column: Some(across), + nested_line_styles: Some(vec![designcraft_doc::NestedLineStyle { style: "Lead".into(), lines: 1 }]), + ..Default::default() + }; + let (mut d, sid, fid) = doc_with(&text, Rect::new(0.0, 0.0, w, h), para); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2; + let chars = designcraft_doc::CharAttrs { fill: Some("[Registration]".into()), ..Default::default() }; + std::sync::Arc::make_mut(&mut d.styles).character.push(designcraft_doc::CharacterStyle { + name: "Lead".into(), + based_on: None, + chars, + shortcut: String::new(), + }); + compose_story(&d, sid, &ComposeOptions::default()) + }; + let mut checked = 0; + for w in (160..320).step_by(10) { + for h in (12..40).step_by(2) { + // Where, with the setting on, a hyphenated first line ends column 1. + let on = compose(w as f64, h as f64, true); + let lines = all_lines(&on); + if !(lines.iter().filter(|l| l.column == 0).count() == 1 && lines[0].hyphenated) { + continue; + } + let cs = compose(w as f64, h as f64, false); + let lines = all_lines(&cs); + assert!(!lines[0].hyphenated, "{w}×{h}"); + let lead = |l: &Line| { + l.glyphs.iter().filter(|g| g.len > 0 && g.visible).map(|g| cs.styles[g.style as usize].fill == "[Registration]").collect::>() + }; + assert!(lead(lines[0]).iter().all(|&r| r), "{w}×{h}: first line"); + assert!(lead(lines[1]).iter().all(|&r| !r), "{w}×{h}: second line"); + checked += 1; + } + } + assert!(checked > 3, "{checked}"); +} + #[test] fn discretionary_hyphen_replaces_automatic_points() { let text = "aaaa bbbb cccc extraordi\u{AD}narily dddd eeee"; @@ -1150,7 +1236,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) { let mut parts: Vec = (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 +2838,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()); @@ -6610,7 +6697,7 @@ fn roman_word_break_breaks_inside_words_without_hyphens() { fn grid_doc(text: &str, para: ParaAttrs, edit: impl FnOnce(&mut Document, StoryId)) -> (Vec, (f64, f64)) { let (mut d, sid, fid) = doc_with(text, Rect::new(0.0, 0.0, 300.0, 400.0), para); if let Some(tf) = d.item_mut(fid).and_then(|i| i.text_frame_mut()) { - tf.options.baseline_grid = Some((12.0, 0.0)); + tf.options.baseline_grid = Some(designcraft_doc::FrameBaselineGrid::new(0.0, 12.0)); } edit(&mut d, sid); // Vertical frames have no baseline grid. @@ -6740,6 +6827,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 +6896,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 +6910,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] @@ -7199,6 +7352,158 @@ fn cache_recomposes_when_the_baseline_grid_changes() { assert!((la.baseline - lb.baseline).abs() > 1.0, "{} {}", la.baseline, lb.baseline); } +/// Two single pages 618 pt tall, top margins 36 and 48, each with an overset 11/13 story aligned +/// to a baseline grid (start 0, increment 13) and a frame filling its margin box (bottom 582). +fn grid_margin_doc(relative_to: designcraft_doc::GridRelative) -> (Document, [StoryId; 2]) { + let mut d = Document::new(&NewDocument { + height: 618.0, + pages: 2, + facing_pages: false, + margins: designcraft_doc::Margins::uniform(36.0), + ..Default::default() + }); + Arc::make_mut(&mut d.spreads[1]).pages[0].margins.top = 48.0; + Arc::make_mut(&mut d.spreads[1]).pages[0].local_margins = true; + d.settings.baseline_grid.start = 0.0; + d.settings.baseline_grid.increment = 13.0; + d.settings.baseline_grid.relative_to = relative_to; + let lid = d.default_layer(); + let para = ParaFormat { para: ParaAttrs { grid_align: Some(GridAlign::AllLines), ..Default::default() }, ..Default::default() }; + let text = [LOREM; 12].join(" "); + let mut sids = Vec::new(); + for (si, top) in [(0, 36.0), (1, 48.0)] { + let (_, sid) = d.add_text_frame(SpreadRef::Doc(si), Rect::new(36.0, top, 336.0, 582.0), lid, &text, para.clone()).unwrap(); + let st = d.story_mut(sid).unwrap(); + st.paras[0].chars = + designcraft_doc::CharAttrs { size: Some(11.0), leading: Some(designcraft_doc::Leading::Points(13.0)), ..Default::default() }; + sids.push(sid); + } + (d, [sids[0], sids[1]]) +} + +/// Page y of a frame's baselines (the frame's inner space is its page's: it has no transform). +fn page_baselines(d: &Document, sid: StoryId) -> Vec { + let cs = compose_story(d, sid, &ComposeOptions::default()); + assert!(cs.is_overset()); + cs.frames[0].lines.iter().map(|l| l.baseline).collect() +} + +#[test] +fn baseline_grid_relative_to_the_top_margin_starts_at_each_pages_margin() { + use designcraft_doc::GridRelative; + let (d, [p1, p2]) = grid_margin_doc(GridRelative::TopMargin); + // Grid lines at 36 + 13n on page 1: 42 lines from 49 to 582. + let lines = page_baselines(&d, p1); + assert!((lines[0] - 49.0).abs() < 1e-6, "{lines:?}"); + assert!(lines.iter().enumerate().all(|(n, y)| (y - (49.0 + 13.0 * n as f64)).abs() < 1e-6), "{lines:?}"); + assert_eq!(lines.len(), 42); + // Page 2's own margin: lines at 48 + 13n. + let lines = page_baselines(&d, p2); + assert!((lines[0] - 61.0).abs() < 1e-6, "{lines:?}"); + // From the top of the page the lines are at 13n: the first at 52, and one line fewer. + let (d, [p1, _]) = grid_margin_doc(GridRelative::TopOfPage); + let lines = page_baselines(&d, p1); + assert!((lines[0] - 52.0).abs() < 1e-6, "{lines:?}"); + assert_eq!(lines.len(), 41); +} + +/// A frame's custom grid (start 10, increment 13) measured from the page top, the top margin +/// (36), the frame top (100) or the top inset (6 below it). +#[test] +fn frame_baseline_grid_relative_to_page_margin_frame_or_inset() { + use designcraft_doc::{FrameBaselineGrid, FrameGridRelative as R}; + let first_baseline = |relative_to: Option| { + let mut d = Document::new(&NewDocument { margins: designcraft_doc::Margins::uniform(36.0), ..Default::default() }); + let lid = d.default_layer(); + let para = ParaFormat { para: ParaAttrs { grid_align: Some(GridAlign::AllLines), ..Default::default() }, ..Default::default() }; + let (fid, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 100.0, 336.0, 400.0), lid, "Grid", para).unwrap(); + let st = d.story_mut(sid).unwrap(); + st.paras[0].chars = + designcraft_doc::CharAttrs { size: Some(11.0), leading: Some(designcraft_doc::Leading::Points(13.0)), ..Default::default() }; + let tf = d.item_mut(fid).unwrap().text_frame_mut().unwrap(); + tf.options.inset = [6.0, 0.0, 0.0, 0.0]; + tf.options.baseline_grid = relative_to.map(|r| FrameBaselineGrid { relative_to: r, ..FrameBaselineGrid::new(10.0, 13.0) }); + // A document grid that matches none of the frame grids. + d.settings.baseline_grid.start = 0.0; + d.settings.baseline_grid.increment = 0.0; + compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0].baseline + }; + // Without a grid the first baseline sits an ascent below the top inset. + let natural = first_baseline(None); + assert!(natural > 106.0 && natural < 120.0, "{natural}"); + for (r, origin) in [(R::TopOfPage, 10.0), (R::TopMargin, 46.0), (R::TopOfFrame, 110.0), (R::TopInset, 116.0)] { + let want = (0..).map(|n| origin + 13.0 * f64::from(n)).find(|y| *y >= natural - 1e-9).unwrap(); + let got = first_baseline(Some(r)); + assert!((got - want).abs() < 1e-6, "{r:?}: first baseline {got}, want {want} (natural {natural})"); + } +} + +/// A group's transform moves a frame from the left page of a spread to the right one: the grid +/// starts at the right page's top margin. +#[test] +fn baseline_grid_follows_a_grouped_frame_to_its_page() { + use designcraft_doc::{Content, GridRelative, Item, ItemId, Shape}; + let mut d = Document::new(&NewDocument { pages: 3, facing_pages: true, margins: designcraft_doc::Margins::uniform(36.0), ..Default::default() }); + Arc::make_mut(&mut d.spreads[1]).pages[1].margins.top = 48.0; + d.settings.baseline_grid.start = 0.0; + d.settings.baseline_grid.increment = 13.0; + d.settings.baseline_grid.relative_to = GridRelative::TopMargin; + let lid = d.default_layer(); + let para = ParaFormat { para: ParaAttrs { grid_align: Some(GridAlign::AllLines), ..Default::default() }, ..Default::default() }; + let (fid, sid) = d.add_text_frame(SpreadRef::Doc(1), Rect::new(36.0, 36.0, 336.0, 582.0), lid, LOREM, para).unwrap(); + let sp = Arc::make_mut(&mut d.spreads[1]); + let at = sp.items.iter().position(|it| it.id == fid).unwrap(); + let frame = sp.items.remove(at); + let right = sp.pages[1].x; + let mut g = Item::new(ItemId(d.alloc()), lid, Shape::Group, designcraft_geom::PathData::default()); + g.content = Content::Group { items: vec![frame] }; + g.xf = designcraft_geom::Affine::translate((right, 0.0)); + Arc::make_mut(&mut d.spreads[1]).items.push(Arc::new(g)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let first = cs.frames[0].lines[0].baseline; + assert!((first - 61.0).abs() < 1e-6, "{first}"); +} + +#[test] +fn cache_recomposes_when_the_grid_reference_or_a_top_margin_changes() { + use designcraft_doc::GridRelative; + let (mut d, [p1, _]) = grid_margin_doc(GridRelative::TopOfPage); + let cache = Cache::new(); + let a = cache.get(&d, p1, None); + d.settings.baseline_grid.relative_to = GridRelative::TopMargin; + let b = cache.get(&d, p1, None); + assert!(!Arc::ptr_eq(&a, &b)); + assert!((a.frames[0].lines[0].baseline - b.frames[0].lines[0].baseline).abs() > 1.0); + Arc::make_mut(&mut d.spreads[0]).pages[0].margins.top = 40.0; + let c = cache.get(&d, p1, None); + assert!(!Arc::ptr_eq(&b, &c)); + assert!((b.frames[0].lines[0].baseline - c.frames[0].lines[0].baseline).abs() > 1.0); +} + +/// Pages trading places under a frame: the frame is on the other page, with its top margin. +#[test] +fn cache_recomposes_when_the_page_under_a_frame_changes() { + use designcraft_doc::GridRelative; + let mut d = Document::new(&NewDocument { pages: 3, facing_pages: true, margins: designcraft_doc::Margins::uniform(36.0), ..Default::default() }); + Arc::make_mut(&mut d.spreads[1]).pages[1].margins.top = 48.0; + d.settings.baseline_grid.start = 0.0; + d.settings.baseline_grid.increment = 13.0; + d.settings.baseline_grid.relative_to = GridRelative::TopMargin; + let lid = d.default_layer(); + let para = ParaFormat { para: ParaAttrs { grid_align: Some(GridAlign::AllLines), ..Default::default() }, ..Default::default() }; + let x = d.spreads[1].pages[0].x; + let (_, sid) = d.add_text_frame(SpreadRef::Doc(1), Rect::new(x + 36.0, 36.0, x + 336.0, 582.0), lid, LOREM, para).unwrap(); + let cache = Cache::new(); + let a = cache.get(&d, sid, None); + let sp = Arc::make_mut(&mut d.spreads[1]); + let (x0, x1) = (sp.pages[0].x, sp.pages[1].x); + (sp.pages[0].x, sp.pages[1].x) = (x1, x0); + let b = cache.get(&d, sid, None); + assert!(!Arc::ptr_eq(&a, &b)); + assert_eq!(b.frames[0].lines[0].baseline, compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0].baseline); + assert!((a.frames[0].lines[0].baseline - b.frames[0].lines[0].baseline).abs() > 1.0); +} + #[test] fn cache_recomposes_when_a_wrap_layer_is_hidden() { let (mut d, sid, _) = doc_with(&[LOREM; 2].join(" "), Rect::new(0.0, 0.0, 300.0, 800.0), ParaAttrs::default()); @@ -7310,3 +7615,165 @@ 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])); +} + +/// A marginal note (an anchored text frame, Only Align First Line to Grid, 9/10) hangs 15 pt +/// above its anchor's baseline on a right-hand page. It composes against the document grid where +/// it is placed: its first line moves down onto the grid line, the second follows 10 pt below, +/// and its page is the host's. Composed where it was stored, it snapped to a grid measured from +/// its own top edge and sat on no page. +#[test] +fn anchored_text_frames_compose_against_the_grid_where_they_are_placed() { + use designcraft_doc::anchored::AnchorRelative as R; + let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() }); + d.settings.baseline_grid.start = 36.0; + d.settings.baseline_grid.increment = 10.0; + let lid = d.default_layer(); + let page = d.spreads[1].pages[1].clone(); + assert_eq!(page.side, designcraft_doc::PageSide::Right); + // Body lines at 46, 56, 66, … (on the grid) while the frame top is at 36. + let body = "One.\nTwo.\nThree.\nFour."; + let rect = Rect::new(page.x + 108.0, 36.0, page.x + page.width - 36.0, 400.0); + let (bf, sid) = d.add_text_frame(SpreadRef::Doc(1), rect, lid, body, ParaFormat::default()).unwrap(); + d.item_mut(bf).unwrap().text_frame_mut().unwrap().options.first_baseline = FirstBaseline::Leading; + let nine_ten = |f: &mut designcraft_doc::story::CharFormat| { + f.over.size = Some(9.0); + f.over.leading = Some(designcraft_doc::Leading::Points(10.0)); + }; + d.story_mut(sid).unwrap().format_chars(0..body.len(), nine_ten); + // The note: its own story in a 60 × 40 frame anchored in the third line. + let (nid, nsid) = (ItemId(d.alloc()), StoryId(d.alloc())); + let text = "Note one\u{2028}note two"; + let para = ParaFormat { para: ParaAttrs { grid_align: Some(GridAlign::FirstLineOnly), ..Default::default() }, ..Default::default() }; + let mut story = Story::with_text(nsid, text, para); + story.format_chars(0..text.len(), nine_ten); + story.frames.push(nid); + d.stories.insert(nsid, Arc::new(story)); + let mut note = + designcraft_doc::Item::new(nid, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 60.0, 40.0))); + note.content = Content::Text(designcraft_doc::TextFrame { story: nsid, options: Default::default() }); + let position = designcraft_doc::AnchorPosition::Custom { + x_relative: R::TextFrame, + y_relative: R::Anchor, + x_offset: -72.0, + y_offset: -15.0, + object_point: 0, + ref_point: 0, + keep_within_column: false, + spine_relative: false, + }; + let at = body.find("Three").unwrap(); + d.story_mut(sid).unwrap().insert_object(at, designcraft_doc::AnchoredObject::new(note, position)); + + let cache = Cache::new(); + let on_grid = |y: f64| on_grid(y, (36.0, 10.0)); + // (anchor line baseline, note baselines), in spread space. + let baselines = |d: &Document, cs: &ComposedStory| { + let host = compose_story(d, sid, &ComposeOptions::default()); + let ft = host.frame(bf).unwrap(); + let o = &ft.objects[0]; + let fxf = d.item(bf).unwrap().xf; + let anchor = (fxf * Point::new(0.0, ft.lines[o.line].baseline)).y; + let nxf = fxf * Affine::translate(o.origin.to_vec2()) * d.anchored_item(nid).unwrap().xf; + (anchor, all_lines(cs).iter().map(|l| (nxf * Point::new(0.0, l.baseline)).y).collect::>()) + }; + let near = |a: f64, b: f64| (a - b).abs() < 1e-6; + + let (anchor, lines) = baselines(&d, &cache.get(&d, nsid, None)); + assert!(near(anchor, 66.0), "anchor baseline {anchor}"); + assert_eq!(lines.len(), 2); + assert!(near(lines[0], anchor) && near(lines[1], anchor + 10.0), "note baselines {lines:?}, anchor {anchor}"); + assert_eq!(baselines(&d, &compose_story(&d, nsid, &ComposeOptions::default())).1, lines); + // The note is on the host's right-hand page, with that page's rectangle. + let placed = placed::placements(&d, nsid, &mut |h| Arc::new(compose_story(&d, h, &ComposeOptions::default()))); + let spec = &frame_specs_placed(&d, nsid, &placed)[0]; + assert_eq!((spec.page, spec.right_page, spec.left_page), (Some(2), true, false)); + assert!(spec.page_rect.is_some()); + + // Host moved down 5 pt: the anchor is off the grid, and the note's first line moves down from + // 10 pt above it onto the grid line 5 pt above it. The cache composes the note again. + let xf = d.item(bf).unwrap().xf; + d.item_mut(bf).unwrap().xf = Affine::translate((0.0, 5.0)) * xf; + let (anchor, lines) = baselines(&d, &cache.get(&d, nsid, None)); + assert!(near(anchor, 71.0), "anchor baseline {anchor}"); + assert!(on_grid(lines[0]) && near(lines[0], anchor - 5.0) && near(lines[1], lines[0] + 10.0), "note baselines {lines:?}, anchor {anchor}"); + assert_eq!(baselines(&d, &compose_story(&d, nsid, &ComposeOptions::default())).1, lines); + + // The host's page turns into a left-hand page: the note is composed again for that side. + let before = cache.get(&d, nsid, None); + Arc::make_mut(&mut d.spreads[1]).pages[1].side = designcraft_doc::PageSide::Left; + assert!(!Arc::ptr_eq(&before, &cache.get(&d, nsid, None))); +} diff --git a/crates/compose/src/warichu.rs b/crates/compose/src/warichu.rs index 133e392ee..8418faac4 100644 --- a/crates/compose/src/warichu.rs +++ b/crates/compose/src/warichu.rs @@ -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], n: usize, m: usize) -> Option> { + 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 { @@ -142,17 +157,7 @@ fn split_even(advs: &[f64], n: usize, before: usize, after: usize) -> Vec 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)] diff --git a/crates/compose/src/wrap.rs b/crates/compose/src/wrap.rs index 57657213f..e1cdc5fa7 100644 --- a/crates/compose/src/wrap.rs +++ b/crates/compose/src/wrap.rs @@ -182,7 +182,7 @@ fn settled(a: &[Vec], b: &[Vec]) -> bool { /// [`neighbours`]) and of its own. pub(crate) fn compose_among(doc: &Document, sid: StoryId, opts: &ComposeOptions, others: &[&ComposedStory]) -> ComposedStory { let Some(st) = doc.story(sid) else { return ComposedStory { story: sid, ..Default::default() } }; - let mut specs = crate::frame_specs(doc, sid); + let mut specs = crate::frame_specs_placed(doc, sid, &opts.placed); if !others.is_empty() { let mut obs = Vec::new(); for cs in others { diff --git a/crates/doc/src/anchored.rs b/crates/doc/src/anchored.rs index c1392f313..74fc8818f 100644 --- a/crates/doc/src/anchored.rs +++ b/crates/doc/src/anchored.rs @@ -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, }, } diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index 077637672..fa7cffc68 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -371,7 +371,7 @@ fn locale_code(words: &[&str]) -> Option { /// 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> = std::sync::Mutex::new(Vec::new()); + static TAGS: std::sync::Mutex> = 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); @@ -1270,6 +1270,9 @@ attr_set! { number_expression: String = "^#.^t".into(), /// Text after the bullet (metacharacters expand as in [`expand_list_text`]). list_separator: String = "\t".into(), + /// The hanging indent was set by the Bulleted or Numbered List button, which takes it away + /// again with the list (an indent the paragraph had before stays). + list_hang: bool = false, balance_ragged: bool = false, optical_margin: bool = false, /// None uses the legacy built-in rules; an empty set explicitly disables kinsoku. diff --git a/crates/doc/src/index.rs b/crates/doc/src/index.rs index 9f56c42ea..826464329 100644 --- a/crates/doc/src/index.rs +++ b/crates/doc/src/index.rs @@ -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 = 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() { diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index cfb13dc53..31d3a08d2 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -452,12 +452,96 @@ pub struct TextFrameOptions { pub column_rule_bottom_inset: f64, /// Use a custom baseline grid for this frame. #[serde(skip_serializing_if = "Option::is_none")] - pub baseline_grid: Option<(f64, f64)>, + pub baseline_grid: Option, /// Type on a Path: the text runs along the item's path instead of filling it. #[serde(skip_serializing_if = "Option::is_none")] pub path: Option, } +/// A text frame's custom baseline grid. Saved as `[start, increment]` when it starts from the +/// top of the page, otherwise as `[start, increment, relativeTo]`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FrameBaselineGrid { + /// The first grid line, down from the line `relative_to` names. + pub start: f64, + /// The distance between grid lines; 0 or less turns the grid off. + pub increment: f64, + pub relative_to: FrameGridRelative, +} + +impl FrameBaselineGrid { + /// A grid measured from the top of the page. + pub fn new(start: f64, increment: f64) -> Self { + FrameBaselineGrid { start, increment, relative_to: FrameGridRelative::TopOfPage } + } +} + +/// What a frame's custom baseline grid's start is measured from. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum FrameGridRelative { + #[default] + #[serde(rename = "page")] + TopOfPage, + #[serde(rename = "margin")] + TopMargin, + #[serde(rename = "frame")] + TopOfFrame, + #[serde(rename = "inset")] + TopInset, +} + +impl FrameGridRelative { + pub const ALL: [FrameGridRelative; 4] = + [FrameGridRelative::TopOfPage, FrameGridRelative::TopMargin, FrameGridRelative::TopOfFrame, FrameGridRelative::TopInset]; + + /// The name commands and native files use: page, margin, frame or inset. + pub fn name(self) -> &'static str { + match self { + FrameGridRelative::TopOfPage => "page", + FrameGridRelative::TopMargin => "margin", + FrameGridRelative::TopOfFrame => "frame", + FrameGridRelative::TopInset => "inset", + } + } + + pub fn from_name(name: &str) -> Option { + Self::ALL.into_iter().find(|r| r.name() == name) + } +} + +impl Serialize for FrameBaselineGrid { + fn serialize(&self, s: S) -> Result { + // Files saved before Relative To existed hold two elements; a page-relative grid keeps that form. + match self.relative_to { + FrameGridRelative::TopOfPage => (self.start, self.increment).serialize(s), + r => (self.start, self.increment, r).serialize(s), + } + } +} + +impl<'de> Deserialize<'de> for FrameBaselineGrid { + fn deserialize>(d: D) -> Result { + struct Grid; + impl<'de> serde::de::Visitor<'de> for Grid { + type Value = FrameBaselineGrid; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("[start, increment] or [start, increment, relativeTo]") + } + fn visit_seq>(self, mut a: A) -> Result { + use serde::de::Error; + let start = a.next_element()?.ok_or_else(|| A::Error::invalid_length(0, &self))?; + let increment = a.next_element()?.ok_or_else(|| A::Error::invalid_length(1, &self))?; + let relative_to = a.next_element()?.unwrap_or_default(); + if a.next_element::()?.is_some() { + return Err(A::Error::invalid_length(4, &self)); + } + Ok(FrameBaselineGrid { start, increment, relative_to }) + } + } + d.deserialize_seq(Grid) + } +} + /// Type on a Path Options. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] @@ -1662,6 +1746,28 @@ mod tests { assert_eq!(*c, ClippingPath::new(ClippingKind::AlphaChannel, PathData::default())); } + #[test] + fn frame_baseline_grid_saves_as_start_then_increment() { + // A file saved before Relative To existed: the grid starts from the top of the page and + // saves back unchanged. + let v = serde_json::json!({"baselineGrid": [6.0, 14.0]}); + let o = serde_json::from_value::(v).unwrap(); + assert_eq!(o.baseline_grid, Some(FrameBaselineGrid { start: 6.0, increment: 14.0, relative_to: FrameGridRelative::TopOfPage })); + assert_eq!(serde_json::to_value(&o).unwrap()["baselineGrid"], serde_json::json!([6.0, 14.0])); + for r in [FrameGridRelative::TopMargin, FrameGridRelative::TopOfFrame, FrameGridRelative::TopInset] { + let o = TextFrameOptions { + baseline_grid: Some(FrameBaselineGrid { relative_to: r, ..FrameBaselineGrid::new(6.0, 14.0) }), + ..Default::default() + }; + let v = serde_json::to_value(&o).unwrap(); + assert_eq!(v["baselineGrid"], serde_json::json!([6.0, 14.0, r.name()])); + assert_eq!(serde_json::from_value::(v).unwrap().baseline_grid, o.baseline_grid); + } + for bad in [serde_json::json!([6.0]), serde_json::json!([6.0, 14.0, "frame", 1]), serde_json::json!([6.0, 14.0, "spine"])] { + assert!(serde_json::from_value::(serde_json::json!({"baselineGrid": bad})).is_err()); + } + } + #[test] fn bounds_and_text_area() { let mut it = Item::new(ItemId(1), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0))); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index b2d3b4f4c..a509fdd8b 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -147,6 +147,17 @@ impl Default for BaselineGrid { } } +impl BaselineGrid { + /// The first grid line on `page`, measured down from the page top: `start` from the top of + /// the page or from its top margin. The other lines follow every `increment` below it. + pub fn origin(&self, page: &page::Page) -> f64 { + match self.relative_to { + GridRelative::TopOfPage => self.start, + GridRelative::TopMargin => self.start + page.margins.top, + } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct DocumentGrid { diff --git a/crates/doc/src/otf.rs b/crates/doc/src/otf.rs index 5e246b8bf..64c976bad 100644 --- a/crates/doc/src/otf.rs +++ b/crates/doc/src/otf.rs @@ -53,7 +53,7 @@ pub fn figures(list: &[String]) -> &'static str { pub fn set_figures(list: &mut Vec, 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 } diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index e96aac4b7..d80b1d475 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -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(()) } } diff --git a/crates/engine/src/cmd/anchored.rs b/crates/engine/src/cmd/anchored.rs index 6d09f89dd..1e00585ca 100644 --- a/crates/engine/src/cmd/anchored.rs +++ b/crates/engine/src/cmd/anchored.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { "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 { "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 { } ("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 { match p.get(k) { @@ -102,6 +109,7 @@ fn position_param(p: &Value, base: &AnchorPosition) -> Result { 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}`"))), @@ -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 + )); } } diff --git a/crates/engine/src/cmd/book.rs b/crates/engine/src/cmd/book.rs index 4a65137cf..18f554d57 100644 --- a/crates/engine/src/cmd/book.rs +++ b/crates/engine/src/cmd/book.rs @@ -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}")))?; diff --git a/crates/engine/src/cmd/datamerge/fill.rs b/crates/engine/src/cmd/datamerge/fill.rs index b0165d9d3..d4b2235e9 100644 --- a/crates/engine/src/cmd/datamerge/fill.rs +++ b/crates/engine/src/cmd/datamerge/fill.rs @@ -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 } diff --git a/crates/engine/src/cmd/datamerge/mod.rs b/crates/engine/src/cmd/datamerge/mod.rs index 38b543ff7..c07e2fff4 100644 --- a/crates/engine/src/cmd/datamerge/mod.rs +++ b/crates/engine/src/cmd/datamerge/mod.rs @@ -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 } diff --git a/crates/engine/src/cmd/datamerge/parse.rs b/crates/engine/src/cmd/datamerge/parse.rs index 61ad6224e..c76c37f1e 100644 --- a/crates/engine/src/cmd/datamerge/parse.rs +++ b/crates/engine/src/cmd/datamerge/parse.rs @@ -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 { diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index fb8848a90..a10e9ae2f 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -262,12 +262,29 @@ pub fn specs() -> Vec { 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 { diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 36fd3d52a..551dd7484 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -10,7 +10,7 @@ use crate::{EngineError, Result, Session}; pub fn specs() -> Vec { 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= 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 { 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 { 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("") && !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()); + } +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index c644284d1..33cea7ed0 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -166,7 +166,7 @@ fn save_bytes(d: &Document, preview: Option<&[u8]>) -> Result> { /// 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(); } @@ -231,11 +231,24 @@ fn new_document_settings(s: &Session, p: &Value) -> Result { Ok(nd) } +/// A document as `file.new` makes it. Its Basic Paragraph doesn't hyphenate capitalized words, a +/// paragraph's last word or across a column. Opened and imported files keep their own values, +/// which is why `ParaProps` keeps the IDML defaults (on). +fn new_document(nd: &NewDocument) -> Document { + let mut d = Document::new(nd); + if let Some(st) = Arc::make_mut(&mut d.styles).para_mut(designcraft_doc::BASIC_PARAGRAPH) { + st.para.hyph_capitalized = Some(false); + st.para.hyph_last_word = Some(false); + st.para.hyph_across_column = Some(false); + } + d +} + fn file_new(s: &mut Session, p: &Value) -> Result { let mut nd = new_document_settings(s, p)?; s.untitled += 1; nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| format!("Untitled-{}", s.untitled)); - let d = Document::new(&nd); + let d = new_document(&nd); let i = s.add_document(DocState::new(d, None)); Ok(json!({"index": i})) } @@ -259,7 +272,7 @@ impl Session { self.untitled += 1; } nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| self.next_untitled_title()); - let st = DocState::new(Document::new(&nd), None); + let st = DocState::new(new_document(&nd), None); let uid = st.uid; match replace.and_then(|i| self.docs.get_mut(i).map(|d| (i, d))) { Some((i, d)) => { @@ -469,7 +482,7 @@ fn open_path(s: &mut Session, p: &Value) -> Result { 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); @@ -2124,3 +2137,35 @@ mod recent_tests { assert!(old.recent_files.is_empty(), "older prefs.json without the list still load"); } } + +#[cfg(test)] +mod hyphenation_default_tests { + use std::sync::Arc; + + use designcraft_doc::BASIC_PARAGRAPH; + use designcraft_doc::build::NewDocument; + use serde_json::json; + + use crate::Session; + + fn hyph_flags(d: &designcraft_doc::Document) -> (bool, bool, bool) { + let (pp, _) = d.styles.resolve_para(&designcraft_doc::ParaFormat::default()); + (pp.hyph_capitalized, pp.hyph_last_word, pp.hyph_across_column) + } + + #[test] + fn new_documents_hyphenate_no_capitalized_last_or_column_end_words_and_imports_keep_theirs() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + assert_eq!(hyph_flags(&s.doc().unwrap().doc), (false, false, false)); + + let mut d = designcraft_doc::Document::new(&NewDocument::default()); + let st = Arc::make_mut(&mut d.styles).para_mut(BASIC_PARAGRAPH).unwrap(); + st.para.hyph_capitalized = Some(true); + st.para.hyph_last_word = Some(true); + st.para.hyph_across_column = Some(true); + let b64 = super::base64_encode(&designcraft_idml::export_idml(&d)); + s.execute("file.openIdml", &json!({"base64": b64, "name": "in.idml"})).unwrap(); + assert_eq!(hyph_flags(&s.doc().unwrap().doc), (true, true, true)); + } +} diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index 3ba6970a1..a40e8da51 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -6,7 +6,7 @@ use std::sync::{Arc, Weak}; use designcraft_compose::grep::{Grep, GrepOptions, translate_replacement}; use designcraft_doc::story::floor_char_boundary; -use designcraft_doc::{Document, Selection, StoryId, TextSel}; +use designcraft_doc::{CellAddr, Document, Selection, StoryId, TextSel}; use serde_json::{Value, json}; use super::{CommandSpec, bad, bool_or, cmd, has_doc, str_param}; @@ -61,7 +61,7 @@ pub fn specs() -> Vec { } ), cmd!(query "find.find", "Find", ["Edit", "Find/Change"], None, - "{find, grep?: bool, caseSensitive?: bool, wholeWord?: bool, scope?: document|story|selection, story?, change?|first?|attrs?: ignored (find.change takes them; one request serves all three Find/Change commands)} → matches [{story, start, end, text}]", + "{find, grep?: bool, caseSensitive?: bool, wholeWord?: bool, scope?: document|story|selection, story?, change?|first?|attrs?: ignored (find.change takes them; one request serves all three Find/Change commands)} → matches [{story, start, end, text, cell?: {table, row, col}}] (document scope also searches table cells; start/end then index the cell's text)", has_doc, find), cmd!( "find.change", @@ -153,6 +153,9 @@ impl Matcher { } } +/// A searched text: a story, or one table cell of it, and the byte range inside. +type Target = (StoryId, Option, Range); + /// A search owns its original domain, not the selection used to paint the latest hit. /// Weak identity avoids retaining an old document merely for this transient cache. #[derive(Clone)] @@ -163,7 +166,7 @@ pub(crate) struct FindContext { selection: Selection, domain: Domain, matcher: Matcher, - targets: Vec<(StoryId, Range)>, + targets: Vec, order: Vec, target: usize, offset: usize, @@ -184,21 +187,25 @@ impl FindContext { self.target = 0; self.last_match_end = None; self.order = (0..self.targets.len()).collect(); - let caret = st.selection.text.filter(|t| t.cell.is_none() && st.selection.cells.is_none()); - if self.domain == Domain::Document - && let Some(t) = st.selection.text - && let Some(i) = self.targets.iter().position(|(sid, _)| *sid == t.story) - { + let caret = st.selection.text.filter(|_| st.selection.cells.is_none()); + // A caret in a searched cell starts there; one in a footnote starts at its story. + let start = st.selection.text.and_then(|t| { + let at = |want: Option| self.targets.iter().position(|(sid, cell, _)| *sid == t.story && *cell == want); + at(t.cell).or_else(|| at(None)) + }); + if let Some(i) = start.filter(|_| self.domain == Domain::Document) { self.order.rotate_left(i); } let first = self.order.first().and_then(|i| self.targets.get(*i)); self.offset = match first { - Some((sid, r)) if self.domain != Domain::Selection => caret.filter(|t| t.story == *sid).map_or(r.start, |t| t.range().end.min(r.end)), - Some((_, r)) => r.start, + Some((sid, cell, r)) if self.domain != Domain::Selection => { + caret.filter(|t| t.story == *sid && t.cell == *cell).map_or(r.start, |t| t.range().end.min(r.end)) + } + Some((_, _, r)) => r.start, None => 0, }; - if let Some((sid, r)) = first { - let text = target_text(&st.doc, *sid, r)?; + if let Some((sid, cell, r)) = first { + let text = target_text(&st.doc, *sid, *cell, r)?; if text.get(self.offset.saturating_sub(r.start)..).is_none() { return Err(bad("find", "invalid UTF-8 search cursor")); } @@ -209,7 +216,7 @@ impl FindContext { fn advance_target(&mut self) { self.target = self.target.saturating_add(1); self.last_match_end = None; - self.offset = self.order.get(self.target).and_then(|i| self.targets.get(*i)).map_or(0, |(_, r)| r.start); + self.offset = self.order.get(self.target).and_then(|i| self.targets.get(*i)).map_or(0, |(_, _, r)| r.start); } fn advance_empty(&mut self, text: &str, relative_end: usize, end: usize) { @@ -222,8 +229,8 @@ impl FindContext { } } -fn target_text<'a>(d: &'a Document, sid: StoryId, r: &Range) -> Result<&'a str> { - d.story(sid) +fn target_text<'a>(d: &'a Document, sid: StoryId, cell: Option, r: &Range) -> Result<&'a str> { + d.text_story(sid, cell) .ok_or_else(|| bad("find", "no such story"))? .text .get(r.clone()) @@ -246,7 +253,7 @@ fn scope(s: &Session, p: &Value) -> Result { let targets = match domain { Domain::Explicit(sid) => { let len = d.story(sid).ok_or_else(|| bad("find", "no such story"))?.len(); - vec![(sid, 0..len)] + vec![(sid, None, 0..len)] } Domain::Story | Domain::Selection => { if st.selection.cells.is_some() || st.selection.text.is_some_and(|t| t.cell.is_some()) { @@ -255,10 +262,21 @@ fn scope(s: &Session, p: &Value) -> Result { let t = st.selection.text.ok_or_else(|| bad("find", "no text selected"))?; let story = d.story(t.story).ok_or_else(|| bad("find", "no such story"))?; let r = if domain == Domain::Selection { t.range() } else { 0..story.len() }; - target_text(d, t.story, &r)?; - vec![(t.story, r)] + target_text(d, t.story, None, &r)?; + vec![(t.story, None, r)] } - Domain::Document => d.stories.values().map(|x| (x.id, 0..x.len())).collect(), + // Table cells hold their own text outside `stories`; search them right after the story that anchors the table. + Domain::Document => d + .stories + .values() + .flat_map(|x| { + let cells = x.tables.iter().flat_map(move |(id, t)| { + let cols = t.ncols().max(1); + t.cells.iter().enumerate().map(move |(i, c)| (x.id, Some(CellAddr { table: *id, row: i / cols, col: i % cols }), 0..c.text.len())) + }); + std::iter::once((x.id, None, 0..x.len())).chain(cells) + }) + .collect(), }; let mut context = FindContext { document: Arc::downgrade(d), @@ -285,10 +303,14 @@ fn find(s: &mut Session, p: &Value) -> Result { let context = scope(s, p)?; let st = s.doc()?; let mut out = Vec::new(); - for (sid, r) in &context.targets { - let text = target_text(&st.doc, *sid, r)?; + for (sid, cell, r) in &context.targets { + let text = target_text(&st.doc, *sid, *cell, r)?; for m in re.find_all(text, MAX_MATCHES.saturating_sub(out.len())).map_err(|e| bad("find", e))? { - out.push(json!({"story": sid.0, "start": r.start + m.start, "end": r.start + m.end, "text": text.get(m)})); + let mut hit = json!({"story": sid.0, "start": r.start + m.start, "end": r.start + m.end, "text": text.get(m)}); + if let Some(c) = cell { + hit["cell"] = json!({"table": c.table, "row": c.row, "col": c.col}); + } + out.push(hit); } } s.find_context = Some(context); @@ -317,8 +339,8 @@ fn change(s: &mut Session, p: &Value) -> Result { let targets = scope(s, p)?.targets; s.edit(|d, sel| { let mut count = 0usize; - for (sid, r) in targets { - let Some(story) = d.story(sid) else { continue }; + for (sid, cell, r) in targets { + let Some(story) = d.text_story(sid, cell) else { continue }; // Collect matches first (positions refer to the original text), apply back to front. let text = story.text.get(r.clone()).ok_or_else(|| bad("find", "invalid selection bounds or UTF-8 boundary"))?; let edits: Vec<(std::ops::Range, String)> = if grep { @@ -330,7 +352,7 @@ fn change(s: &mut Session, p: &Value) -> Result { .into_iter() .map(|(m, out)| (r.start + m.start..r.start + m.end, out)) .collect(); - let Some(st) = d.story_mut(sid) else { continue }; + let Some(st) = d.text_story_mut(sid, cell) else { continue }; for (range, new) in edits.iter().rev() { st.replace(range.clone(), new); if !over.is_empty() && !new.is_empty() { @@ -338,7 +360,7 @@ fn change(s: &mut Session, p: &Value) -> Result { } count += 1; } - if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid && t.cell.is_none()) { + if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid && t.cell == cell) { // Repair byte validity once against the final story, without remapping valid offsets. let range = t.range(); let start = floor_char_boundary(&st.text, range.start); @@ -365,8 +387,8 @@ fn find_next(s: &mut Session, p: &Value) -> Result { let mut context = scope(s, p)?; let st = s.doc()?; let mut hit = None; - while let Some((sid, r)) = context.order.get(context.target).and_then(|i| context.targets.get(*i)).cloned() { - let text = target_text(&st.doc, sid, &r)?; + while let Some((sid, cell, r)) = context.order.get(context.target).and_then(|i| context.targets.get(*i)).cloned() { + let text = target_text(&st.doc, sid, cell, &r)?; let from = context.offset.checked_sub(r.start).ok_or_else(|| bad("find", "invalid search cursor"))?; if text.get(from..).is_none() { return Err(bad("find", "invalid UTF-8 search cursor")); @@ -379,7 +401,7 @@ fn find_next(s: &mut Session, p: &Value) -> Result { context.advance_empty(text, m.end, b); continue; } - hit = Some((sid, a, b)); + hit = Some((sid, cell, a, b)); context.last_match_end = Some(b); if m.is_empty() { context.advance_empty(text, m.end, b); @@ -390,17 +412,22 @@ fn find_next(s: &mut Session, p: &Value) -> Result { } context.advance_target(); } - let Some((sid, a, b)) = hit else { + let Some((sid, cell, a, b)) = hit else { s.find_context = Some(context); return Ok(Value::Null); }; let frame = st.doc.story(sid).and_then(|x| x.frames.first().copied()); + let cell_json = cell.map(|c| json!({"table": c.table, "row": c.row, "col": c.col})); let stm = s.doc_mut()?; - stm.selection = Selection::text(TextSel { story: sid, anchor: a, focus: b, frame, cell: None }); + stm.selection = Selection::text(TextSel { story: sid, anchor: a, focus: b, frame, cell }); stm.bump_revision(); context.acknowledge(stm); s.find_context = Some(context); - Ok(json!({"story": sid.0, "start": a, "end": b})) + let mut out = json!({"story": sid.0, "start": a, "end": b}); + if let Some(c) = cell_json { + out["cell"] = c; + } + Ok(out) } #[cfg(test)] @@ -1007,6 +1034,31 @@ mod tests { assert_eq!(s.doc().unwrap().history.undo.len(), history); } + #[test] + fn document_scope_searches_and_changes_table_cells() { + let (mut s, sid) = scope_session("Ore 12.15 here"); + scope_select(&mut s, sid, 0, 0); + let table = s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + s.execute("table.setCell", &json!({"row": 0, "col": 1, "text": "Ore 12.15"})).unwrap(); + // The caret sits in the first cell, so Find Next goes to the second cell, then the story. + let p = json!({"find": "Ore 12.15", "scope": "document"}); + let cell = designcraft_doc::CellAddr { table, row: 0, col: 1 }; + assert_eq!(s.execute("find.next", &p).unwrap(), json!({"story": sid.0, "start": 0, "end": 9, "cell": {"table": table, "row": 0, "col": 1}})); + assert_eq!(s.doc().unwrap().selection.text.map(|t| (t.story, t.range(), t.cell)), Some((sid, 0..9, Some(cell)))); + let next = s.execute("find.next", &p).unwrap(); + assert!(next.get("start").is_some() && next.get("cell").is_none(), "{next}"); + let hits = s.execute("find.find", &p).unwrap(); + let hits = hits.as_array().unwrap(); + assert_eq!(hits.len(), 2); + let in_cell: Vec<_> = hits.iter().filter(|h| h.get("cell").is_some()).collect(); + assert_eq!(in_cell.len(), 1); + assert_eq!((in_cell[0]["cell"]["row"].as_u64(), in_cell[0]["cell"]["col"].as_u64()), (Some(0), Some(1))); + let n = s.execute("find.change", &json!({"find": "Ore 12.15", "change": "Ore 13.00", "scope": "document"})).unwrap(); + assert_eq!(n["count"], 2); + assert!(s.execute("find.find", &json!({"find": "12.15", "scope": "document"})).unwrap().as_array().unwrap().is_empty()); + assert_eq!(s.execute("find.find", &json!({"find": "13.00", "scope": "document"})).unwrap().as_array().unwrap().len(), 2); + } + fn child_scope_session(note: bool, child: &str) -> (Session, designcraft_doc::StoryId, designcraft_doc::TextSel) { let (mut s, sid) = scope_session("red"); scope_select(&mut s, sid, 3, 3); @@ -1051,12 +1103,15 @@ mod tests { p["story"] = json!(sid.0); p["scope"] = json!("selection"); } + // Document scope also covers table cells (#1624); an explicit story and footnotes stay top-level. + let in_cell = if explicit || note { 0 } else { child.matches("red").count() }; + let changed = if in_cell > 0 { child.replace("red", "blue") } else { child.to_string() }; s.doc_mut().unwrap().selection = selection.clone(); - assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1 + in_cell); scope_hit(&mut s, &p, sid, 0, 3); s.doc_mut().unwrap().selection = selection.clone(); - assert_eq!(s.execute("find.change", &p).unwrap()["count"], 1); - assert_eq!(s.doc().unwrap().doc.text_story(sid, selected.cell).unwrap().text, child); + assert_eq!(s.execute("find.change", &p).unwrap()["count"], 1 + in_cell); + assert_eq!(s.doc().unwrap().doc.text_story(sid, selected.cell).unwrap().text, changed); assert_eq!(s.doc().unwrap().selection, selection, "parent replacement does not clamp child offsets"); p["find"] = json!("blue"); p["change"] = json!("red"); diff --git a/crates/engine/src/cmd/flex.rs b/crates/engine/src/cmd/flex.rs index 9b0f56b16..8d7117858 100644 --- a/crates/engine/src/cmd/flex.rs +++ b/crates/engine/src/cmd/flex.rs @@ -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 = 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::() / gaps.len().max(1) as f64) - }; +fn in_line_gap(bounds: &[Rect], row: bool, tol: f64) -> Option { + 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 = 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::() / 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() }, } } diff --git a/crates/engine/src/cmd/index.rs b/crates/engine/src/cmd/index.rs index bb6f09917..38a6bc49d 100644 --- a/crates/engine/src/cmd/index.rs +++ b/crates/engine/src/cmd/index.rs @@ -72,7 +72,9 @@ fn add_reference(s: &mut Session, p: &Value) -> Result { "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) -> 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, }; diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 047bd90ba..d67f469f0 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -161,14 +161,25 @@ pub fn specs() -> Vec { "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))); + } +} diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 6623b6cb8..c39e05668 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -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 { 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 { 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 { diff --git a/crates/engine/src/cmd/lists.rs b/crates/engine/src/cmd/lists.rs index 9f8824870..74570f3fd 100644 --- a/crates/engine/src/cmd/lists.rs +++ b/crates/engine/src/cmd/lists.rs @@ -1,13 +1,37 @@ //! Type › Bulleted & Numbered Lists › Define Lists: named lists whose numbers can continue from //! story to story. Paragraphs join one with `listName` (and restart with `startAt`). -use designcraft_doc::NumberedList; +use designcraft_doc::{ListType, NumberedList}; use serde_json::{Value, json}; -use super::{CommandSpec, bad, cmd, has_doc, str_param}; +use super::{CommandSpec, bad, cmd, has_doc, has_text_or_frames, str_param}; +use crate::{Result, Session}; + +/// The hanging indent a list gets from the Bulleted and Numbered List buttons, as InDesign's +/// default (Left Indent 0.25 in, First Line Indent −0.25 in): the bullet or number at the margin, +/// the text after its tab at the left indent. +pub const LIST_HANG: f64 = 18.0; pub fn specs() -> Vec { vec![ + cmd!( + "list.bullets", + "Apply Bullets", + ["Type", "Bulleted & Numbered Lists"], + None, + "{on?: bool (default: off when every selected paragraph has bullets, else on)} — the Bulleted List button: paragraphs with no indents get the default hanging indent (left 18 pt, first line −18 pt); taking bullets off removes that indent if it is still as applied", + has_text_or_frames, + |s, p| apply_list(s, p, ListType::Bullets) + ), + cmd!( + "list.numbers", + "Apply Numbers", + ["Type", "Bulleted & Numbered Lists"], + None, + "{on?: bool (default: off when every selected paragraph is numbered, else on)} — the Numbered List button, as list.bullets", + has_text_or_frames, + |s, p| apply_list(s, p, ListType::Numbers) + ), cmd!( "list.define", "Define Lists…", @@ -48,12 +72,181 @@ pub fn specs() -> Vec { ] } +/// Bullets or numbers on (or off) for the selected paragraphs, as InDesign's Bulleted and +/// Numbered List buttons: one undo step. Values equal to the paragraph style's are left to the +/// style rather than set locally, so a paragraph back to its style shows no override. +fn apply_list(s: &mut Session, p: &Value, kind: ListType) -> Result { + let name = if kind == ListType::Bullets { "list.bullets" } else { "list.numbers" }; + let on = match p.get("on") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_bool().ok_or_else(|| bad(name, "`on` must be true or false"))?), + }; + let targets = super::text::format_targets_pub(s); + if targets.is_empty() { + return Err(bad(name, "select text or a text frame")); + } + // Off when every selected paragraph already has this list, as the button shows it pressed. + let on = on.unwrap_or_else(|| !s.doc().is_ok_and(|st| all_have(&st.doc, &targets, kind))); + s.edit(|d, _| { + let styles = d.styles.clone(); + for t in &targets { + let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; + let span = para_span(&st.text, &t.range); + st.format_paras(span, |pf| { + let (now, _) = styles.resolve_para(pf); + // Off takes away this list only: a paragraph with the other list keeps it. + if !on && now.list_type != kind { + return; + } + let (style, _) = styles.resolve_para_style(&pf.style); + // The hanging indent this button set (and nothing has changed since). + let own_hang = pf.para.list_hang == Some(true) && now.left_indent == LIST_HANG && now.first_line_indent == -LIST_HANG; + // A value: left to the style when it gives the same, else set here. + let mut set = |key: &str, v: Value, same: bool| { + let _ = pf.para.set_json(key, if same { &Value::Null } else { &v }); + }; + let list = if on { kind } else { ListType::None }; + set("listType", serde_json::to_value(list).unwrap_or(Value::Null), style.list_type == list); + if on && now.left_indent == 0.0 && now.first_line_indent == 0.0 { + set("leftIndent", json!(LIST_HANG), style.left_indent == LIST_HANG); + set("firstLineIndent", json!(-LIST_HANG), style.first_line_indent == -LIST_HANG); + set("listHang", json!(true), false); + } else if !on && now.list_type == kind { + // Only the hanging indent set here: one the paragraph had before, or its style + // gives, stays. + if own_hang { + set("leftIndent", json!(0.0), style.left_indent == 0.0); + set("firstLineIndent", json!(0.0), style.first_line_indent == 0.0); + } + set("listHang", Value::Null, true); + } + }); + } + Ok(json!({"on": on})) + }) +} + +/// The range whose paragraphs a list applies to: a selection ending where a paragraph starts (after +/// a return) doesn't reach into that paragraph. The story's last paragraph, empty after a final +/// return, stays in when the range runs to the end (a whole frame). +fn para_span(text: &str, range: &std::ops::Range) -> std::ops::Range { + let end = range.end.max(range.start); + if end > range.start && end < text.len() && text.as_bytes().get(end - 1) == Some(&b'\n') { range.start..end - 1 } else { range.start..end } +} + +/// Whether every paragraph of `targets` has list `kind`. +fn all_have(d: &designcraft_doc::Document, targets: &[super::text::Target], kind: ListType) -> bool { + targets.iter().all(|t| { + d.text_story(t.story, t.cell).is_some_and(|st| { + let span = para_span(&st.text, &t.range); + let (a, b) = (st.para_at(span.start), st.para_at(span.end)); + st.paras.get(a..=b).is_some_and(|ps| ps.iter().all(|pf| d.styles.resolve_para(pf).0.list_type == kind)) + }) + }) +} + +impl Session { + /// Whether the Bulleted or Numbered List button (and Type › Apply Bullets or Numbers) shows + /// pressed: every paragraph the list commands apply to has list `kind`, through the selected + /// text, table cells or text frames. + pub fn selection_has_list(&self, kind: ListType) -> bool { + let targets = super::text::format_targets_pub(self); + !targets.is_empty() && self.doc().is_ok_and(|st| all_have(&st.doc, &targets, kind)) + } +} + #[cfg(test)] mod tests { use serde_json::json; use crate::Session; + /// The Bulleted List button, as InDesign's: bullets with the default hanging indent (the + /// bullet at the margin, the text 18 pt in); again, bullets and that indent go, back to the + /// style. Numbers swap in keeping the indent; paragraphs with their own indents keep them. + #[test] + fn list_buttons_apply_the_default_hanging_indent() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Eggs\nMilk\nFlour"})).unwrap(); + let sid = f["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 9})).unwrap(); + let para = |s: &Session, i: usize| { + let d = &s.doc().unwrap().doc; + let st = d.story(designcraft_doc::StoryId(sid)).unwrap(); + (d.styles.resolve_para(&st.paras[i]).0, st.paras[i].para.clone()) + }; + let undo = s.doc().unwrap().history.undo.len(); + assert_eq!(s.execute("list.bullets", &json!({})).unwrap()["on"], true); + for i in 0..2 { + let (p, _) = para(&s, i); + assert_eq!((p.list_type, p.left_indent, p.first_line_indent), (designcraft_doc::ListType::Bullets, 18.0, -18.0)); + } + assert_eq!(para(&s, 2).0.list_type, designcraft_doc::ListType::None, "the paragraph outside the selection"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + // The number of a bullet's tab: the text starts at the left indent. + let d = &s.doc().unwrap().doc; + let cs = designcraft_compose::compose_story(d, designcraft_doc::StoryId(sid), &Default::default()); + let line = &cs.frames[0].lines[0]; + let text_x = line.glyphs.iter().filter(|g| g.len > 0).map(|g| g.x).next().unwrap(); + assert!((text_x - line.x0 - 18.0).abs() < 0.5, "the text at the hanging indent: {} from {}", text_x, line.x0); + // Numbers instead: the indent stays. + s.execute("list.numbers", &json!({})).unwrap(); + let (p, _) = para(&s, 0); + assert_eq!((p.list_type, p.left_indent), (designcraft_doc::ListType::Numbers, 18.0)); + // Numbers off: list and hanging indent go, and nothing is left set on the paragraph. + assert_eq!(s.execute("list.numbers", &json!({})).unwrap()["on"], false); + let (p, own) = para(&s, 0); + assert_eq!((p.list_type, p.left_indent, p.first_line_indent), (designcraft_doc::ListType::None, 0.0, 0.0)); + assert_eq!(own, designcraft_doc::ParaAttrs::default(), "back to the style"); + // A paragraph with its own indents keeps them. + s.execute("type.para", &json!({"attrs": {"leftIndent": 36}})).unwrap(); + s.execute("list.bullets", &json!({"on": true})).unwrap(); + let (p, _) = para(&s, 0); + assert_eq!((p.left_indent, p.first_line_indent), (36.0, 0.0)); + s.execute("list.bullets", &json!({"on": false})).unwrap(); + assert_eq!(para(&s, 0).0.left_indent, 36.0); + assert!(s.execute("list.bullets", &json!({"on": "yes"})).is_err()); + } + + /// Pressed means every targeted paragraph has the list: a selection reaching an unbulleted + /// paragraph, or a frame with one, isn't. Explicitly off, a list button takes away its own + /// list only: a bulleted paragraph keeps bullets and indent when Numbers go off. + #[test] + fn a_list_is_pressed_and_turned_off_for_its_own_paragraphs() { + use designcraft_doc::ListType; + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Eggs\nMilk\nFlour"})).unwrap(); + let sid = f["story"].as_u64().unwrap(); + let select = |s: &mut Session, a: usize, b: usize| { + s.execute("text.select", &json!({"story": sid, "anchor": a, "focus": b})).unwrap(); + }; + select(&mut s, 0, 3); + s.execute("list.bullets", &json!({})).unwrap(); + assert!(s.selection_has_list(ListType::Bullets)); + assert!(!s.selection_has_list(ListType::Numbers)); + // From the bulleted Eggs into the plain Milk: not pressed, and a press bullets both. + select(&mut s, 0, 7); + assert!(!s.selection_has_list(ListType::Bullets)); + // The frame selected: its whole story, Flour unbulleted. + s.execute("selection.set", &json!({"ids": [f["id"]]})).unwrap(); + assert!(!s.selection_has_list(ListType::Bullets)); + // Eggs bulleted, Milk numbered; Numbers off over both leaves Eggs' bullets and indent. + select(&mut s, 5, 7); + s.execute("list.numbers", &json!({"on": true})).unwrap(); + select(&mut s, 0, 7); + s.execute("list.numbers", &json!({"on": false})).unwrap(); + let d = &s.doc().unwrap().doc; + let st = d.story(designcraft_doc::StoryId(sid)).unwrap(); + let p = |i: usize| d.styles.resolve_para(&st.paras[i]).0; + assert_eq!((p(0).list_type, p(0).left_indent, p(0).first_line_indent), (ListType::Bullets, 18.0, -18.0)); + assert_eq!((p(1).list_type, p(1).left_indent), (ListType::None, 0.0)); + // With no text or frame selected, nothing is pressed. + s.execute("selection.set", &json!({"ids": []})).unwrap(); + assert!(!s.selection_has_list(ListType::Bullets)); + } + /// The digit the story's first line starts with (its list number). fn digit(g: &designcraft_compose::PlacedGlyph) -> char { let face = g.face.get(); @@ -99,4 +292,74 @@ mod tests { s.execute("type.para", &json!({"attrs": {"startAt": null}})).unwrap(); assert_eq!(first_char(&s, stories[1]), '4'); } + + /// A selection ending right after a return (the whole first paragraph, as a triple click selects + /// it) applies the list to that paragraph only, and the button shows it pressed. + #[test] + fn a_selection_ending_at_a_paragraph_start_leaves_that_paragraph_out() { + use designcraft_doc::ListType; + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let f = s + .execute( + "frame.create", + &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Eggs +Milk +Flour +"}), + ) + .unwrap(); + let sid = f["story"].as_u64().unwrap(); + let kinds = |s: &Session| { + let d = &s.doc().unwrap().doc; + d.story(designcraft_doc::StoryId(sid)).unwrap().paras.iter().map(|p| d.styles.resolve_para(p).0.list_type).collect::>() + }; + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 5})).unwrap(); + assert!(!s.selection_has_list(ListType::Bullets)); + assert_eq!(s.execute("list.bullets", &json!({})).unwrap()["on"], true); + assert_eq!(kinds(&s), [ListType::Bullets, ListType::None, ListType::None, ListType::None]); + assert!(s.selection_has_list(ListType::Bullets), "pressed for the paragraph selected"); + // The whole frame still takes in its last, empty paragraph. + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 16})).unwrap(); + s.execute("list.numbers", &json!({"on": true})).unwrap(); + assert_eq!(kinds(&s), [ListType::Numbers; 4]); + } + + /// A paragraph's own hanging indent, the same as the button's, stays when the list goes off: + /// the button takes away only the indent it set. + #[test] + fn a_paragraphs_own_hanging_indent_stays_when_the_list_goes_off() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Eggs"})).unwrap(); + let sid = f["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 4})).unwrap(); + s.execute("type.para", &json!({"attrs": {"leftIndent": 18, "firstLineIndent": -18}})).unwrap(); + s.execute("list.bullets", &json!({"on": true})).unwrap(); + s.execute("list.bullets", &json!({"on": false})).unwrap(); + let d = &s.doc().unwrap().doc; + let pf = &d.story(designcraft_doc::StoryId(sid)).unwrap().paras[0]; + let p = d.styles.resolve_para(pf).0; + assert_eq!((p.list_type, p.left_indent, p.first_line_indent), (designcraft_doc::ListType::None, 18.0, -18.0)); + assert_eq!(pf.para.list_hang, None, "nothing of the list left"); + } + + /// A hanging indent the paragraph style gives stays when the list goes off. + #[test] + fn a_style_hanging_indent_stays_when_the_list_goes_off() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Eggs"})).unwrap(); + let sid = f["story"].as_u64().unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Hang", "para": {"leftIndent": 18, "firstLineIndent": -18}})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 4})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Hang"})).unwrap(); + s.execute("list.bullets", &json!({"on": true})).unwrap(); + s.execute("list.bullets", &json!({"on": false})).unwrap(); + let d = &s.doc().unwrap().doc; + let pf = &d.story(designcraft_doc::StoryId(sid)).unwrap().paras[0]; + let p = d.styles.resolve_para(pf).0; + assert_eq!((p.list_type, p.left_indent, p.first_line_indent), (designcraft_doc::ListType::None, 18.0, -18.0)); + assert_eq!(pf.para, designcraft_doc::ParaAttrs::default(), "nothing left set on the paragraph"); + } } diff --git a/crates/engine/src/cmd/load_styles.rs b/crates/engine/src/cmd/load_styles.rs index 19a5a6794..b89b42fa5 100644 --- a/crates/engine/src/cmd/load_styles.rs +++ b/crates/engine/src/cmd/load_styles.rs @@ -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 { - 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 { + 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 { 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 { + 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 { 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 { 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 { } } } - // 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 = { diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 4ccb5c823..84881b067 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -598,7 +598,7 @@ pub fn specs() -> Vec { "Text Frame Options…", ["Object"], Some("Cmd+B"), - "{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, columnsKind?: fixedNumber|fixedWidth, columnWidth? (pt, Fixed Width), firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin? (pt), autoSize?: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference?: topLeft|topCenter|topRight|leftCenter|center|rightCenter|bottomLeft|bottomCenter|bottomRight (the point that stays put), useMinimumHeight?: bool, minimumHeight? (pt), useMinimumWidth?: bool, minimumWidth? (pt), noLineBreaks?: bool, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}", + "{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, columnsKind?: fixedNumber|fixedWidth, columnWidth? (pt, Fixed Width), firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin? (pt), autoSize?: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference?: topLeft|topCenter|topRight|leftCenter|center|rightCenter|bottomLeft|bottomCenter|bottomRight (the point that stays put), useMinimumHeight?: bool, minimumHeight? (pt), useMinimumWidth?: bool, minimumWidth? (pt), noLineBreaks?: bool, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), baselineGrid?: {start? (pt), increment? (pt), relativeTo?: page|margin|frame|inset} (the frame's custom baseline grid; fields not given keep the frame's, or come from the document grid; null turns it off), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}", has_selection, text_frame_options ), @@ -686,7 +686,7 @@ pub fn specs() -> Vec { "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 { 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 { 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| { @@ -2506,6 +2506,41 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result { as u8, ), }; + // `Some(None)` turns the frame's custom baseline grid off. + let custom_grid = match p.get("baselineGrid") { + None => None, + Some(Value::Null) => Some(None), + Some(Value::Object(g)) => { + let num = |key: &str, lo: f64, hi: f64| match g.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => match v.as_f64() { + Some(x) if x.is_finite() && (lo..=hi).contains(&x) => Ok(Some(x)), + _ => Err(bad(ID, format!("baselineGrid.{key} must be a number of points from {lo} to {hi}, not {v}"))), + }, + }; + let start = num("start", -15552.0, 15552.0)?; + let increment = num("increment", 0.0, 15552.0)?; + let relative_to = match g.get("relativeTo") { + None | Some(Value::Null) => None, + Some(v) => Some( + v.as_str() + .and_then(designcraft_doc::FrameGridRelative::from_name) + .ok_or_else(|| bad(ID, format!("baselineGrid.relativeTo must be page|margin|frame|inset, not {v}")))?, + ), + }; + Some(Some((start, increment, relative_to))) + } + Some(v) => return Err(bad(ID, format!("baselineGrid must be an object or null, not {v}"))), + }; + // A frame without a custom grid starts from the document's. + let doc_grid = { + let g = &st.doc.settings.baseline_grid; + let relative_to = match g.relative_to { + designcraft_doc::GridRelative::TopOfPage => designcraft_doc::FrameGridRelative::TopOfPage, + designcraft_doc::GridRelative::TopMargin => designcraft_doc::FrameGridRelative::TopMargin, + }; + designcraft_doc::FrameBaselineGrid { start: g.start, increment: g.increment, relative_to } + }; let rule_color = match p.get("columnRuleColor") { None | Some(Value::Null) => None, Some(Value::String(c)) if st.doc.swatch(c).is_some() => Some(c.clone()), @@ -2517,7 +2552,7 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result { 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); @@ -2596,6 +2631,17 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result { if let Some(v) = rule_bottom { o.column_rule_bottom_inset = v; } + match custom_grid { + Some(Some((start, increment, relative_to))) => { + let mut g = o.baseline_grid.unwrap_or(doc_grid); + g.start = start.unwrap_or(g.start); + g.increment = increment.unwrap_or(g.increment); + g.relative_to = relative_to.unwrap_or(g.relative_to); + o.baseline_grid = Some(g); + } + Some(None) => o.baseline_grid = None, + None => {} + } // Fixed Width columns set the frame's width: n columns, the gutters between them and // the left and right insets. let fixed_width = o.columns_kind == designcraft_doc::ColumnsKind::FixedWidth && o.column_width > 0.0; @@ -3074,8 +3120,18 @@ fn align_reference(d: &Document, ids: &[ItemId], to: &str, key: Option) fn align(s: &mut Session, p: &Value) -> Result { 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 { @@ -3788,6 +3844,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)] @@ -4498,6 +4569,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)] @@ -5569,3 +5655,33 @@ mod fill_tint_tests { assert!(stroke_of(&s, b).is_none()); } } + +#[cfg(test)] +mod frame_baseline_grid_tests { + use super::*; + use designcraft_doc::{FrameBaselineGrid, FrameGridRelative}; + + #[test] + fn text_frame_options_set_a_custom_baseline_grid_relative_to() { + 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", "text": "Hello"})).unwrap(); + let id = ItemId(r["id"].as_u64().unwrap()); + let grid = |s: &Session| s.doc().unwrap().doc.item(id).and_then(|i| i.text_frame()).and_then(|t| t.options.baseline_grid); + // Turning it on with only Relative To takes the document grid's start and increment. + let doc = s.doc().unwrap().doc.settings.baseline_grid.clone(); + s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": {"relativeTo": "frame"}})).unwrap(); + assert_eq!(grid(&s), Some(FrameBaselineGrid { start: doc.start, increment: doc.increment, relative_to: FrameGridRelative::TopOfFrame })); + s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": {"start": 10, "increment": 13, "relativeTo": "inset"}})).unwrap(); + assert_eq!(grid(&s), Some(FrameBaselineGrid { start: 10.0, increment: 13.0, relative_to: FrameGridRelative::TopInset })); + // Fields not given keep the frame's. + s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": {"relativeTo": "margin"}})).unwrap(); + assert_eq!(grid(&s), Some(FrameBaselineGrid { start: 10.0, increment: 13.0, relative_to: FrameGridRelative::TopMargin })); + for bad in [json!({"relativeTo": "spine"}), json!({"start": f64::MAX}), json!({"increment": -1}), json!(3)] { + assert!(s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": bad})).is_err(), "{bad}"); + } + assert_eq!(grid(&s).map(|g| g.relative_to), Some(FrameGridRelative::TopMargin)); + s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": null})).unwrap(); + assert_eq!(grid(&s), None); + } +} diff --git a/crates/engine/src/cmd/pdflayers.rs b/crates/engine/src/cmd/pdflayers.rs index c8badf533..937fe81c0 100644 --- a/crates/engine/src/cmd/pdflayers.rs +++ b/crates/engine/src/cmd/pdflayers.rs @@ -42,6 +42,22 @@ pub fn specs() -> Vec { 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 { + two_layer_pdf_generation(0) + } + + fn two_layer_pdf_generation(generation: i32) -> Vec { + two_layer_pdf_options(generation, "", "") + } + + fn two_layer_pdf_options(generation: i32, config: &str, trailer: &str) -> Vec { 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 = [ - "<>>>>>".to_string(), + format!( + "<>>>>>" + ), "<>".to_string(), "<>>>/Contents 4 0 R>>".to_string(), format!("<>\nstream\n{content}\nendstream", content.len()), "<>".to_string(), "<>".to_string(), + "<>\nstream\n\nendstream".to_string(), + "<>".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<>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1)); + t.push_str(&format!("trailer\n<>\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<>\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<>\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) { + 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<>\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::().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 <>\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<>>>"] { + let content = "0 0 1 rg 0 0 50 100 re f"; + let objs = [ + format!("<>"), + "<>".into(), + "<>".into(), + format!("<>\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<>\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(); diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs index c54b55084..1053fafc0 100644 --- a/crates/engine/src/cmd/place_text.rs +++ b/crates/engine/src/cmd/place_text.rs @@ -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 { @@ -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::>(), 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::>(), 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::>(), ids); + assert!(d.story(StoryId(r["story"].as_u64().unwrap())).is_none()); + assert_eq!(d.spreads.iter().map(|sp| sp.items.len()).sum::(), 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::(), 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 diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 4f79f13ad..0bb280780 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -17,7 +17,7 @@ pub fn specs() -> Vec { "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()); } } diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 7049c2f39..776dd5e82 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -297,7 +297,7 @@ fn table_specs() -> Vec { "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 { 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 { 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 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 { + 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 { 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 { 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 = if column { t.columns.iter().map(|c| c.width).collect() @@ -1289,14 +1322,7 @@ fn resize_border(s: &mut Session, p: &Value) -> Result { (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 { } 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 { .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(); diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 12bd540b7..3a40844be 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -1502,12 +1502,29 @@ fn ruby_cmd(s: &mut Session, p: &Value) -> Result { /// 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 { 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}"); + } + } +} diff --git a/crates/engine/src/cmd/thread.rs b/crates/engine/src/cmd/thread.rs index d5056d463..51bab42db 100644 --- a/crates/engine/src/cmd/thread.rs +++ b/crates/engine/src/cmd/thread.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { "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 ), diff --git a/crates/engine/src/cmd/xref.rs b/crates/engine/src/cmd/xref.rs index d462f3d1b..446148095 100644 --- a/crates/engine/src/cmd/xref.rs +++ b/crates/engine/src/cmd/xref.rs @@ -22,7 +22,7 @@ pub fn specs() -> Vec { "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 { fn create_anchor(s: &mut Session, p: &Value) -> Result { 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(); diff --git a/crates/engine/src/guard.rs b/crates/engine/src/guard.rs index 2ac6bb63c..6395c3594 100644 --- a/crates/engine/src/guard.rs +++ b/crates/engine/src/guard.rs @@ -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::().cloned()) .unwrap_or_else(|| "unknown panic".into()) } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 5378a411e..b493edf35 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -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 { 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). diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d16dc9dd1..545c3fac3 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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, 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 diff --git a/crates/engine/src/math.rs b/crates/engine/src/math.rs index 224420953..16ef7e234 100644 --- a/crates/engine/src/math.rs +++ b/crates/engine/src/math.rs @@ -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; } diff --git a/crates/engine/src/recovery.rs b/crates/engine/src/recovery.rs index eed263f04..7ec62d073 100644 --- a/crates/engine/src/recovery.rs +++ b/crates/engine/src/recovery.rs @@ -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::().is_ok()); + let numbered = path.file_stem().and_then(std::ffi::OsStr::to_str).is_some_and(|s| s.parse::().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> { 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::().ok()) else { continue }; + let Some(uid) = p.file_stem().and_then(std::ffi::OsStr::to_str).and_then(|s| s.parse::().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() diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 42f57b3a1..b1792485d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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"); +} diff --git a/crates/engine/src/tests_linked_paths.rs b/crates/engine/src/tests_linked_paths.rs index eb4c953ec..448ffedb9 100644 --- a/crates/engine/src/tests_linked_paths.rs +++ b/crates/engine/src/tests_linked_paths.rs @@ -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); +} diff --git a/crates/engine/src/tests_table.rs b/crates/engine/src/tests_table.rs index 59d2f267b..2fc4fd338 100644 --- a/crates/engine/src/tests_table.rs +++ b/crates/engine/src/tests_table.rs @@ -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); +} diff --git a/crates/engine/tests/it/pdf_backend.rs b/crates/engine/tests/it/pdf_backend.rs index 6b4f93081..5344dc6f7 100644 --- a/crates/engine/tests/it/pdf_backend.rs +++ b/crates/engine/tests/it/pdf_backend.rs @@ -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"); diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index 1df759608..348b9c80e 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -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 = 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, 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 = 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))) diff --git a/crates/fonts/build.rs b/crates/fonts/build.rs index 651fed196..497c41f83 100644 --- a/crates/fonts/build.rs +++ b/crates/fonts/build.rs @@ -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=` 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 { 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}"))?; diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 352e0952b..1c1e55bd1 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -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) -> FaceRef { - static LEAKED: RwLock>> = RwLock::new(Vec::new()); + static LEAKED: RwLock>> = 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> = std::sync::OnceLock::new(); + static CHAIN: std::sync::OnceLock> = 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>> { - 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> { 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) -> 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 = { - 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> { 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 = 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>, 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 = self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter().map(|e| e.path.clone()).collect(); + let mut paths: Vec = 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 { 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> { @@ -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); } diff --git a/crates/geom/src/trace.rs b/crates/geom/src/trace.rs index b2d850ea9..292f82e87 100644 --- a/crates/geom/src/trace.rs +++ b/crates/geom/src/trace.rs @@ -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> { +/// 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> { + 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, } diff --git a/crates/geom/src/units.rs b/crates/geom/src/units.rs index da1676d4b..f1077ffce 100644 --- a/crates/geom/src/units.rs +++ b/crates/geom/src/units.rs @@ -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() { diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 99dcb383e..0106364e2 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -177,7 +177,7 @@ impl<'a> Ex<'a> { } fn assign_swatch_ids(&mut self) { - let mut used: HashSet = ["Swatch/None", "Color/Paper", "Color/Black", "Color/Registration"].iter().map(|s| s.to_string()).collect(); + let mut used: HashSet = ["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)); @@ -2012,12 +1944,21 @@ impl<'a> Ex<'a> { } if let Content::Text(t) = &it.content { el.push(text_frame_pref(&t.options, &self.sw(&t.options.column_rule_color))); - if let Some((start, inc)) = t.options.baseline_grid { + if let Some(g) = t.options.baseline_grid { el.push( El::new("BaselineFrameGridOption") .attr("UseCustomBaselineFrameGrid", "true") - .attr("StartingOffsetForBaselineFrameGrid", num(start)) - .attr("BaselineFrameGridIncrement", num(inc)), + .attr("StartingOffsetForBaselineFrameGrid", num(g.start)) + .attr( + "BaselineFrameGridRelativeOption", + match g.relative_to { + designcraft_doc::FrameGridRelative::TopOfPage => "TopOfPageOfBaselineFrameGridRelativeOption", + designcraft_doc::FrameGridRelative::TopMargin => "TopOfMarginOfBaselineFrameGridRelativeOption", + designcraft_doc::FrameGridRelative::TopOfFrame => "TopOfFrameOfBaselineFrameGridRelativeOption", + designcraft_doc::FrameGridRelative::TopInset => "TopOfInsetOfBaselineFrameGridRelativeOption", + }, + ) + .attr("BaselineFrameGridIncrement", num(g.increment)), ); } } @@ -2414,6 +2355,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 +2366,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 +2740,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 +2767,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 { + 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 +3138,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 +3363,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)); } diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index ef504065e..77d730fa0 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -295,6 +295,10 @@ struct Importer<'r> { parents: Vec, /// Things read but not kept as they were, for the import report. warnings: Vec, + /// 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, spreads: Vec, page_index: HashMap, @@ -387,13 +391,10 @@ fn mojikumi_row(e: &El) -> Result { 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 { - 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 { - let v = nums(s); + let v: Vec = 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 = 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> = e.prop("GradientFillStart").map(|s| finite_nums(&s)); + let start: Option> = 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> = e.prop("GradientStrokeStart").map(|s| finite_nums(&s)); + let start: Option> = 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 { @@ -2967,8 +2974,11 @@ impl<'r> Importer<'r> { if let Some(g) = self.with_style(e, "BaselineFrameGridOption") && g.get("UseCustomBaselineFrameGrid") == Some("true") { - options.baseline_grid = - Some((g.num("StartingOffsetForBaselineFrameGrid").unwrap_or(0.0), g.num("BaselineFrameGridIncrement").unwrap_or(12.0))); + options.baseline_grid = Some(designcraft_doc::FrameBaselineGrid { + start: g.num("StartingOffsetForBaselineFrameGrid").unwrap_or(0.0), + increment: g.num("BaselineFrameGridIncrement").unwrap_or(12.0), + relative_to: g.get("BaselineFrameGridRelativeOption").map(frame_grid_relative).unwrap_or_default(), + }); } it.content = Content::Text(TextFrame { story, options }); self.ctx.threads.push(( @@ -3509,6 +3519,19 @@ fn fit_columns(mut columns: Columns, margins: &Margins, page_width: f64) -> Colu columns } +/// A `BaselineFrameGridRelativeOption` value (`TopOfInsetOfBaselineFrameGridRelativeOption`, or a +/// short form such as `TopOfInset`); unknown values measure from the top of the page. +fn frame_grid_relative(v: &str) -> designcraft_doc::FrameGridRelative { + use designcraft_doc::FrameGridRelative as R; + let v = v.strip_suffix("OfBaselineFrameGridRelativeOption").unwrap_or(v); + match v { + "TopOfMargin" | "TopMargin" => R::TopMargin, + "TopOfFrame" => R::TopOfFrame, + "TopOfInset" | "TopInset" => R::TopInset, + _ => R::TopOfPage, + } +} + /// Text Frame Options from `TextFramePreference` elements: each attribute from the first that sets it. fn text_frame_options(prefs: &[&El]) -> TextFrameOptions { let e = Prefs(prefs); @@ -3702,6 +3725,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::().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 +3884,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 = 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::*; diff --git a/crates/idml/src/names.rs b/crates/idml/src/names.rs index 569244470..b9102c9ae 100644 --- a/crates/idml/src/names.rs +++ b/crates/idml/src/names.rs @@ -144,16 +144,37 @@ pub const TABLE_BUILTINS: &[(&str, &str)] = &[("[Basic Table]", "$ID/[Basic Tabl pub const OBJECT_BUILTINS: &[(&str, &str)] = &[("[None]", "$ID/[None]"), ("[Basic Graphics Frame]", "$ID/[Normal Graphics Frame]"), ("[Basic Text Frame]", "$ID/[Normal Text Frame]")]; -/// Our style name → IDML `Name` (groups: `A/B` → `A:B`). +/// Our style name → IDML `Name` (groups: `A/B` → `A:B`; a literal `:` or `\` is escaped as `\:` +/// or `\\`, so it is not read back as a group separator). pub fn style_name_out(builtins: &[(&str, &str)], name: &str) -> String { - builtins.iter().find(|(o, _)| *o == name).map(|(_, i)| i.to_string()).unwrap_or_else(|| name.replace('/', ":")) + builtins + .iter() + .find(|(o, _)| *o == name) + .map(|(_, i)| i.to_string()) + .unwrap_or_else(|| name.replace('\\', "\\\\").replace(':', "\\:").replace('/', ":")) } /// IDML `Name` → our style name. pub fn style_name_in(builtins: &[(&str, &str)], name: &str) -> String { if let Some((o, _)) = builtins.iter().find(|(_, i)| *i == name) { return o.to_string(); } - name.strip_prefix("$ID/").unwrap_or(name).replace(':', "/") + let name = name.strip_prefix("$ID/").unwrap_or(name); + let mut out = String::with_capacity(name.len()); + let mut chars = name.chars(); + while let Some(c) = chars.next() { + match c { + '\\' => match chars.clone().next() { + Some(next @ ('\\' | ':')) => { + out.push(next); + chars.next(); + } + _ => out.push('\\'), + }, + ':' => out.push('/'), + _ => out.push(c), + } + } + out } /// `ParagraphStyle/…` Self id for one of our names. pub fn style_self(kind: &str, builtins: &[(&str, &str)], name: &str) -> String { diff --git a/crates/idml/src/ruby.rs b/crates/idml/src/ruby.rs index 601792f6c..352da63a1 100644 --- a/crates/idml/src/ruby.rs +++ b/crates/idml/src/ruby.rs @@ -98,11 +98,45 @@ pub(crate) fn read(e: &El, a: &mut CharAttrs, mut swatch: impl FnMut(&str) -> St a.ruby_overprint_stroke = overprint("RubyOverprintStroke"); } +/// Ruby parent-text options (OpenType Pro, auto tate-chu-yoko, overhang, spacing, scaling). +fn write_parent_options(el: &mut El, a: &CharAttrs) { + let b = |v: bool| if v { "true" } else { "false" }; + if let Some(v) = a.ruby_open_type_pro { + el.set("RubyOpenTypePro", b(v)); + } + if let Some(v) = a.ruby_auto_tcy_digits { + el.set("RubyAutoTcyDigits", v); + } + if let Some(v) = a.ruby_auto_tcy_include_roman { + el.set("RubyAutoTcyIncludeRoman", b(v)); + } + if let Some(v) = a.ruby_auto_tcy_auto_scale { + el.set("RubyAutoTcyAutoScale", b(v)); + } + if let Some(v) = a.ruby_overhang { + el.set("RubyOverhang", b(v)); + } + if let Some(v) = a.ruby_overhang_amount.and_then(|v| name(OVERHANGS, v)) { + el.set("RubyParentOverhangAmount", v); + } + if let Some(v) = a.ruby_parent_spacing.and_then(|v| name(SPACINGS, v)) { + el.set("RubyParentSpacing", v); + } + if let Some(v) = a.ruby_auto_align { + el.set("RubyAutoAlign", b(v)); + } + if let Some(v) = a.ruby_auto_scaling { + el.set("RubyAutoScaling", b(v)); + } + if let Some(v) = a.ruby_scaling_min { + el.set("RubyParentScalingPercent", num(v * 100.0)); + } +} + /// Write the ruby settings set in `a`: attributes on `el`, fonts and colours as `props`. `sw` /// maps a swatch name to its IDML reference. Returns the ruby font (family, style) to list in /// the fonts part. pub(crate) fn write(el: &mut El, props: &mut Vec, a: &CharAttrs, sw: impl Fn(&str) -> String) -> Option<(String, String)> { - let b = |v: bool| if v { "true" } else { "false" }; let prop = |k: &str, ty: &str, v: String| El::new(k).attr("type", ty).text(v); if let Some(v) = a.ruby_type.and_then(|v| name(TYPES, v)) { el.set("RubyType", v); @@ -134,36 +168,7 @@ pub(crate) fn write(el: &mut El, props: &mut Vec, a: &CharAttrs, sw: impl Fn if let Some(v) = a.ruby_y_scale { el.set("RubyYScale", num(v * 100.0)); } - if let Some(v) = a.ruby_open_type_pro { - el.set("RubyOpenTypePro", b(v)); - } - if let Some(v) = a.ruby_auto_tcy_digits { - el.set("RubyAutoTcyDigits", v); - } - if let Some(v) = a.ruby_auto_tcy_include_roman { - el.set("RubyAutoTcyIncludeRoman", b(v)); - } - if let Some(v) = a.ruby_auto_tcy_auto_scale { - el.set("RubyAutoTcyAutoScale", b(v)); - } - if let Some(v) = a.ruby_overhang { - el.set("RubyOverhang", b(v)); - } - if let Some(v) = a.ruby_overhang_amount.and_then(|v| name(OVERHANGS, v)) { - el.set("RubyParentOverhangAmount", v); - } - if let Some(v) = a.ruby_parent_spacing.and_then(|v| name(SPACINGS, v)) { - el.set("RubyParentSpacing", v); - } - if let Some(v) = a.ruby_auto_align { - el.set("RubyAutoAlign", b(v)); - } - if let Some(v) = a.ruby_auto_scaling { - el.set("RubyAutoScaling", b(v)); - } - if let Some(v) = a.ruby_scaling_min { - el.set("RubyParentScalingPercent", num(v * 100.0)); - } + write_parent_options(el, a); for (k, c) in [("RubyFill", &a.ruby_fill), ("RubyStroke", &a.ruby_stroke)] { if let Some(c) = c { props.push(if c.is_empty() { prop(k, "string", TEXT_COLOR.into()) } else { prop(k, "object", sw(c.as_str())) }); diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 37fb2af50..970567e7c 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -10,6 +10,8 @@ use super::*; mod assets; mod clipping; mod decorations; +mod facing_ltr; +mod frame_grid; mod index_sort; mod liquid_guides; mod object_styles; @@ -1100,6 +1102,35 @@ fn imports_and_round_trips_explicit_cell_border_overrides() { assert_eq!(roundtrip.cell(0, 1).unwrap().border_overrides, [false; 4]); } +#[test] +fn explicitly_hidden_cell_edges_hide_the_default_table_border() { + // No table border or table style is given, and every cell edge is explicitly hidden (issue #1623): + // the first cell by weight 0, the second by the None swatch. + let cell = |name: &str, attr: &str, value: &str| { + format!( + r#" + X"# + ) + }; + let story = format!( + r#" + + + + + {}{} +
"#, + cell("0:0", "StrokeWeight", "0"), + cell("1:0", "StrokeColor", "Swatch/None") + ); + let d = import_idml(&fixture_with_story(&story)).unwrap(); + d.check().unwrap(); + let table = d.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + for c in 0..2 { + assert_eq!(table.cell(0, c).unwrap().border_overrides, [true; 4], "cell {c} suppresses the table border"); + } +} + #[test] fn round_trips_footnotes_and_options() { let mut d = Document::new(&NewDocument::default()); @@ -1325,6 +1356,7 @@ fn anchored_objects_take_unset_settings_from_object_styles() { object_point, ref_point: 6, keep_within_column, + spine_relative: false, }; let positions: Vec<_> = s.objects.iter().map(|o| o.position.clone()).collect(); assert_eq!( @@ -1337,6 +1369,44 @@ fn anchored_objects_take_unset_settings_from_object_styles() { ); } +/// Relative to Spine comes from the object style chain like the other anchored settings, an +/// item's own setting wins, and export writes it back. +#[test] +fn anchored_spine_relative_comes_from_object_styles_and_round_trips() { + use designcraft_doc::AnchorPosition; + let styles = STYLES.replace( + r#""#, + r#" + + ObjectStyle/Side"#, + ); + let rect = |style: &str, setting: &str| { + format!( + r#" + + + + + "# + ) + }; + let story = format!( + r#" + a{}{}{} + "#, + rect("Note", ""), + rect("Note", r#"SpineRelative="false""#), + rect("$ID/[None]", ""), + ); + let spine = |d: &Document| -> Vec { + let s = d.stories.values().find(|s| !s.objects.is_empty()).unwrap(); + s.objects.iter().map(|o| matches!(o.position, AnchorPosition::Custom { spine_relative: true, .. })).collect() + }; + let d = import_idml(&fixture_with_table_styles(&styles, &story)).unwrap(); + assert_eq!(spine(&d), [true, false, false]); + assert_eq!(spine(&import_idml(&export_idml(&d)).unwrap()), [true, false, false]); +} + /// IDML measures a left-aligned anchored object's X offset leftwards; the document model and /// composition measure every X offset rightwards. #[test] @@ -4368,3 +4438,144 @@ fn cell_first_baseline_comes_from_cell_styles_table_regions_and_cells_and_round_ let s2 = back.styles.cell.iter().find(|c| c.name == "Cap").unwrap(); assert_eq!(s2.first_baseline, Some(FirstBaseline::CapHeight)); } + +/// A graphic placed in a table cell is written into the cell, not dropped. +#[test] +fn exports_graphic_in_table_cell() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(40.0, 40.0, 400.0, 400.0), lid, "Intro", ParaFormat::default()).unwrap(); + let asset = designcraft_doc::AssetId(d.alloc()); + let id = d.add_asset(designcraft_doc::Asset { + id: asset, + name: "tick.png".into(), + mime: "image/png".into(), + data: std::sync::Arc::new(vec![1, 2, 3]), + ..Default::default() + }); + let mut t = designcraft_doc::Table::new(d.alloc(), 2, 2, 0, 0, 200.0); + t.cell_mut(1, 0).unwrap().graphic = Some(designcraft_doc::Graphic { + asset: id, + size: (24.0, 24.0), + xf: designcraft_geom::Affine::IDENTITY, + clip: None, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + }); + d.story_mut(sid).unwrap().insert_table(5, t); + let story = zip_text(&export_idml(&d), "Stories/"); + let cell = story.split("").next().unwrap_or_default(); + assert!(cell.contains(" = match *color { + Color::Rgb { r, g, b } => vec![r, g, b], + Color::Cmyk { c, m, y, k } => vec![c, m, y, k], + Color::Gray { k } => vec![k], + }; + assert!(parts.iter().all(|v| v.is_finite()), "swatch {} is not finite: {color:?}", s.name); + } + } + for sp in doc.spreads.iter().chain(&doc.parents) { + for p in &sp.pages { + assert!(p.width.is_finite() && p.height.is_finite() && p.x.is_finite(), "page {p:?}"); + } + } + assert_eq!(doc.non_finite_item(), None); +} + +/// Cell Options › Text › Rotation: the angle survives the round trip, and the import says it is +/// not composed — narrow header columns of vertical labels come in horizontal and wrap mid-word, +/// and nothing said so (#1468). +#[test] +fn rotated_cell_text_round_trips_and_the_import_says_it_is_not_composed() { + let mut d = Document::new(&NewDocument::default()); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 300.0, 300.0), d.default_layer(), "", ParaFormat::default()).unwrap(); + let mut t = designcraft_doc::Table::new(7, 1, 3, 0, 0, 300.0); + t.cell_mut(0, 0).unwrap().rotation = 90.0; + t.cell_mut(0, 1).unwrap().rotation = 270.0; + d.story_mut(sid).unwrap().insert_table(0, t); + let bytes = export_idml(&d); + let story = zip_text(&bytes, "Stories/"); + assert!(story.contains(r#"RotationAngle="90""#) && story.contains(r#"RotationAngle="270""#), "{story}"); + + let back = import_idml_report(&bytes, &|_| None).unwrap(); + let t = back.document.stories.values().find(|st| !st.tables.is_empty()).unwrap().tables.values().next().unwrap().clone(); + assert_eq!((t.cell(0, 0).unwrap().rotation, t.cell(0, 1).unwrap().rotation, t.cell(0, 2).unwrap().rotation), (90.0, 270.0, 0.0)); + let about: Vec<&String> = back.warnings.iter().filter(|w| w.contains("rotated text")).collect(); + assert_eq!(about.len(), 1, "one line for the file, not one per cell: {:?}", back.warnings); + assert!(about[0].starts_with("2 table cell(s)"), "{}", about[0]); + assert!(about[0].contains("composed horizontally"), "{}", about[0]); + + // A file with no rotated cell says nothing. + let mut plain = Document::new(&NewDocument::default()); + let (_, sid) = + plain.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 300.0, 300.0), plain.default_layer(), "", ParaFormat::default()).unwrap(); + plain.story_mut(sid).unwrap().insert_table(0, designcraft_doc::Table::new(8, 1, 2, 0, 0, 200.0)); + let quiet = import_idml_report(&export_idml(&plain), &|_| None).unwrap(); + assert!(quiet.warnings.iter().all(|w| !w.contains("rotated text")), "{:?}", quiet.warnings); +} + +#[test] +fn style_names_with_a_literal_colon_survive_a_round_trip() { + use designcraft_doc::styles::{CellStyle, CharacterStyle, ObjectStyle, ParagraphStyle, TableStyle}; + let names = ["Title: Big", "Group/Child: X", "Back\\slash: \\:"]; + let mut d = small_doc(); + for n in names { + let st = d.styles_mut(); + st.paragraph.push(ParagraphStyle { + name: n.into(), + based_on: None, + next_style: None, + para: Default::default(), + chars: Default::default(), + shortcut: String::new(), + }); + st.character.push(CharacterStyle { name: n.into(), based_on: None, chars: Default::default(), shortcut: String::new() }); + st.cell.push(CellStyle { name: n.into(), ..Default::default() }); + st.table.push(TableStyle { name: n.into(), ..Default::default() }); + st.object.push(ObjectStyle { name: n.into(), ..Default::default() }); + } + let back = import_idml(&export_idml(&d)).unwrap(); + for n in names { + assert!( + back.styles.paragraph.iter().any(|s| s.name == n), + "paragraph {n:?}: {:?}", + back.styles.paragraph.iter().map(|s| &s.name).collect::>() + ); + assert!(back.styles.character.iter().any(|s| s.name == n), "character {n:?}"); + assert!(back.styles.cell.iter().any(|s| s.name == n), "cell {n:?}"); + assert!(back.styles.table.iter().any(|s| s.name == n), "table {n:?}"); + assert!(back.styles.object.iter().any(|s| s.name == n), "object {n:?}"); + } + // A group is still written as an IDML `:` group path. + assert_eq!(names::style_name_out(names::PARA_BUILTINS, "Group/Child"), "Group:Child"); + for n in names { + assert_eq!(names::style_name_in(names::PARA_BUILTINS, &names::style_name_out(names::PARA_BUILTINS, n)), n); + } +} diff --git a/crates/idml/src/tests/facing_ltr.rs b/crates/idml/src/tests/facing_ltr.rs new file mode 100644 index 000000000..b1c322ecc --- /dev/null +++ b/crates/idml/src/tests/facing_ltr.rs @@ -0,0 +1,111 @@ +use designcraft_doc::PageSide::{self, Left, Right}; + +use super::*; + +const NS: &str = r#"xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0""#; + +/// A running head: a text frame across `x0..x1` (IDML spread x), 20 pt below the page top. +fn head(id: &str, story: &str, x0: f64, x1: f64) -> String { + let pts: String = [(x0, -280.0), (x0, -260.0), (x1, -260.0), (x1, -280.0)] + .iter() + .map(|(x, y)| format!(r#""#)) + .collect(); + format!( + r#"{pts}"# + ) +} + +fn story(id: &str, text: &str) -> String { + format!( + r#"{text}"# + ) +} + +/// Bound left to right with facing pages: the first page of a spread lies left of the spine +/// (except page 1, alone on the right). Every page has margins `Left=36 Right=120` (inside 36, +/// outside 120), the parent's left page a ruler guide at 108 and a "Left head" running head, its +/// right page a guide at 300 and a "Right head". Three pages: 1 alone, then 2–3. Rulers measure +/// from each page. +fn left_to_right_fixture() -> Vec { + let margins = r#""#; + let page = |id: &str, x: f64, applied: bool, guide: Option| { + let applied = if applied { r#" AppliedMaster="m1""# } else { "" }; + let guide = guide.map_or(String::new(), |at| format!(r#""#)); + format!(r#"{margins}{guide}"#) + }; + let master = format!( + r#"{}{}{}{}"#, + page("mp1", -400.0, false, Some(108.0)), + page("mp2", 0.0, false, Some(300.0)), + head("hl", "sl", -280.0, -36.0), + head("hr", "sr", 36.0, 280.0), + ); + let spread = |id: &str, pages: &[(&str, f64)]| { + let ps: String = pages.iter().map(|(p, x)| page(p, *x, true, None)).collect(); + format!(r#"{ps}"#, pages.len()) + }; + let map = format!( + r#""# + ); + let prefs = format!( + r#""# + ); + zip_files(&[ + ("designmap.xml", &map), + ("Resources/Preferences.xml", &prefs), + ("MasterSpreads/m1.xml", &master), + ("Spreads/sp0.xml", &spread("sp0", &[("p1", 0.0)])), + ("Spreads/sp1.xml", &spread("sp1", &[("p2", -400.0), ("p3", 0.0)])), + ("Stories/sl.xml", &story("sl", "Left head")), + ("Stories/sr.xml", &story("sr", "Right head")), + ]) +} + +/// The text of the running heads a document page shows from its parent page. +fn shown_heads(d: &Document, abs: usize) -> Vec { + let (ppi, ppage) = d.parent_page_for(abs).unwrap(); + let parent = &d.parents[ppi]; + parent + .items + .iter() + .filter(|it| parent.page_at_x(it.bounds().center().x) == Some(ppage)) + .filter_map(|it| d.story(it.text_frame()?.story).map(|s| s.text.clone())) + .collect() +} + +/// Ruler guides of the parent page a document page shows, as x from that parent page's left edge. +fn parent_guides(d: &Document, abs: usize) -> Vec { + let (ppi, ppage) = d.parent_page_for(abs).unwrap(); + let pp = &d.parents[ppi].pages[ppage]; + pp.guides.iter().map(|g| g.position - pp.x).collect() +} + +fn assert_right_pages_mirror_left_pages(d: &Document) { + assert!(d.settings.facing_pages && !d.settings.right_to_left_binding); + let sides: Vec = d.parents[0].pages.iter().map(|p| p.side).collect(); + assert_eq!(sides, vec![Left, Right]); + // Right pages: inside (36) on the left; left pages: outside (120) on the left. + for (abs, side, live, guide, text) in [ + (0, Right, (36.0, 280.0), 300.0, "Right head"), + (1, Left, (120.0, 364.0), 108.0, "Left head"), + (2, Right, (36.0, 280.0), 300.0, "Right head"), + ] { + let n = abs + 1; + let page = d.page(abs).unwrap(); + assert_eq!(page.side, side, "page {n}"); + let m = page.margin_rect(); + assert!((m.x0 - page.x - live.0).abs() < 1e-6 && (m.x1 - page.x - live.1).abs() < 1e-6, "page {n}: margins {m:?}"); + assert_eq!(d.parents[0].pages[d.parent_page_for(abs).unwrap().1].side, side, "page {n} shows the parent's {side:?} page"); + let guides = parent_guides(d, abs); + assert!(guides.len() == 1 && (guides[0] - guide).abs() < 1e-6, "page {n}: guides {guides:?}"); + assert_eq!(shown_heads(d, abs), vec![text.to_string()], "page {n}"); + } +} + +#[test] +fn left_to_right_right_pages_take_mirrored_margins_and_the_right_parent_page() { + let d = import_idml(&left_to_right_fixture()).unwrap(); + assert_right_pages_mirror_left_pages(&d); + let again = import_idml(&export_idml(&d)).unwrap(); + assert_right_pages_mirror_left_pages(&again); +} diff --git a/crates/idml/src/tests/frame_grid.rs b/crates/idml/src/tests/frame_grid.rs new file mode 100644 index 000000000..99f57b4ea --- /dev/null +++ b/crates/idml/src/tests/frame_grid.rs @@ -0,0 +1,98 @@ +use super::*; + +const NS: &str = r#"xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="14.0""#; + +/// A page with one text frame at its top edge whose custom baseline grid starts at 6 pt with a +/// 14 pt increment; its four paragraphs align to the grid. The frame's top is the page's top, so +/// the grid lines are 6 + n·14 pt down from the frame top whatever the grid is measured from. +fn frame_grid_doc() -> Document { + frame_grid_doc_relative(None) +} + +/// [`frame_grid_doc`] with `BaselineFrameGridRelativeOption` set to `relative` when given. +fn frame_grid_doc_relative(relative: Option<&str>) -> Document { + let relative = relative.map(|r| format!(r#" BaselineFrameGridRelativeOption="{r}""#)).unwrap_or_default(); + let designmap = format!( + r#" +"# + ); + let prefs = format!(r#""#); + let pts: String = [(50.0, 0.0), (50.0, 300.0), (250.0, 300.0), (250.0, 0.0)] + .iter() + .map(|(x, y)| format!(r#""#)) + .collect(); + let spread = format!( + r#" +{pts} +"# + ); + let story = format!( + r#"One
Two
Three
Four
"# + ); + let bytes = zip_files(&[ + ("designmap.xml", &designmap), + ("Resources/Graphic.xml", GRAPHIC), + ("Resources/Styles.xml", STYLES), + ("Resources/Preferences.xml", &prefs), + ("Spreads/Spread_sp1.xml", &spread), + ("Stories/Story_s1.xml", &story), + ]); + import_idml_with(&bytes, &|_| None).unwrap() +} + +/// Each composed line's baseline below the frame top. +fn baselines(d: &Document) -> Vec { + let sid = *d.stories.keys().next().unwrap(); + let top = d.spreads[0].items[0].text_area().y0; + let cs = designcraft_compose::compose_story(d, sid, &Default::default()); + cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.baseline - top).collect() +} + +#[test] +fn custom_frame_grid_sets_lines_from_its_start_by_its_increment() { + let d = frame_grid_doc(); + let grid = d.spreads[0].items[0].text_frame().unwrap().options.baseline_grid; + assert_eq!(grid, Some(designcraft_doc::FrameBaselineGrid::new(6.0, 14.0))); + let b = baselines(&d); + assert_eq!(b.len(), 4, "{b:?}"); + for y in &b { + let n = (y - 6.0) / 14.0; + assert!((n - n.round()).abs() < 1e-6 && n >= 0.0, "baseline {y} is on the 6 + n·14 grid: {b:?}"); + } + // The first line's ascent is past the 6 pt line, so it sits on the next one. + assert!((b[0] - 20.0).abs() < 1e-6, "{b:?}"); +} + +#[test] +fn custom_frame_grid_round_trips() { + let d = frame_grid_doc(); + let bytes = export_idml(&d); + let spread = zip_text(&bytes, "Spreads/"); + assert!(spread.contains(r#"StartingOffsetForBaselineFrameGrid="6""#), "{spread}"); + assert!(spread.contains(r#"BaselineFrameGridIncrement="14""#), "{spread}"); + let back = import_idml(&bytes).unwrap(); + let grid = |d: &Document| d.spreads[0].items[0].text_frame().unwrap().options.baseline_grid; + assert_eq!(grid(&back), grid(&d)); + assert_eq!(baselines(&back), baselines(&d)); +} + +#[test] +fn custom_frame_grid_relative_to_round_trips() { + use designcraft_doc::FrameGridRelative as R; + let relative = |d: &Document| d.spreads[0].items[0].text_frame().unwrap().options.baseline_grid.map(|g| g.relative_to); + for (attr, want) in [ + ("TopOfPageOfBaselineFrameGridRelativeOption", R::TopOfPage), + ("TopOfMarginOfBaselineFrameGridRelativeOption", R::TopMargin), + ("TopOfFrameOfBaselineFrameGridRelativeOption", R::TopOfFrame), + ("TopOfInsetOfBaselineFrameGridRelativeOption", R::TopInset), + ("TopOfInset", R::TopInset), + ("SomethingNew", R::TopOfPage), + ] { + let d = frame_grid_doc_relative(Some(attr)); + assert_eq!(relative(&d), Some(want), "{attr}"); + let back = import_idml(&export_idml(&d)).unwrap(); + assert_eq!(relative(&back), Some(want), "{attr} after export"); + } + // A frame without the attribute measures from the top of the page. + assert_eq!(relative(&frame_grid_doc()), Some(R::TopOfPage)); +} diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index 1723145ea..3a8facf08 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -738,8 +738,8 @@ mod tests { impl tiff::encoder::colortype::ColorType for Palette { type Inner = u8; const TIFF_VALUE: tiff::tags::PhotometricInterpretation = tiff::tags::PhotometricInterpretation::RGBPalette; - const BITS_PER_SAMPLE: &'static [u16] = &[BITS]; - const SAMPLE_FORMAT: &'static [tiff::tags::SampleFormat] = &[tiff::tags::SampleFormat::Uint]; + const BITS_PER_SAMPLE: &[u16] = &[BITS]; + const SAMPLE_FORMAT: &[tiff::tags::SampleFormat] = &[tiff::tags::SampleFormat::Uint]; fn horizontal_predict(row: &[u8], result: &mut Vec) { result.extend_from_slice(row); } diff --git a/crates/images/src/psd.rs b/crates/images/src/psd.rs index db11eef5a..4006e0e30 100644 --- a/crates/images/src/psd.rs +++ b/crates/images/src/psd.rs @@ -81,6 +81,16 @@ impl Mode { /// The merged composite as straight RGBA8, `None` when the file is damaged or unsupported (32-bit, /// Multichannel, ZIP-compressed composite). +/// Whether the header's depth, channel count and size are all within what the decoder supports. +fn header_in_range(mode: Mode, depth: u16, channels: usize, (w, h): (u32, u32), max_side: u32) -> bool { + let depth_ok = match mode { + Mode::Bitmap => depth == 1, + Mode::Indexed => depth == 8, + _ => depth == 8 || depth == 16, + }; + (1..=56).contains(&channels) && channels >= mode.colour_channels() && (1..=max_side).contains(&w) && (1..=max_side).contains(&h) && depth_ok +} + pub(crate) fn decode(bytes: &[u8]) -> Option { let mut r = Reader { b: bytes }; if r.take(4)? != b"8BPS" { @@ -97,13 +107,8 @@ pub(crate) fn decode(bytes: &[u8]) -> Option { let depth = r.u16()?; let mode = Mode::parse(r.u16()?)?; let max_side = if psb { 300_000 } else { 30_000 }; - let depth_ok = match mode { - Mode::Bitmap => depth == 1, - Mode::Indexed => depth == 8, - _ => depth == 8 || depth == 16, - }; let colours = mode.colour_channels(); - if !(1..=56).contains(&channels) || channels < colours || !(1..=max_side).contains(&w) || !(1..=max_side).contains(&h) || !depth_ok { + if !header_in_range(mode, depth, channels, (w, h), max_side) { return None; } let (wu, hu) = (usize::try_from(w).ok()?, usize::try_from(h).ok()?); @@ -298,7 +303,7 @@ impl<'a> ImageData<'a> { } let mut counts = self.counts.chunks_exact(self.count_size).map(|c| c.iter().fold(0usize, |n, &b| n << 8 | usize::from(b))); // This channel starts after every earlier channel's rows. - let mut at = counts.by_ref().take(ch.checked_mul(self.height)?).try_fold(0usize, |at, n| at.checked_add(n))?; + let mut at = counts.by_ref().take(ch.checked_mul(self.height)?).try_fold(0usize, usize::checked_add)?; for _ in 0..self.height { let n = counts.next()?; let end = at.checked_add(n)?; diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 3abcdb521..5d3ad0644 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -134,6 +134,25 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions) write::guard(|| export_pdf_with_report_inner(doc, cache, opts)) } +/// Form fields (Buttons and Forms) and, for interactive PDF, video and sound; not in PDF/X +/// or PDF/A: this update comes after krilla's validator ran, and its NeedAppearances fields and +/// Screen annotations aren't allowed in PDF/A. +fn add_form_fields(bytes: &mut Vec, warnings: &mut Vec, doc: &Document, sheets: &[Sheet], opts: &PdfOptions) { + let fields = crate::forms::collect(doc, sheets); + let media = if opts.media { crate::forms::collect_media(doc, sheets) } else { Vec::new() }; + if fields.is_empty() && media.is_empty() { + return; + } + match opts.standard { + Standard::PdfX4 => warnings.push("PDF/X-4: form fields and media were left out".into()), + Standard::PdfA2b => warnings.push("PDF/A-2b: form fields and media were left out".into()), + _ => match crate::forms::add_fields(&*bytes, &fields, &media) { + Some(b) => *bytes = b, + None => warnings.push("form fields and media couldn't be added to this PDF".into()), + }, + } +} + fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions) -> Result { let sheets = sheets(doc, opts)?; let warnings = Vec::new(); @@ -235,23 +254,7 @@ fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions // group that got past it, with its page. crate::pdfx::cmyk_group_spaces(&mut bytes); } - // Form fields (Buttons and Forms) and, for interactive PDF, video and sound; not in PDF/X - // or PDF/A: this update comes after krilla's validator ran, and its NeedAppearances fields and - // Screen annotations aren't allowed in PDF/A. - let fields = crate::forms::collect(doc, &sheets); - let media = if opts.media { crate::forms::collect_media(doc, &sheets) } else { Vec::new() }; - if !fields.is_empty() || !media.is_empty() { - if opts.standard == Standard::PdfX4 { - warnings.push("PDF/X-4: form fields and media were left out".into()); - } else if opts.standard == Standard::PdfA2b { - warnings.push("PDF/A-2b: form fields and media were left out".into()); - } else { - match crate::forms::add_fields(&bytes, &fields, &media) { - Some(b) => bytes = b, - None => warnings.push("form fields and media couldn't be added to this PDF".into()), - } - } - } + add_form_fields(&mut bytes, &mut warnings, doc, &sheets, opts); if opts.standard == Standard::PdfX4 { // Output intent and PDF/X identification, then check the result. match crate::pdfx::make_pdfx4(&bytes, &title, created) { @@ -654,6 +657,27 @@ impl Exporter<'_> { self.doc.resolve_color(name, tint).map(|c| device(&c, self.rgb_only)) } + /// The parent pages' items on `layer` shown on page `pi` of spread `si`: the parent's bases + /// first, then the parent. + fn parent_items(&mut self, s: &mut Surface, si: usize, pi: usize, page: &designcraft_doc::Page, layer: &designcraft_doc::Layer, first: usize) { + let doc = self.doc; + for (ppi, ppage) in doc.shown_parent_pages(SpreadRef::Doc(si), pi) { + let Some(parent) = doc.parents.get(ppi) else { continue }; + let Some(ppg) = parent.pages.get(ppage) else { continue }; + let dx = page.x - ppg.x; + for it in &parent.items { + if it.layer != layer.id || page.overridden.contains(&it.id) { + continue; + } + // Parent items belong to the parent page they sit on. + if parent.page_at_x(it.bounds().center().x) != Some(ppage) && parent.pages.len() > 1 { + continue; + } + self.top_item(s, it, Affine::translate((dx, 0.0)), Some(first + pi), true); + } + } + } + fn spread(&mut self, s: &mut Surface, si: usize) { let doc = self.doc; let Some(sp) = doc.spreads.get(si) else { return }; @@ -663,24 +687,8 @@ impl Exporter<'_> { continue; } for (pi, page) in sp.pages.iter().enumerate() { - if !page.show_parent_items { - continue; - } - // The parent's bases first, then the parent. - for (ppi, ppage) in doc.shown_parent_pages(SpreadRef::Doc(si), pi) { - let Some(parent) = doc.parents.get(ppi) else { continue }; - let Some(ppg) = parent.pages.get(ppage) else { continue }; - let dx = page.x - ppg.x; - for it in &parent.items { - if it.layer != layer.id || page.overridden.contains(&it.id) { - continue; - } - // Parent items belong to the parent page they sit on. - if parent.page_at_x(it.bounds().center().x) != Some(ppage) && parent.pages.len() > 1 { - continue; - } - self.top_item(s, it, Affine::translate((dx, 0.0)), Some(first + pi), true); - } + if page.show_parent_items { + self.parent_items(s, si, pi, page, layer, first); } } for it in &sp.items { @@ -1629,6 +1637,21 @@ fn is_rgb_icc(icc: &[u8]) -> bool { icc.get(16..20) == Some(b"RGB ".as_slice()) } +/// Whether an ICC profile describes grey. +fn is_grey_icc(icc: &[u8]) -> bool { + icc.get(16..20) == Some(b"GRAY".as_slice()) +} + +/// Whether `data` decodes to grey (with or without alpha), read from its header. +fn is_grey(data: &[u8]) -> bool { + use image::ImageDecoder; + image::ImageReader::new(std::io::Cursor::new(data)) + .with_guessed_format() + .ok() + .and_then(|r| r.into_decoder().ok()) + .is_some_and(|dec| !dec.color_type().has_color()) +} + /// A JPEG, passed through. With `tag_rgb`, a JPEG keeps its own profile, and one without gets /// sRGB. krilla uses a profile only when its channels match the JPEG's, so a grey or CMYK JPEG /// without a profile stays DeviceGray or DeviceCMYK. @@ -1668,6 +1691,10 @@ fn lossless(data: &Arc>, fmt: Option, mode: RasterMo _ => None, }; direct.or_else(|| { + // A grey TIFF, BMP… stays DeviceGray, as a grey PNG or JPEG does (#1426). + if let Some(grey) = GreyImage::new(data) { + return Image::from_custom(grey, i).ok(); + } // TIFF, BMP, PSD composites… and, with `tag_rgb`, untagged RGB. if mode.tag_rgb { return SrgbImage::new(rgba).and_then(|img| Image::from_custom(img, i).ok()); @@ -1722,6 +1749,61 @@ impl krilla::image::CustomImage for SrgbImage { } } +/// A grey raster as a DeviceGray image (ICCBased when the file has a grey profile of its own), so +/// a page that only holds black prints as black, not as RGB. +#[derive(Clone, Hash)] +struct GreyImage { + grey: Arc>, + /// Only when some pixel is not opaque. + alpha: Option>>, + size: (u32, u32), + icc: Option>>, +} + +impl GreyImage { + /// `None` when `data` isn't grey or can't be decoded. + fn new(data: &[u8]) -> Option { + if !is_grey(data) { + return None; + } + let img = image::load_from_memory(data).ok()?.to_luma_alpha8(); + let size = img.dimensions(); + let pixels = usize::try_from(size.0).ok()?.checked_mul(usize::try_from(size.1).ok()?)?; + if pixels == 0 { + return None; + } + let (mut grey, mut alpha) = (Vec::with_capacity(pixels), Vec::with_capacity(pixels)); + for p in img.pixels() { + grey.push(p.0[0]); + alpha.push(p.0[1]); + } + let alpha = alpha.iter().any(|a| *a < 255).then(|| Arc::new(alpha)); + let icc = embedded_icc(data).filter(|p| is_grey_icc(p)).map(Arc::new); + Some(Self { grey: Arc::new(grey), alpha, size, icc }) + } +} + +impl krilla::image::CustomImage for GreyImage { + fn color_channel(&self) -> &[u8] { + &self.grey + } + fn alpha_channel(&self) -> Option<&[u8]> { + self.alpha.as_deref().map(Vec::as_slice) + } + fn bits_per_component(&self) -> krilla::image::BitsPerComponent { + krilla::image::BitsPerComponent::Eight + } + fn size(&self) -> (u32, u32) { + self.size + } + fn icc_profile(&self) -> Option<&[u8]> { + self.icc.as_deref().map(Vec::as_slice) + } + fn color_space(&self) -> krilla::image::ImageColorspace { + krilla::image::ImageColorspace::Luma + } +} + /// A CMYK raster as a krilla image: its samples are written unchanged as DeviceCMYK, like a /// CMYK JPEG's. An embedded ICC profile is left out, as it is for JPEGs: the inks are meant for /// the output intent's press, and tagging them with another CMYK profile would let a RIP @@ -1768,11 +1850,18 @@ fn recompress(data: &[u8], mode: RasterMode) -> Option { if img.color().has_alpha() && img.to_rgba8().pixels().any(|p| p[3] < 255) { return None; } - let rgb = img.to_rgb8(); let mut buf = Vec::new(); let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, 90); - image::ImageEncoder::write_image(enc, rgb.as_raw(), rgb.width(), rgb.height(), image::ExtendedColorType::Rgb8).ok()?; - jpeg(buf.into(), || embedded_icc(data).filter(|p| is_rgb_icc(p)), mode) + // A grey image stays a grey JPEG (#1426). + let grey = !img.color().has_color(); + if grey { + let l = img.to_luma8(); + image::ImageEncoder::write_image(enc, l.as_raw(), l.width(), l.height(), image::ExtendedColorType::L8).ok()?; + } else { + let rgb = img.to_rgb8(); + image::ImageEncoder::write_image(enc, rgb.as_raw(), rgb.width(), rgb.height(), image::ExtendedColorType::Rgb8).ok()?; + } + jpeg(buf.into(), || embedded_icc(data).filter(|p| if grey { is_grey_icc(p) } else { is_rgb_icc(p) }), mode) } #[cfg(test)] diff --git a/crates/pdf/src/forms.rs b/crates/pdf/src/forms.rs index 39aa597ec..07f4cf04c 100644 --- a/crates/pdf/src/forms.rs +++ b/crates/pdf/src/forms.rs @@ -8,7 +8,7 @@ use std::fmt::Write as _; use designcraft_doc::{Document, FieldKind, FormField, Item, MediaOptions}; use crate::export::Sheet; -use crate::transitions::{int_after, object, rfind}; +use crate::transitions::{bracketed, int_after, object, rfind}; /// A field placed on an output page: (page index, PDF rectangle, field). pub(crate) type Placed = (usize, [f64; 4], FormField); @@ -94,7 +94,7 @@ pub(crate) fn add_fields(pdf: &[u8], fields: &[Placed], media: &[PlacedMedia]) - let pages_id = int_after(&cat, "/Pages")?; let pages = String::from_utf8_lossy(&pdf[object(pdf, pages_id)?]).to_string(); let kids_str = &pages[pages.find("/Kids")? + 5..]; - let kids_str = &kids_str[kids_str.find('[')? + 1..kids_str.find(']')?]; + let kids_str = bracketed(kids_str)?; let nums: Vec = kids_str.split_whitespace().filter_map(|t| t.parse().ok()).collect(); let kids: Vec = nums.chunks(2).map(|c| c[0]).collect(); diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index ad1123897..66512ac50 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -56,7 +56,7 @@ pub enum Standard { impl Standard { /// `none`, `x4` / `pdfx4` / `PDF/X-4`, `a2b` / `pdfa2b` / `PDF/A-2b`. pub fn parse(s: &str) -> Option { - let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::().to_ascii_lowercase(); + let k: String = s.chars().filter(char::is_ascii_alphanumeric).collect::().to_ascii_lowercase(); match k.as_str() { "" | "none" => Some(Self::None), "x4" | "pdfx4" | "pdfx42008" | "pdfx42010" => Some(Self::PdfX4), diff --git a/crates/pdf/src/pdfx.rs b/crates/pdf/src/pdfx.rs index 534886f1d..11d30602b 100644 --- a/crates/pdf/src/pdfx.rs +++ b/crates/pdf/src/pdfx.rs @@ -10,6 +10,8 @@ use hayro_syntax::page::Resources; use crate::transitions::{find, int_after, object, rfind}; +mod fonts; + /// Escape a PDF literal string. fn lit(s: &str) -> String { let mut o = String::from("("); @@ -94,7 +96,7 @@ pub fn make_pdfx4(pdf: &[u8], title: &str, date: Option) -> Option> let s0 = find(body, b"stream", 0)? + b"stream".len(); let s0 = s0 + body[s0..].iter().take_while(|c| **c == b'\r' || **c == b'\n').count(); let s1 = rfind(body, b"endstream")?; - let xml = String::from_utf8_lossy(&body[s0..s1]).trim_end().to_string(); + let xml = String::from_utf8_lossy(body.get(s0..s1)?).trim_end().to_string(); let dict = String::from_utf8_lossy(&body[..find(body, b"stream", 0)?]).to_string(); if dict.contains("/Filter") { return None; @@ -246,6 +248,7 @@ pub fn check_pdfx4(pdf: &[u8]) -> Vec { issues.push("Type 3 fonts aren't allowed".into()); } issues.extend(device_rgb(pdf)); + issues.extend(fonts::unembedded_fonts(pdf)); issues } @@ -282,7 +285,7 @@ fn device_rgb(pdf: &[u8]) -> Vec { found .into_iter() .map(|(what, pages)| { - let list: Vec = pages.iter().take(10).map(|p| p.to_string()).collect(); + let list: Vec = pages.iter().take(10).map(ToString::to_string).collect(); let more = if pages.len() > 10 { ", …" } else { "" }; let s = if pages.len() == 1 { "" } else { "s" }; format!("{what} on page{s} {}{more}: under a CMYK output intent RGB needs an ICC profile", list.join(", ")) @@ -389,18 +392,7 @@ impl RgbScan { } b"BI" | b"EI" if !managed => { if let Some(Object::Stream(img)) = last { - let cs = img.dict().get::>(b"CS").or_else(|| img.dict().get::>(b"ColorSpace")); - let cs = match cs { - Some(Object::Name(n)) - if !matches!(n.as_ref(), b"DeviceRGB" | b"RGB" | b"G" | b"DeviceGray" | b"CMYK" | b"DeviceCMYK") => - { - res.get_color_space(&n) - } - cs => cs, - }; - if cs.is_some_and(|cs| is_device_rgb(&cs)) { - self.add("an RGB inline image".into()); - } + self.inline_image(&img, res); } } _ => {} @@ -408,6 +400,20 @@ impl RgbScan { } } + /// An inline image (`BI … EI`) in an RGB space, named directly or through the resources. + fn inline_image(&mut self, img: &Stream<'_>, res: &Resources<'_>) { + let cs = img.dict().get::>(b"CS").or_else(|| img.dict().get::>(b"ColorSpace")); + let cs = match cs { + Some(Object::Name(n)) if !matches!(n.as_ref(), b"DeviceRGB" | b"RGB" | b"G" | b"DeviceGray" | b"CMYK" | b"DeviceCMYK") => { + res.get_color_space(&n) + } + cs => cs, + }; + if cs.is_some_and(|cs| is_device_rgb(&cs)) { + self.add("an RGB inline image".into()); + } + } + fn image(&mut self, img: &Stream<'_>, managed: bool) { let id = img.obj_id(); if managed || !self.seen.insert(id) || img.dict().get::(b"ImageMask") == Some(true) { @@ -480,7 +486,7 @@ mod tests { use crate::{ExportReport, PdfOptions, Standard, check_pdfx4, export_pdf_with_report}; - fn place(d: &mut Document, name: &str, data: Vec, size: (f64, f64), at: (f64, f64)) { + pub(super) fn place(d: &mut Document, name: &str, data: Vec, size: (f64, f64), at: (f64, f64)) { let asset = AssetId(d.alloc()); let mime = designcraft_images::mime(&data).into(); let pixels = designcraft_images::pixel_size(&data); @@ -579,7 +585,7 @@ mod tests { pdf.finish().unwrap() } - fn x4(d: &Document) -> ExportReport { + pub(super) fn x4(d: &Document) -> ExportReport { export_pdf_with_report(d, &Cache::new(), &PdfOptions { standard: Standard::PdfX4, ..Default::default() }).unwrap() } @@ -645,13 +651,18 @@ mod tests { /// A minimal PDF with a CMYK (N 4) PDF/X output intent and one page with `entries` (its /// resources, a group…) and `content`. fn tiny_pdf(entries: &str, content: &str) -> Vec { + tiny_pdf_with(entries, content, "<>\nstream\n\nendstream") + } + + /// [`tiny_pdf`] with `extra` as object 6 (an RGB profile in [`tiny_pdf`]). + pub(super) fn tiny_pdf_with(entries: &str, content: &str, extra: &str) -> Vec { let objects = [ "<>]>>".to_string(), "<>".to_string(), format!("<>"), "<>\nstream\n\nendstream".to_string(), format!("<>\nstream\n{content}\nendstream", content.len()), - "<>\nstream\n\nendstream".to_string(), + extra.to_string(), ]; let mut out = b"%PDF-1.6\n".to_vec(); let mut offsets = Vec::new(); @@ -668,6 +679,18 @@ mod tests { out } + /// A metadata object whose only `stream` is the one inside `endstream` is refused, not a slice panic. + #[test] + fn metadata_with_only_endstream_does_not_panic() { + let objects = ["<>", "<>", "<>endstream"]; + let mut out = b"%PDF-1.6\n".to_vec(); + for (i, o) in objects.iter().enumerate() { + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + out.extend_from_slice(b"trailer\n<>\nstartxref\n0\n%%EOF\n"); + assert!(super::make_pdfx4(&out, "t", None).is_none()); + } + /// DeviceRGB is allowed when a `DefaultRGB` colour space makes it colour-managed. #[test] fn default_rgb_makes_device_rgb_managed() { @@ -682,6 +705,108 @@ mod tests { assert!(!cmyk.iter().any(rgb), "{cmyk:?}"); } + /// Every font that draws visible text must be embedded (#909): a base-14 font with no font + /// file, a CID font whose descendant has none, and one drawn from inside a form are reported; + /// an embedded font, an unused one and invisible text (render mode 3, as in OCR layers) are not. + #[test] + fn unembedded_fonts_are_flagged() { + let fonts = |entries: &str, content: &str| -> Vec { + check_pdfx4(&tiny_pdf(entries, content)).into_iter().filter(|i| i.contains("font")).collect() + }; + let helvetica = "/Resources<>>>>>"; + assert_eq!( + fonts(helvetica, "BT /F1 24 Tf 20 50 Td (Base14 text) Tj ET"), + ["the font Helvetica isn't embedded (page 1): PDF/X-4 needs every font used for visible text embedded"] + ); + assert!(fonts(helvetica, "BT /F1 24 Tf 3 Tr 20 50 Td (OCR) Tj ET").is_empty(), "invisible text needs no embedded font"); + assert!(fonts(helvetica, "0 0 0 1 k 0 0 10 10 re f").is_empty(), "an unused font is not reported"); + let restored = fonts(helvetica, "BT /F1 24 Tf q 3 Tr Q 20 50 Td (shown) Tj ET"); + assert_eq!(restored.len(), 1, "Q restores the visible render mode: {restored:?}"); + + let embedded = "/Resources<>>>>>>>"; + assert!(fonts(embedded, "BT /F1 12 Tf (x) Tj ET").is_empty()); + let cid = "/Resources<>>>]>>>>>>"; + assert_eq!(fonts(cid, "BT /F1 12 Tf <0001> Tj ET").len(), 1); + let cid_embedded = cid.replace("/FontName/KozMin>>", "/FontName/KozMin/FontFile3 6 0 R>>"); + assert!(fonts(&cid_embedded, "BT /F1 12 Tf <0001> Tj ET").is_empty()); + + let draw = "BT /T 9 Tf (in a form) Tj ET"; + let form = format!( + "<>>>>>/Length {}>>\nstream\n{draw}\nendstream", + draw.len() + ); + let pdf = tiny_pdf_with("/Resources<>>>", "/X1 Do", &form); + let issues: Vec = check_pdfx4(&pdf).into_iter().filter(|i| i.contains("font")).collect(); + assert_eq!(issues.len(), 1, "{issues:?}"); + assert!(issues[0].starts_with("the font Times-Roman isn't embedded"), "{issues:?}"); + } + + /// A placed PDF that draws with an unembedded font: the PDF/X-4 export warns about it (#909). + #[test] + fn placed_pdf_with_an_unembedded_font_is_reported() { + let mut d = Document::new(&NewDocument::default()); + let placed = tiny_pdf( + "/Resources<>>>>>", + "BT /F1 24 Tf 0 0 0 1 k 20 50 Td (Base14 text) Tj ET", + ); + place(&mut d, "helvetica.pdf", placed, (100.0, 100.0), (72.0, 72.0)); + let r = x4(&d); + assert!(r.warnings.iter().any(|w| w.starts_with("PDF/X-4: the font Helvetica isn't embedded (page 1)")), "{:?}", r.warnings); + } + + #[test] + fn unembedded_fonts_are_reported_by_every_text_show_operator() { + let entries = "/Resources<>>>>>"; + for show in ["(x) Tj", "[(x) 20 (y)] TJ", "(x) '", "0 0 (x) \""] { + let pdf = tiny_pdf(entries, &format!("BT /F1 12 Tf {show} ET")); + let issues = check_pdfx4(&pdf); + assert_eq!(issues.iter().filter(|i| i.contains("isn't embedded")).count(), 1, "{show}: {issues:?}"); + } + } + + #[test] + fn unembedded_fonts_in_forms_inherit_and_restore_text_state() { + let entries = "/Resources<>>>/XObject<>>>"; + let draw = "BT (inherited font) Tj /F1 10 Tf 3 Tr ET"; + let form = format!("<>\nstream\n{draw}\nendstream", draw.len()); + for (content, count) in [ + ("/F1 12 Tf /X1 Do", 1), + ("/F1 12 Tf 3 Tr /X1 Do", 0), + ("/F1 12 Tf 3 Tr /X1 Do 0 Tr /X1 Do", 1), + ("/F1 12 Tf /X1 Do BT (still visible) Tj ET", 1), + ] { + let pdf = tiny_pdf_with(entries, content, &form); + let issues = check_pdfx4(&pdf); + assert_eq!(issues.iter().filter(|i| i.contains("isn't embedded")).count(), count, "{content}: {issues:?}"); + } + } + + #[test] + fn unembedded_fonts_in_recursive_forms_are_bounded() { + let draw = "BT /F1 12 Tf (x) Tj ET /X1 Do"; + let form = format!( + "<>>>/XObject<>>>/Length {}>>\nstream\n{draw}\nendstream", + draw.len() + ); + let pdf = tiny_pdf_with("/Resources<>>>", "/X1 Do", &form); + let issues = check_pdfx4(&pdf); + assert_eq!(issues.iter().filter(|i| i.contains("isn't embedded")).count(), 1, "{issues:?}"); + } + + #[test] + fn unembedded_fonts_are_reported_after_form_text_state_is_restored() { + let entries = "/Resources<>>>/XObject<>>>"; + let draw = "BT /F2 10 Tf 3 Tr (hidden) Tj ET"; + let form = format!( + "<>>>>>/Length {}>>\nstream\n{draw}\nendstream", + draw.len() + ); + let pdf = tiny_pdf_with(entries, "/F1 12 Tf /X1 Do BT (visible) Tj ET", &form); + let issues: Vec = check_pdfx4(&pdf).into_iter().filter(|i| i.contains("isn't embedded")).collect(); + assert_eq!(issues.len(), 1, "{issues:?}"); + assert!(issues[0].starts_with("the font Helvetica isn't embedded"), "{issues:?}"); + } + /// An RGB blending space on the page group and an RGB inline image are device RGB too. #[test] fn rgb_groups_and_inline_images_are_flagged() { diff --git a/crates/pdf/src/pdfx/fonts.rs b/crates/pdf/src/pdfx/fonts.rs new file mode 100644 index 000000000..83178682a --- /dev/null +++ b/crates/pdf/src/pdfx/fonts.rs @@ -0,0 +1,172 @@ +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; + +use hayro_syntax::content::UntypedIter; +use hayro_syntax::object::{Array, Dict, Name, Object, ObjectIdentifier}; +use hayro_syntax::page::Resources; + +use super::MAX_DEPTH; + +#[cfg(test)] +mod tests; + +const MAX_OPERATIONS: usize = 100_000; +const LIMIT_WARNING: &str = "font scan operation limit reached: PDF/X-4 font embedding checks are incomplete"; +const DEPTH_WARNING: &str = "font scan nesting depth limit reached: PDF/X-4 font embedding checks are incomplete"; + +pub(super) fn unembedded_fonts(pdf: &[u8]) -> Vec { + let Ok(file) = hayro_syntax::Pdf::new(pdf.to_vec()) else { return Vec::new() }; + let mut found: BTreeMap> = BTreeMap::new(); + let mut scan = FontScan::default(); + for (i, page) in file.pages().iter().enumerate() { + scan.font = None; + scan.mode = 0; + if let Some(content) = page.page_stream() { + scan.content(content, page.resources(), 0); + } + for name in std::mem::take(&mut scan.found) { + found.entry(name).or_default().push(i + 1); + } + if scan.limited { + break; + } + } + let mut warnings: Vec = found + .into_iter() + .map(|(name, pages)| { + let list: Vec = pages.iter().take(10).map(ToString::to_string).collect(); + let more = if pages.len() > 10 { ", …" } else { "" }; + let s = if pages.len() == 1 { "" } else { "s" }; + format!("the font {name} isn't embedded (page{s} {}{more}): PDF/X-4 needs every font used for visible text embedded", list.join(", ")) + }) + .collect(); + if scan.limited { + warnings.push(LIMIT_WARNING.to_string()); + } + if scan.depth_limited { + warnings.push(DEPTH_WARNING.to_string()); + } + warnings +} + +fn font_embedded(font: &Dict<'_>) -> bool { + let descriptor = match font.get::>(b"Subtype").as_deref() { + Some(b"Type3") => return true, + Some(b"Type0") => { + font.get::>(b"DescendantFonts").and_then(|a| a.iter::>().next()).and_then(|d| d.get::>(b"FontDescriptor")) + } + _ => font.get::>(b"FontDescriptor"), + }; + descriptor.is_some_and(|d| d.contains_key(b"FontFile") || d.contains_key(b"FontFile2") || d.contains_key(b"FontFile3")) +} + +#[derive(Default)] +struct FontScan<'a> { + found: BTreeSet, + active: HashSet, + path: Vec, + completed: HashMap, BTreeSet>, + font: Option>, + mode: i32, + operations: usize, + limited: bool, + // A form nested too deeply to follow. The scan goes on (unlike `limited`), and the flag is + // never cleared, so a memoized subtree that was cut short can't hide it. + depth_limited: bool, +} + +// Forms inherit text state and missing resource names. Depth and ancestors also affect +// which descendants the depth/cycle guards permit; only identical contexts may share results. +#[derive(Hash, PartialEq, Eq)] +struct FormContext<'a> { + form: ObjectIdentifier, + font: Option<&'a [u8]>, + mode: i32, + resources: Vec<(&'a [u8], &'a [u8])>, + ancestors: Vec, +} + +impl<'a> FontScan<'a> { + fn content(&mut self, data: &[u8], res: &Resources<'a>, depth: usize) { + if depth > MAX_DEPTH { + return; + } + let mut saved = Vec::new(); + let mut ops = UntypedIter::new(data); + while let Some(op) = ops.next() { + if self.operations == MAX_OPERATIONS { + self.limited = true; + return; + } + self.operations += 1; + let operator: &[u8] = op.operator; + match operator { + b"q" => saved.push((self.font.clone(), self.mode)), + b"Q" => { + if let Some(state) = saved.pop() { + (self.font, self.mode) = state; + } + } + b"Tf" => self.font = op.operands().next().cloned().and_then(Object::into_name).and_then(|n| res.get_font(&n)), + b"Tr" => { + if let Some(mode) = op.operands().next().cloned().and_then(|n| i32::try_from(n).ok()) { + self.mode = mode; + } + } + b"Tj" | b"TJ" | b"'" | b"\"" if self.mode != 3 => { + if let Some(font) = self.font.as_ref().filter(|f| !font_embedded(f)) { + let name = font.get::>(b"BaseFont").map_or_else(|| "(unnamed)".to_string(), |n| n.as_str().to_string()); + self.found.insert(name); + } + } + b"Do" => { + let xo = op.operands().last().cloned().and_then(Object::into_name).and_then(|n| res.get_x_object(&n)); + if let Some(form) = xo.filter(|x| x.dict().get::>(b"Subtype").as_deref() == Some(b"Form")) + && (depth < MAX_DEPTH || { + // A form already being scanned is a cycle: it can't hide new content. + self.depth_limited |= !self.active.contains(&form.obj_id()); + false + }) + && self.active.insert(form.obj_id()) + { + let mut resources = Vec::new(); + let mut parent = Some(res); + while let Some(r) = parent { + resources.push((r.fonts.data(), r.x_objects.data())); + parent = r.parent(); + } + let key = FormContext { + form: form.obj_id(), + font: self.font.as_ref().map(Dict::data), + mode: self.mode, + resources, + ancestors: self.path.clone(), + }; + if let Some(found) = self.completed.get(&key) { + self.found.extend(found.iter().cloned()); + self.active.remove(&form.obj_id()); + continue; + } + let inner = Resources::from_parent(form.dict().get::>(b"Resources").unwrap_or_default(), res.clone()); + let state = (self.font.clone(), self.mode); + let outer_found = std::mem::take(&mut self.found); + self.path.push(form.obj_id()); + if let Ok(data) = form.decoded() { + self.content(&data, &inner, depth + 1); + } + self.path.pop(); + if !self.limited { + self.completed.insert(key, self.found.clone()); + } + self.found.extend(outer_found); + (self.font, self.mode) = state; + self.active.remove(&form.obj_id()); + if self.limited { + return; + } + } + } + _ => {} + } + } + } +} diff --git a/crates/pdf/src/pdfx/fonts/tests.rs b/crates/pdf/src/pdfx/fonts/tests.rs new file mode 100644 index 000000000..c148cb076 --- /dev/null +++ b/crates/pdf/src/pdfx/fonts/tests.rs @@ -0,0 +1,153 @@ +use std::time::{Duration, Instant}; + +use super::{DEPTH_WARNING, FontScan, LIMIT_WARNING, MAX_OPERATIONS, unembedded_fonts}; +use crate::check_pdfx4; +use crate::pdfx::tests::{place, tiny_pdf_with, x4}; +use designcraft_doc::{Document, build::NewDocument}; + +fn stream(entries: &str, content: &str) -> String { + format!("<<{entries}/Length {}>>\nstream\n{content}\nendstream", content.len()) +} + +fn fanout_pdf(levels: usize) -> Vec { + let mut objects = vec![ + "<>".to_string(), + "<>".to_string(), + "<>>>/Contents 4 0 R>>".to_string(), + stream("", "/Next Do"), + ]; + for level in 0..levels { + let resources = if level + 1 == levels { + "/Font<>>>".to_string() + } else { + format!("/XObject<>", level + 6) + }; + let content = if level + 1 == levels { "BT /F1 12 Tf (bottom) Tj ET".to_string() } else { "/Next Do\n".repeat(10) }; + objects.push(stream(&format!("/Type/XObject/Subtype/Form/BBox[0 0 100 100]/Resources<<{resources}>>"), &content)); + } + pdf_objects(&objects) +} + +fn pdf_objects(objects: &[String]) -> Vec { + let mut bytes = b"%PDF-1.6\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f\r\n", objects.len() + 1).as_bytes()); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n\r\n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + bytes +} + +#[test] +fn nested_form_fanout_is_memoized_and_reports_bottom_font() { + let bytes = fanout_pdf(16); + let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap(); + let page = pdf.pages().first().unwrap(); + let mut scan = FontScan::default(); + let start = Instant::now(); + scan.content(page.page_stream().unwrap(), page.resources(), 0); + assert!(start.elapsed() < Duration::from_millis(500)); + assert_eq!(scan.operations, 155); + assert_eq!(scan.completed.len(), 16); + assert!(!scan.limited); + assert!(!scan.depth_limited); + assert_eq!(scan.found.into_iter().collect::>(), ["Helvetica"]); + assert!(check_pdfx4(&bytes).iter().any(|w| w.contains("Helvetica isn't embedded"))); +} + +#[test] +fn placed_pdf_nested_form_fanout_reports_unembedded_font() { + let mut doc = Document::new(&NewDocument::default()); + place(&mut doc, "fanout.pdf", fanout_pdf(14), (100.0, 100.0), (72.0, 72.0)); + let report = x4(&doc); + assert!(report.warnings.iter().any(|w| w.contains("Helvetica isn't embedded")), "{:?}", report.warnings); +} + +#[test] +fn form_memo_resolves_resource_names_in_each_parent_context() { + let wrapper = |font: &str| { + stream( + &format!("/Type/XObject/Subtype/Form/BBox[0 0 100 100]/Resources<>>>>>"), + "/Shared Do", + ) + }; + let bytes = pdf_objects(&[ + "<>".into(), + "<>".into(), + "<>>>/Contents 4 0 R>>".into(), + stream("", "/A Do /B Do"), + wrapper("Helvetica"), + wrapper("Times-Roman"), + stream("/Type/XObject/Subtype/Form/BBox[0 0 100 100]", "BT /F1 12 Tf (shared) Tj ET"), + ]); + let issues = unembedded_fonts(&bytes); + assert_eq!(issues.len(), 2, "{issues:?}"); + assert!(issues.iter().any(|w| w.contains("Helvetica isn't embedded"))); + assert!(issues.iter().any(|w| w.contains("Times-Roman isn't embedded"))); +} + +#[test] +fn form_memo_replays_font_findings_on_each_page() { + let page = "<>>>/Contents 5 0 R>>"; + let bytes = pdf_objects(&[ + "<>".into(), + "<>".into(), + page.into(), + page.into(), + stream("", "/Shared Do"), + stream( + "/Type/XObject/Subtype/Form/BBox[0 0 100 100]/Resources<>>>>>", + "BT /F1 12 Tf (shared) Tj ET", + ), + ]); + let issues = unembedded_fonts(&bytes); + assert_eq!(issues.len(), 1, "{issues:?}"); + assert!(issues[0].contains("(pages 1, 2)"), "{issues:?}"); +} + +#[test] +fn form_memo_keeps_inherited_font_and_resource_contexts_distinct() { + let draw = "BT (inherited) Tj /F1 12 Tf (resource) Tj ET"; + let form = stream("/Type/XObject/Subtype/Form/BBox[0 0 100 100]", draw); + let entries = "/Resources<>/F2<>>>/XObject<>>>"; + let bytes = tiny_pdf_with(entries, "/F1 12 Tf /X1 Do /F2 12 Tf /X1 Do", &form); + let issues = unembedded_fonts(&bytes); + assert_eq!(issues.len(), 2, "{issues:?}"); + assert!(issues.iter().any(|w| w.contains("Times-Roman isn't embedded"))); +} + +#[test] +fn font_scan_operation_budget_warns_instead_of_silently_truncating() { + let content = "q Q\n".repeat(MAX_OPERATIONS); + let bytes = tiny_pdf_with("/Resources<<>>", &content, "<<>>"); + assert_eq!(unembedded_fonts(&bytes), [LIMIT_WARNING]); +} + +#[test] +fn font_scan_depth_limit_warns_instead_of_silently_truncating() { + // The font is two levels below the deepest form followed, and every level draws the next + // ten times: memo replays of the cut-short subtree still give one warning. + assert_eq!(unembedded_fonts(&fanout_pdf(18)), [DEPTH_WARNING]); +} + +#[test] +fn font_scan_cycles_are_not_reported_as_incomplete() { + let font = "/Font<>>>"; + let bytes = pdf_objects(&[ + "<>".to_string(), + "<>".to_string(), + "<>>>/Contents 4 0 R>>".to_string(), + stream("", "/A Do"), + stream(&format!("/Type/XObject/Subtype/Form/BBox[0 0 100 100]/Resources<<{font}/XObject<>>>"), "BT /F1 12 Tf (a) Tj ET /B Do"), + stream("/Type/XObject/Subtype/Form/BBox[0 0 100 100]/Resources<>>>", "/A Do /B Do"), + ]); + let warnings = unembedded_fonts(&bytes); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert!(warnings[0].contains("Helvetica isn't embedded"), "{warnings:?}"); +} diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index ee02e2190..39f472ffc 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -340,6 +340,26 @@ fn cmyk_tiff_keeps_its_inks() { } } +/// A placed greyscale TIFF went into PDFs as DeviceRGB, unlike a greyscale PNG or JPEG (#1426): +/// a page holding only a black-and-white scan declared colour. +#[test] +fn grey_tiff_exports_as_device_gray() { + let px: Vec = vec![0, 64, 128, 255, 255, 128, 64, 0]; + let mut tif = Vec::new(); + tiff::encoder::TiffEncoder::new(std::io::Cursor::new(&mut tif)).unwrap().write_image::(4, 2, &px).unwrap(); + let mut d = doc_with_text("x"); + place(&mut d, tif, (4, 2)); + for standard in [Standard::None, Standard::PdfX4] { + let r = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { standard, ..Default::default() }).unwrap(); + assert!(r.warnings.is_empty(), "{standard:?}: {:?}", r.warnings); + assert_eq!(image_xobjects(&r.bytes), vec![("DeviceGray".to_string(), px.clone())], "{standard:?}"); + } + // Recompressed, it stays a grey JPEG. + let r = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { compress_images: true, compress: false, ..Default::default() }).unwrap(); + let s = String::from_utf8_lossy(&r.bytes); + assert!(s.contains("/DCTDecode") && s.contains("/DeviceGray") && !s.contains("/DeviceRGB"), "a grey JPEG"); +} + /// An 8×8 raster encoded as `fmt`; `alpha` gives it a transparent corner. fn raster(fmt: image::ImageFormat, alpha: bool) -> Vec { let img = diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index 300a47d93..7687bd2f8 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -160,7 +160,7 @@ fn cluster_chars(members: &[PlacedGlyph], cluster: &str) -> Option Vec<(designcraft_compose::Rule, Rect)> { let mut bars: Vec<(designcraft_compose::Rule, Rect)> = Vec::new(); for line in &ft.lines { - for g in line.glyphs.iter().filter(|g| g.visible) { + for g in line.glyphs.iter().filter(|g| g.spans_rules()) { let style = &cs.styles[g.style as usize]; let (on, rule) = if underline { (style.underline, &style.underline_rule) } else { (style.strikethrough, &style.strike_rule) }; if !on { @@ -303,6 +303,25 @@ impl Exporter<'_> { }); } + /// One table cell's graphic, or its text composed in the cell's own area. + fn table_cell(&mut self, s: &mut Surface, c: &designcraft_compose::PlacedCell) { + if let Some(g) = &c.graphic { + if let Some(clip) = to_path(&c.clip.to_path(0.1)) { + s.push_clip_path(&clip, &krilla::paint::FillRule::NonZero); + s.push_transform(&tf(Affine::translate((c.clip.x0, c.clip.y0)))); + self.graphic(s, g); + s.pop(); + s.pop(); + } + } else if let Some(cft) = c.text.frames.first() { + s.push_transform(&tf(Affine::translate(c.origin.to_vec2()))); + let area = std::mem::replace(&mut self.text_area, lines_area(cft)); + self.frame_text(s, &c.text, cft, &c.source); + self.text_area = area; + s.pop(); + } + } + /// Table fragments: cell fills, cell text (real text), edges and the border. fn tables(&mut self, s: &mut Surface, ft: &FrameText) { for t in &ft.tables { @@ -314,21 +333,7 @@ impl Exporter<'_> { } } for c in &t.cells { - if let Some(g) = &c.graphic { - if let Some(clip) = to_path(&c.clip.to_path(0.1)) { - s.push_clip_path(&clip, &krilla::paint::FillRule::NonZero); - s.push_transform(&tf(Affine::translate((c.clip.x0, c.clip.y0)))); - self.graphic(s, g); - s.pop(); - s.pop(); - } - } else if let Some(cft) = c.text.frames.first() { - s.push_transform(&tf(Affine::translate(c.origin.to_vec2()))); - let area = std::mem::replace(&mut self.text_area, lines_area(cft)); - self.frame_text(s, &c.text, cft, &c.source); - self.text_area = area; - s.pop(); - } + self.table_cell(s, c); } for seg in &t.strokes { let st = &seg.stroke; @@ -638,3 +643,39 @@ fn glyph_outlines(glyphs: &[PlacedGlyph], size: f64, tan: f64) -> BezPath { } bp } + +#[cfg(test)] +mod rule_tests { + use super::*; + use designcraft_compose::Cache; + use designcraft_doc::{Document, ParaFormat, SpreadRef}; + use designcraft_geom::Rect; + + /// A tab draws nothing but advances to its stop, and the underline runs across it, as it does + /// on screen: a form's fill-in lines are underlined tabs (#1466). + #[test] + fn an_underline_runs_across_a_tab() { + let mut doc = Document::new(&Default::default()); + for style in &mut doc.styles_mut().paragraph { + style.chars.underline = Some(true); + } + let layer = doc.default_layer(); + let (_, sid) = + doc.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 400.0, 300.0), layer, "Name\tCity", ParaFormat::default()).unwrap(); + let cs = Cache::new().get(&doc, sid, None); + let line = &cs.frames[0].lines[0]; + let tab = line.glyphs.iter().find(|g| g.rendered_char == '\t').expect("the tab is placed on the line"); + let bars = text_rules(&cs, &cs.frames[0], true); + assert_eq!(bars.len(), 1, "one bar across the line, not one before the tab and one after: {bars:?}"); + let bar = bars[0].1; + assert!(bar.x0 <= tab.x + 0.5 && bar.x1 >= tab.x + tab.adv - 0.5, "the bar covers the tab: {bar:?}, tab {} + {}", tab.x, tab.adv); + // The strikethrough of the same text behaves the same way. + for style in &mut doc.styles_mut().paragraph { + style.chars.underline = None; + style.chars.strikethrough = Some(true); + } + let cs = Cache::new().get(&doc, sid, None); + assert!(text_rules(&cs, &cs.frames[0], true).is_empty(), "no underline now"); + assert_eq!(text_rules(&cs, &cs.frames[0], false).len(), 1, "one strike across the line"); + } +} diff --git a/crates/pdf/src/transitions.rs b/crates/pdf/src/transitions.rs index cbfbe27cc..d37581afd 100644 --- a/crates/pdf/src/transitions.rs +++ b/crates/pdf/src/transitions.rs @@ -43,6 +43,14 @@ pub(crate) fn int_after(s: &str, key: &str) -> Option { rest[..end].parse().ok() } +/// The text between the first `[` and the first `]` after it. `None` when there is no such pair (a `]` +/// before the `[` included). +pub(crate) fn bracketed(s: &str) -> Option<&str> { + let open = s.find('[')?; + let close = s[open..].find(']')? + open; + s.get(open + 1..close) +} + /// Byte range of object `id`'s body (between `obj` and `endobj`). pub(crate) fn object(pdf: &[u8], id: usize) -> Option> { // The latest copy (incremental updates re-state objects further on). @@ -132,7 +140,7 @@ pub fn add_page_entries(pdf: &[u8], extra: &[Option]) -> Option> let pages = String::from_utf8_lossy(&pdf[object(pdf, pages_id)?]).to_string(); let kids_at = pages.find("/Kids")?; let kids_str = &pages[kids_at + 5..]; - let kids_str = &kids_str[kids_str.find('[')? + 1..kids_str.find(']')?]; + let kids_str = bracketed(kids_str)?; let nums: Vec = kids_str.split_whitespace().filter_map(|t| t.parse().ok()).collect(); let kids: Vec = nums.chunks(2).map(|c| c[0]).collect(); let starts = object_starts(pdf, extra.iter().zip(&kids).filter_map(|(t, &kid)| t.as_ref().map(|_| kid))); @@ -199,6 +207,31 @@ pub fn add_catalog_entries(pdf: &[u8], entries: &str) -> Option> { Some(out) } +#[cfg(test)] +mod bracket_tests { + use super::{add_page_entries, bracketed}; + + #[test] + fn bracketed_ignores_a_close_before_the_open() { + assert_eq!(bracketed(" [3 0 R 4 0 R] "), Some("3 0 R 4 0 R")); + assert_eq!(bracketed("] ["), None); + assert_eq!(bracketed("] [3 0 R]"), Some("3 0 R")); + assert_eq!(bracketed("[]"), Some("")); + } + + /// `]` before the `[` of /Kids (a spoofed page tree) is refused, not a slice panic. + #[test] + fn kids_closed_before_opened_is_refused() { + let objects = ["<>", "<>"]; + let mut out = b"%PDF-1.6\n".to_vec(); + for (i, o) in objects.iter().enumerate() { + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + out.extend_from_slice(b"trailer\n<>\nstartxref\n0\n%%EOF\n"); + assert!(add_page_entries(&out, &[Some("/Rotate 0".to_string())]).is_none()); + } +} + #[cfg(test)] mod object_index_tests { use super::{MAX_INDEXED_PAGE_OBJECTS, object_starts}; diff --git a/crates/render/src/blendspace.rs b/crates/render/src/blendspace.rs index 79c476343..f6c2fa328 100644 --- a/crates/render/src/blendspace.rs +++ b/crates/render/src/blendspace.rs @@ -240,7 +240,7 @@ fn srgb_table() -> std::sync::Arc> { type Lut = (designcraft_color::cms::ColorSettings, std::sync::Arc>); static LUT: std::sync::Mutex> = std::sync::Mutex::new(None); let settings = designcraft_color::cms::active_settings(); - let mut g = LUT.lock().unwrap_or_else(|e| e.into_inner()); + let mut g = LUT.lock().unwrap_or_else(std::sync::PoisonError::into_inner); match g.as_ref() { Some((s, t)) if *s == settings => t.clone(), _ => { diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index 4a6052781..37606097d 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -56,7 +56,7 @@ fn size_of(pm: &Pixmap) -> usize { } fn with(f: impl FnOnce(&mut Cache) -> R) -> R { - let mut g = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + let mut g = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let c = g.get_or_insert_with(Cache::default); // Decoding converts CMYK through the working space: new colour settings, new pixels. let colour = designcraft_color::cms::generation(); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 6823fc006..522e245cb 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -277,6 +277,26 @@ pub fn default_threads() -> u16 { } } +/// An item's fill under its content; an overprinting fill multiplies into what is beneath. +fn draw_body_fill(ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine) { + if it.fill.is_none() { + return; + } + let doc = f.doc; + ctx.set_transform(f.view * xf); + let op = overprints(f, &it.fill.swatch, it.fill.tint, it.fill.overprint); + if op { + ctx.push_layer(None, Some(BlendMode::new(Mix::Multiply, Compose::SrcOver)), None, None, None); + } + if set_fill_paint(ctx, doc, &it.fill, bp.bounding_box()) { + ctx.fill_path(bp); + } + ctx.reset_paint_transform(); + if op { + ctx.pop_layer(); + } +} + impl Renderer { pub fn new() -> Self { Self { @@ -627,22 +647,7 @@ impl Renderer { /// Fill, content and stroke of a leaf item (no opacity, blend or effects). pub(crate) fn draw_body(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page: ShownOn) { - let doc = f.doc; - // Fill. - if !it.fill.is_none() { - ctx.set_transform(f.view * xf); - let op = overprints(f, &it.fill.swatch, it.fill.tint, it.fill.overprint); - if op { - ctx.push_layer(None, Some(BlendMode::new(Mix::Multiply, Compose::SrcOver)), None, None, None); - } - if set_fill_paint(ctx, doc, &it.fill, bp.bounding_box()) { - ctx.fill_path(bp); - } - ctx.reset_paint_transform(); - if op { - ctx.pop_layer(); - } - } + draw_body_fill(ctx, f, it, bp, xf); // Content. match &it.content { Content::Graphic(g) => { @@ -875,14 +880,20 @@ impl Renderer { let on_screen = (f.view * xf * g.xf).determinant().abs().sqrt(); let on_screen = if f.opts.quality == DisplayQuality::Typical { on_screen.min(1.0) } else { on_screen }; let hidden = HIDDEN_LAYERS.with(|h| h.borrow().clone()); - let data = if !hidden.is_empty() && is_pdf(&asset.data) { pdf_layers::layered(&asset.data, &hidden) } else { asset.data.clone() }; + let data = if !hidden.is_empty() && is_pdf(&asset.data) { + // Stale persisted/relinked choices must not reveal hidden source content. + let Some(variant) = pdf_layers::layered(&asset.data, &hidden) else { return }; + variant + } else { + asset.data.clone() + }; // A placed PDF cropped to a box: the whole page, placed so that the box spans graphic space. let [l, t, r, b] = shown_box(asset).unwrap_or([0.0, 0.0, 1.0, 1.0]); let (page_w, page_h) = (g.size.0 / (r - l), g.size.1 / (b - t)); let page = Rect::new(-l * page_w, -t * page_h, (1.0 - l) * page_w, (1.0 - t) * page_h); let Some(pm) = images::mip(&data, asset.page, page_w, on_screen) else { return }; // A coloured image is already in the plate's inks (its colours come from `color_of`). - let pm = match (colorize::colorized(f.doc, &g.fill, &asset.data, &pm), PLATE.with(|p| p.get())) { + let pm = match (colorize::colorized(f.doc, &g.fill, &asset.data, &pm), PLATE.with(std::cell::Cell::get)) { (Some(coloured), _) => coloured, (None, Some(plate)) => plate_pixmap(&pm, plate), (None, None) => pm, @@ -913,7 +924,7 @@ pub(crate) fn cmyk_table() -> std::sync::Arc> { type Lut = (designcraft_color::cms::ColorSettings, std::sync::Arc>); static LUT: std::sync::Mutex> = std::sync::Mutex::new(None); let settings = designcraft_color::cms::active_settings(); - let mut g = LUT.lock().unwrap_or_else(|e| e.into_inner()); + let mut g = LUT.lock().unwrap_or_else(std::sync::PoisonError::into_inner); match g.as_ref() { Some((s, t)) if *s == settings => t.clone(), _ => { @@ -961,7 +972,7 @@ fn plate_pixmap(pm: &std::sync::Arc, plate: u8) -> std::sync: let key = (std::sync::Arc::as_ptr(pm) as usize, plate); let settings = designcraft_color::cms::active_settings(); { - 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); if let Some((previous, map, _)) = guard.as_ref() { if *previous == settings { if let Some((_, derived)) = map.get(&key) { @@ -994,7 +1005,7 @@ fn plate_pixmap(pm: &std::sync::Arc, plate: u8) -> std::sync: let out = std::sync::Arc::new(vello_cpu::Pixmap::from_parts(data, pm.width(), pm.height())); let bytes = (pm.width() as usize).saturating_mul(pm.height() as usize).saturating_mul(8); if bytes <= BUDGET { - 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 (previous, map, used) = guard.get_or_insert_with(|| (settings.clone(), Default::default(), 0)); if *previous != settings || used.saturating_add(bytes) > BUDGET { map.clear(); @@ -1024,7 +1035,7 @@ thread_local! { /// Screen indicators and placeholders are display sRGB, independent of the document's RGB /// working profile. Encode their inks only while drawing a process plate or CMYK blend pass. pub(crate) fn screen_color(rgb: [u8; 3], alpha: u8) -> peniko::Color { - if PLATE.with(|plate| plate.get()).is_none() { + if PLATE.with(std::cell::Cell::get).is_none() { return peniko::Color::from_rgba8(rgb[0], rgb[1], rgb[2], alpha); } let [c, m, y, k] = cmyk_in(&cmyk_table(), rgb.map(|v| v as f32 / 255.0)); @@ -1032,7 +1043,7 @@ pub(crate) fn screen_color(rgb: [u8; 3], alpha: u8) -> peniko::Color { } pub fn color_of(c: &designcraft_color::Color, alpha: f32) -> peniko::Color { - if let Some(p) = PLATE.with(|p| p.get()) { + if let Some(p) = PLATE.with(std::cell::Cell::get) { let cmyk = match *c { designcraft_color::Color::Cmyk { c, m, y, k } => [c, m, y, k], _ => c.to_cmyk_managed(designcraft_color::cms::Intent::RelativeColorimetric), @@ -1045,7 +1056,7 @@ pub fn color_of(c: &designcraft_color::Color, alpha: f32) -> peniko::Color { let v = ink(p.min(3) as usize); return peniko::Color::from_rgba8(v, v, v, a); } - if RICH_BLACK.with(|r| r.get()) + if RICH_BLACK.with(std::cell::Cell::get) && let designcraft_color::Color::Cmyk { c: cc, m, y, k } = *c && cc <= 1e-3 && m <= 1e-3 @@ -1392,8 +1403,13 @@ pub fn pdf_crop_box(bytes: &[u8], page: usize, crop: designcraft_doc::PdfCrop) - PdfCrop::Crop => WHOLE, PdfCrop::ContentVisible => pdf_bounds::page_content_bounds(bytes, page).unwrap_or(WHOLE), PdfCrop::ContentAll => { - let all_on = pdf_hide_layers(bytes, &[]); - pdf_bounds::page_content_bounds(all_on.as_deref().unwrap_or(bytes), page).unwrap_or(WHOLE) + if pdf_layers::has_optional_content(&pdf) { + // If all-on rewriting is unsupported, a visible-only box could cut + // off hidden content. Keep the entire page conservatively instead. + pdf_hide_layers(bytes, &[]).and_then(|all_on| pdf_bounds::page_content_bounds(&all_on, page)).unwrap_or(WHOLE) + } else { + pdf_bounds::page_content_bounds(bytes, page).unwrap_or(WHOLE) + } } PdfCrop::Art | PdfCrop::Trim | PdfCrop::Bleed | PdfCrop::Media => { let media = p.media_box(); diff --git a/crates/render/src/pdf_layers.rs b/crates/render/src/pdf_layers.rs index bc6d864a7..3b323a505 100644 --- a/crates/render/src/pdf_layers.rs +++ b/crates/render/src/pdf_layers.rs @@ -6,20 +6,17 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; use craft_pdf::syntax::Pdf; -use craft_pdf::syntax::object::{Array, Dict, Name, ObjectIdentifier}; +use craft_pdf::syntax::object::{Array, Dict, MaybeRef, Name, ObjRef, Object, ObjectIdentifier}; -/// (object number, name, on by default) of each layer. -fn ocgs(pdf: &Pdf) -> Vec<(i32, String, bool)> { +/// (object reference, name, on by default) of each layer. +fn ocgs(pdf: &Pdf) -> Vec<(ObjRef, String, bool)> { let xref = pdf.xref(); let Some(cat) = xref.get::>(xref.root_id()) else { return vec![] }; let Some(ocp) = cat.get::>(b"OCProperties") else { return vec![] }; let Some(list) = ocp.get::>(b"OCGs") else { return vec![] }; let cfg = ocp.get::>(b"D"); - let ids_in = |key: &[u8]| -> Vec { - cfg.as_ref() - .and_then(|c| c.get::>(key)) - .map(|a| a.raw_iter().filter_map(|o| o.as_obj_ref()).map(|r| r.obj_number).collect()) - .unwrap_or_default() + let ids_in = |key: &[u8]| -> Vec { + cfg.as_ref().and_then(|c| c.get::>(key)).map(|a| a.raw_iter().filter_map(|o| o.as_obj_ref()).collect()).unwrap_or_default() }; let base_off = cfg.as_ref().and_then(|c| c.get::>(b"BaseState")).is_some_and(|n| n.as_ref() == b"OFF"); let (on, off) = (ids_in(b"ON"), ids_in(b"OFF")); @@ -31,73 +28,283 @@ fn ocgs(pdf: &Pdf) -> Vec<(i32, String, bool)> { .get::>(b"Name") .map(|s| String::from_utf8_lossy(s.as_bytes()).to_string()) .unwrap_or_default(); - let visible = if base_off { on.contains(&r.obj_number) } else { !off.contains(&r.obj_number) }; - Some((r.obj_number, name, visible)) + let visible = if base_off { on.contains(&r) } else { !off.contains(&r) }; + Some((r, name, visible)) }) .collect() } +/// Whether interpreting this file can depend on optional-content state. Empty/stub +/// properties are layerless, but malformed group references with state overrides +/// still require a conservative all-content crop when rewriting is unavailable. +pub(crate) fn has_optional_content(pdf: &Pdf) -> bool { + let Some(cat) = pdf.xref().get::>(pdf.xref().root_id()) else { return false }; + let Some(ocp) = cat.get::>(b"OCProperties") else { return false }; + let nonempty = |dict: &Dict<'_>, key: &[u8]| dict.get::>(key).is_some_and(|a| a.raw_iter().next().is_some()); + nonempty(&ocp, b"OCGs") || ocp.get::>(b"D").is_some_and(|cfg| nonempty(&cfg, b"ON") || nonempty(&cfg, b"OFF")) +} + /// The layers of a placed PDF: (name, visible by default). pub fn pdf_layers(bytes: &[u8]) -> Vec<(String, bool)> { craft_pdf::open_slice(bytes, None).map(|p| ocgs(&p).into_iter().map(|(_, n, v)| (n, v)).collect()).unwrap_or_default() } -/// `bytes` with exactly the layers named in `hidden` off (the others on). `None` when the file -/// has no such layers or can't be read. +// Copy untouched dictionary entries as raw PDF tokens. In particular, do not reinterpret +// strings, numbers or unknown extension keys. The parser bounds container nesting. +fn patch_dict(dict: &Dict<'_>, updates: &[(&[u8], &[u8])]) -> Option> { + use craft_pdf::syntax::reader::{Reader, ReaderExt}; + let data = if dict.is_empty() { b"<<>>".as_slice() } else { dict.data() }; + if data.len() > 4 * 1024 * 1024 { + return None; + } + let mut reader = Reader::new(data); + reader.forward_tag(b"<<")?; + let mut out = b"<<".to_vec(); + let mut ended = false; + for _ in 0..100_000 { + reader.skip_white_spaces_and_comments(); + if reader.forward_tag(b">>").is_some() { + ended = true; + break; + } + let start = reader.offset(); + let name = reader.read_without_context::>()?; + reader.skip_white_spaces_and_comments(); + reader.skip::>>(false)?; + if !updates.iter().any(|(key, _)| *key == name.as_ref()) { + out.push(b' '); + out.extend_from_slice(data.get(start..reader.offset())?); + } + } + if !ended { + return None; + } + for (key, value) in updates { + if !value.is_empty() { + out.extend_from_slice(b" /"); + out.extend_from_slice(key); + out.push(b' '); + out.extend_from_slice(value); + } + } + out.extend_from_slice(b" >>"); + Some(out) +} + +fn trailer_at(bytes: &[u8], offset: usize) -> Option> { + use craft_pdf::syntax::reader::{Reader, ReaderExt}; + const MAX_TABLE_BYTES: usize = 4 * 1024 * 1024; + let data = bytes.get(offset..)?; + // Bound even whitespace/comment scanning and context-free dictionary parsing. + let mut reader = Reader::new(data.get(..data.len().min(MAX_TABLE_BYTES))?); + reader.skip_white_spaces_and_comments(); + if reader.forward_tag(b"xref").is_some() { + // Walk table syntax, never a raw substring: a repairable file may contain + // a fake "trailer" in a comment. Such a comment must not hide real guards. + let mut ended = false; + for _ in 0..100_000 { + if reader.offset() > MAX_TABLE_BYTES { + return None; + } + reader.skip_white_spaces(); + if reader.forward_tag(b"trailer").is_some() { + ended = true; + break; + } + reader.read_without_context::()?; // first object number + reader.skip_white_spaces(); + let count = reader.read_without_context::()?; + if count > MAX_TABLE_BYTES / 18 { + return None; + } + for _ in 0..count { + reader.skip_white_spaces(); + reader.read_without_context::()?; // offset or next free object + reader.skip_white_spaces(); + if reader.read_without_context::()? > 65535 { + return None; + } + reader.skip_white_spaces(); + reader.forward_tag(b"n").or_else(|| reader.forward_tag(b"f"))?; + if reader.offset() > MAX_TABLE_BYTES { + return None; + } + } + } + if !ended { + return None; + } + reader.skip_white_spaces(); + } else { + // An xref stream's dictionary also holds the trailer entries. + reader.read_without_context::()?; + reader.skip_white_spaces_and_comments(); + } + reader.read_without_context::>() +} + +// Null dictionary entries are absent in PDF. Keep references and malformed values +// fail-closed: trailer_at's dictionaries have no context for resolving references. +fn has_non_null_entry(dict: &Dict<'_>, key: &[u8]) -> bool { + dict.contains_key(key) && !matches!(dict.get_raw::>(key), Some(MaybeRef::NotRef(Object::Null(_)))) +} + +/// `bytes` with exactly the layers named in `hidden` off (the others on). +/// Returns `None` for unreadable, encrypted, hybrid-reference or over-budget updates. +/// Untouched catalog/configuration entries and full object references are preserved. pub fn pdf_hide_layers(bytes: &[u8], hidden: &[String]) -> Option> { + crate::check_pdf_input_size(bytes.len()).ok()?; let pdf = craft_pdf::open_slice(bytes, None).ok()?; let layers = ocgs(&pdf); if layers.is_empty() { return None; } - let xref = pdf.xref(); - let cat = xref.get::>(xref.root_id())?; - let pages = cat.get_ref(b"Pages")?; - let all: Vec = layers.iter().map(|(n, _, _)| format!("{n} 0 R")).collect(); - let off: Vec = layers.iter().filter(|(_, name, _)| hidden.contains(name)).map(|(n, _, _)| format!("{n} 0 R")).collect(); - // A new catalog under a fresh object number, with the original's page tree. - let root = 9_999_000; - let mut out = bytes.to_vec(); - if !out.ends_with(b"\n") { - out.push(b'\n'); + let tail = String::from_utf8_lossy(bytes.get(bytes.len().saturating_sub(1024)..)?); + let prev = tail.rsplit_once("startxref")?.1.split_whitespace().next()?.parse::().ok()?; + let trailer = trailer_at(bytes, prev)?; + // Do not copy decrypted dictionaries into an encrypted file, or ignore a hybrid + // xref table. Follow bounded Prev links because encryption can be inherited. + let mut offset = Some(prev); + let mut visited = std::collections::HashSet::new(); + for _ in 0..32 { + let Some(at) = offset else { break }; + if !visited.insert(at) { + return None; + } + let t = trailer_at(bytes, at)?; + if has_non_null_entry(&t, b"Encrypt") || has_non_null_entry(&t, b"XRefStm") { + return None; + } + offset = t.get::(b"Prev"); + if offset.is_none() && has_non_null_entry(&t, b"Prev") { + return None; + } } + if offset.is_some() { + return None; + } + let xref = pdf.xref(); + let root = xref.root_id(); + if root.obj_number <= 0 || !(0..=65535).contains(&root.gen_number) || trailer.get::(b"Size")? <= usize::try_from(root.obj_number).ok()? { + return None; + } + let cat = xref.get::>(root)?; + let ocp = cat.get::>(b"OCProperties")?; + let cfg = ocp.get::>(b"D"); + let mut off = b"[".to_vec(); + for (id, name, _) in &layers { + if hidden.contains(name) { + off.extend_from_slice(format!("{} {} R ", id.obj_number, id.gen_number).as_bytes()); + } + } + off.push(b']'); + let empty = Dict::empty(); + let cfg = patch_dict(cfg.as_ref().unwrap_or(&empty), &[(b"BaseState", b"/ON"), (b"ON", b"[]"), (b"OFF", &off)])?; + let ocp = patch_dict(&ocp, &[(b"D", &cfg)])?; + let catalog = patch_dict(&cat, &[(b"OCProperties", &ocp)])?; + let root_ref = format!("{} {} R", root.obj_number, root.gen_number); + let prev_value = prev.to_string(); + let trailer = patch_dict( + &trailer, + &[ + (b"Root", root_ref.as_bytes()), + (b"Prev", prev_value.as_bytes()), + (b"Type", b""), + (b"Length", b""), + (b"Filter", b""), + (b"DecodeParms", b""), + (b"W", b""), + (b"Index", b""), + ], + )?; + let mut out = bytes.to_vec(); + out.push(b'\n'); let at = out.len(); - out.extend_from_slice( - format!( - "{root} 0 obj\n<>>>>>\nendobj\n", - pages.obj_number, - pages.gen_number, - all.join(" "), - off.join(" ") - ) - .as_bytes(), - ); + // Redefine the existing catalog: no arbitrary fresh object number or Size increase. + out.extend_from_slice(format!("{} {} obj\n", root.obj_number, root.gen_number).as_bytes()); + out.extend_from_slice(&catalog); + out.extend_from_slice(b"\nendobj\n"); let xref_at = out.len(); - let prev = { - let tail = String::from_utf8_lossy(&bytes[bytes.len().saturating_sub(1024)..]).to_string(); - tail.rsplit_once("startxref").and_then(|(_, t)| t.split_whitespace().next()).and_then(|n| n.parse::().ok())? - }; - out.extend_from_slice( - format!("xref\n{root} 1\n{at:010} 00000 n\r\ntrailer\n<>\nstartxref\n{xref_at}\n%%EOF\n", root + 1) - .as_bytes(), - ); + out.extend_from_slice(format!("xref\n{} 1\n{at:010} {:05} n\r\ntrailer\n", root.obj_number, root.gen_number).as_bytes()); + out.extend_from_slice(&trailer); + out.extend_from_slice(format!("\nstartxref\n{xref_at}\n%%EOF\n").as_bytes()); Some(out) } /// Shared copies of layer variants (by source address and hidden set). -pub(crate) fn layered(data: &Arc>, hidden: &[String]) -> Arc> { +pub(crate) fn layered(data: &Arc>, hidden: &[String]) -> Option>> { type Key = (usize, Vec); - static CACHE: Mutex>>>> = Mutex::new(None); + static CACHE: Mutex>, Option>>)>>> = Mutex::new(None); let key = (Arc::as_ptr(data) as usize, hidden.to_vec()); - let mut c = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + let mut c = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let map = c.get_or_insert_with(HashMap::new); if let Some(v) = map.get(&key) { - return v.clone(); + return v.1.clone(); } if map.len() > 64 { map.clear(); } - let v = pdf_hide_layers(data, hidden).map(Arc::new).unwrap_or_else(|| data.clone()); - map.insert(key, v.clone()); + let v = pdf_hide_layers(data, hidden).map(Arc::new); + map.insert(key, (data.clone(), v.clone())); v } + +#[cfg(test)] +mod tests { + use super::*; + use craft_pdf::syntax::reader::{Reader, ReaderExt}; + + #[test] + fn trailer_lookup_does_not_take_guard_values_from_xref_comments() { + for guard in ["/Encrypt 12 0 R", "/XRefStm 123"] { + let bytes = format!("xref\n% trailer <>\n0 1\n0000000000 65535 f\r\ntrailer\n<>"); + assert!(trailer_at(bytes.as_bytes(), 0).is_none(), "repair-only table must fail closed: {guard}"); + } + } + + #[test] + fn trailer_lookup_accepts_whitespace_and_bounded_table_layouts() { + for eol in ["\n", "\r\n"] { + let bytes = format!(" \r\nxref{eol}0 1{eol}0000000000 65535 f{eol}7 1{eol}0000000123 00001 n{eol}trailer{eol}<>"); + assert_eq!(trailer_at(bytes.as_bytes(), 0).unwrap().get::(b"Size"), Some(9)); + } + assert!(trailer_at(b"xref\n0 1\n0000000000 65535 f\ntrailer% comment\n<< /Size 1 >>", 0).is_none()); + let mut whitespace = vec![b' '; 4 * 1024 * 1024]; + whitespace.extend_from_slice(b"xref\n0 0\ntrailer<<>>"); + assert!(trailer_at(&whitespace, 0).is_none()); + for bytes in [b"xref\n0 999999999\n".as_slice(), b"xref\n0 1\n0000000000 99999 f\ntrailer<<>>", b"xref\n0 1\n0000000000 65535 x\ntrailer<<>>"] + { + assert!(trailer_at(bytes, 0).is_none()); + } + } + + #[test] + fn null_trailer_entries_are_absent_but_references_stay_unsupported() { + for key in ["Encrypt", "XRefStm"] { + assert!(!has_non_null_entry(&Dict::empty(), key.as_bytes())); + for (value, allowed) in [("null", true), ("12 0 R", false), ("<< /Filter /Standard >>", false), ("false", false)] { + let input = format!("<>"); + let dict = Reader::new(input.as_bytes()).read_without_context::>().unwrap(); + assert_eq!(!has_non_null_entry(&dict, key.as_bytes()), allowed, "{key}: {value}"); + } + } + } + + #[test] + fn dictionary_patch_preserves_raw_values_and_bounds_updates() { + let data = b"<]/Base#53tate/OFF/Name(a(b)c)>>"; + let dict = Reader::new(data).read_without_context::>().unwrap(); + let out = patch_dict(&dict, &[(b"BaseState", b"/ON")]).unwrap(); + let text = std::str::from_utf8(&out).unwrap(); + assert!(text.contains("/Unknown[(x) 12 1 R ]")); + assert!(text.contains("/Name(a(b)c)")); + assert!(!text.contains("/OFF")); + assert!(text.contains("/BaseState /ON")); + assert_eq!(patch_dict(&Dict::empty(), &[(b"BaseState", b"/ON")]).unwrap(), b"<< /BaseState /ON >>"); + let mut many = b"<< /Big (".to_vec(); + many.resize(4 * 1024 * 1024 + 1, b'x'); + many.extend_from_slice(b") >>"); + let dict = Reader::new(&many).read_without_context::>().unwrap(); + assert!(patch_dict(&dict, &[]).is_none()); + } +} diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 70f31b4f6..a9cdc5ce3 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -156,13 +156,29 @@ fn union(r: Option, b: Rect) -> Option { Some(r.map_or(b, |r| r.union(b))) } +/// Add a rule bar, joined onto the previous one when it continues the same stretch. +fn push_bar(bars: &mut Vec<(String, f32, Rect)>, color: &str, tint: f32, r: Rect) { + match bars.last_mut() { + Some((c, t, last)) + if c.as_str() == color + && *t == tint + && (last.y0 - r.y0).abs() < 1e-6 + && (last.y1 - r.y1).abs() < 1e-6 + && (r.x0 - last.x1).abs() < 0.5 => + { + last.x1 = last.x1.max(r.x1); + } + _ => bars.push((color.to_owned(), tint, r)), + } +} + fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line, vertical: bool) -> LineGlyphs { let mut runs: Vec<(u32, BezPath)> = Vec::new(); let mut underlines: Vec<(String, f32, Rect)> = Vec::new(); let mut strikes: Vec<(String, f32, Rect)> = Vec::new(); let mut glyphs = 0; for g in &l.glyphs { - if !g.visible { + if !g.spans_rules() { continue; } let style = &cs.styles[g.style as usize]; @@ -171,20 +187,13 @@ fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line, vertical: bool) -> Line let r = rule.rect(g.x, g.x + g.adv, designcraft_compose::rule_baseline(style, l, g)); // One bar per stretch, as the PDF export draws them: a bar per glyph left // anti-aliased seams between the letters. - match bars.last_mut() { - Some((color, tint, last)) - if *color == rule.color - && *tint == rule.tint - && (last.y0 - r.y0).abs() < 1e-6 - && (last.y1 - r.y1).abs() < 1e-6 - && (r.x0 - last.x1).abs() < 0.5 => - { - last.x1 = last.x1.max(r.x1); - } - _ => bars.push((rule.color.clone(), rule.tint, r)), - } + push_bar(bars, &rule.color, rule.tint, r); } } + // A tab carries the rule across its width but draws nothing itself. + if !g.visible { + continue; + } let outline = db.outline(&g.face, g.gid); if outline.elements().is_empty() { continue; @@ -725,3 +734,45 @@ mod cache_identity_tests { assert_eq!(glyphs.lines[0].runs, runs); } } + +#[cfg(test)] +mod rule_tests { + use super::*; + + /// A tab is not drawn, but it is a character with an advance, and InDesign underlines it: + /// forms rule their fill-in lines with underlined tabs (`Name ⇥⇥⇥⇥, city ⇥⇥⇥⇥`). The bars + /// stopped at the last letter before each tab, so an imported form lost every writing line + /// (#1466). + #[test] + fn an_underline_runs_across_a_tab() { + let mut doc = designcraft_doc::Document::new(&Default::default()); + for style in &mut doc.styles_mut().paragraph { + style.chars.underline = Some(true); + } + let layer = doc.default_layer(); + let (_, sid) = doc + .add_text_frame( + designcraft_doc::SpreadRef::Doc(0), + Rect::new(36.0, 36.0, 400.0, 300.0), + layer, + "Name\tCity", + designcraft_doc::ParaFormat::default(), + ) + .unwrap(); + let owner = crate::Cache::new(); + let cs = owner.get(&doc, sid, None); + let line = &cs.frames[0].lines[0]; + let tab = line.glyphs.iter().find(|g| g.rendered_char == '\t').expect("the tab is placed on the line"); + assert!(!tab.visible, "a tab draws nothing itself"); + assert!(tab.adv > 1.0, "it advances to its stop: {}", tab.adv); + + let mut cache = GlyphCache::default(); + cache.tick(); + let glyphs = cache.get(&cs, &cs.frames[0]); + let bars = &glyphs.lines[0].underlines; + assert_eq!(bars.len(), 1, "one bar across the line, not one before the tab and one after: {bars:?}"); + let bar = bars[0].2; + assert!(bar.x0 <= tab.x + 0.5, "the bar starts at the text, not at the tab: {bar:?}"); + assert!(bar.x1 >= tab.x + tab.adv - 0.5, "the bar covers the tab's width: {bar:?}, tab {} + {}", tab.x, tab.adv); + } +} diff --git a/crates/textimport/src/docx.rs b/crates/textimport/src/docx.rs index 783b010c6..87aaa3359 100644 --- a/crates/textimport/src/docx.rs +++ b/crates/textimport/src/docx.rs @@ -115,6 +115,12 @@ pub(crate) fn parse(xml: &str) -> Result { p.kids.push(Node::Text(s)); } } + Event::CData(t) => { + let s = t.decode().map(|s| s.to_string()).unwrap_or_default(); + if let Some(p) = stack.last_mut() { + p.kids.push(Node::Text(s)); + } + } Event::GeneralRef(e) => { let name = String::from_utf8_lossy(&e).to_string(); let s = match name.as_str() { @@ -180,8 +186,9 @@ fn run_attrs(rpr: Option<&El>) -> CharAttrs { if let Some(s) = r.on("strike") { a.strikethrough = Some(s); } - if let Some(sz) = r.val("sz").and_then(|v| v.parse::().ok()).filter(|v| v.is_finite()) { - a.size = Some(sz / 2.0); + // Half-points; sizes outside the engine's 0.1–1296 pt range (or non-finite) are dropped. + if let Some(pt) = r.val("sz").and_then(|v| v.parse::().ok()).map(|sz| sz / 2.0).filter(|pt| (0.1..=1296.0).contains(pt)) { + a.size = Some(pt); } if let Some(f) = r.child("rFonts").and_then(|f| f.attr("ascii").or(f.attr("hAnsi"))) { a.font_family = Some(f.to_string()); @@ -578,6 +585,12 @@ mod tests { use super::*; use std::io::Write; + #[test] + fn cdata_text() { + let el = parse("").unwrap(); + assert_eq!(el.child("t").unwrap().text(), "x < y"); + } + /// Issue #712: nesting past the budget is an error from the parser, not a stack overflow later. #[test] fn nesting_past_the_budget_is_refused() { @@ -702,6 +715,19 @@ mod tests { } } + #[test] + fn out_of_range_sizes_are_dropped() { + let document = format!( + r#" + ab + "# + ); + let st = import(&docx(&document, "", "")).unwrap().story; + for at in [0, 1] { + assert_eq!(st.format_after(at).over.size, None, "run {at}"); + } + } + #[test] fn tracked_changes_import_as_accepted_including_moves() { // Regression (#256): text in a tracked move destination (`w:moveTo`) vanished on import. diff --git a/crates/textimport/src/lib.rs b/crates/textimport/src/lib.rs index ba9cb92e3..56eac1a00 100644 --- a/crates/textimport/src/lib.rs +++ b/crates/textimport/src/lib.rs @@ -114,7 +114,7 @@ pub fn import(name: &str, bytes: &[u8]) -> Result { if l.ends_with(".doc") { return Err(ImportError::Unsupported("legacy Word .doc (save it as .docx)".into())); } - Ok(plain(&String::from_utf8_lossy(bytes))) + Ok(plain(&tagged::decode(bytes))) } /// Plain text: line breaks become paragraphs (CRLF / CR normalised). @@ -136,4 +136,14 @@ mod tests { assert!(is_text_file("Report.DOCX") && !is_text_file("a.png")); assert!(matches!(import("x.doc", b"\xD0\xCF"), Err(ImportError::Unsupported(_)))); } + + #[test] + fn plain_text_utf16() { + let mut le = vec![0xFF, 0xFE]; + le.extend("Hi there".encode_utf16().flat_map(u16::to_le_bytes)); + assert_eq!(import("a.txt", &le).unwrap().story.text, "Hi there"); + let mut be = vec![0xFE, 0xFF]; + be.extend("Hi there".encode_utf16().flat_map(u16::to_be_bytes)); + assert_eq!(import("a.txt", &be).unwrap().story.text, "Hi there"); + } } diff --git a/crates/textimport/src/rtf.rs b/crates/textimport/src/rtf.rs index baf065c30..782071798 100644 --- a/crates/textimport/src/rtf.rs +++ b/crates/textimport/src/rtf.rs @@ -95,6 +95,8 @@ pub fn import(bytes: &[u8]) -> Result { stack.push(top); } first = false; + // A group is not a character: the `\uN` fallback skip ends here too. + pending = 0; // `{\*\dest …}`: an ignorable destination. if chars.get(i + 1) == Some(&'\\') && chars.get(i + 2) == Some(&'*') @@ -134,6 +136,11 @@ pub fn import(bytes: &[u8]) -> Result { } if !next.is_ascii_alphabetic() { // Control symbols: \\ \{ \} \~ (nbsp) \- (soft hyphen) \_ (nb hyphen). + i += 2; + if pending > 0 { + pending -= 1; + continue; + } let skip = stack.last().is_some_and(|s| s.skip); if !skip { match next { @@ -149,7 +156,6 @@ pub fn import(bytes: &[u8]) -> Result { _ => {} } } - i += 2; continue; } // A control word with an optional numeric parameter and one delimiting space. @@ -172,6 +178,11 @@ pub fn import(bytes: &[u8]) -> Result { k += 1; } i = k; + // A control word is one character of the `\uN` fallback (a following `\u` is not). + if pending > 0 && word != "u" { + pending -= 1; + continue; + } let skip = stack.last().is_some_and(|s| s.skip); if SKIP.contains(&word.as_str()) { flush(&mut st, &mut buf, &fmt); @@ -234,9 +245,12 @@ pub fn import(bytes: &[u8]) -> Result { } "u" => { if let Some(n) = param { - let unit = if n < 0 { (n + 65536) as u32 } else { n as u32 }; - if let Some(ch) = units.push(unit) { - buf.push(ch); + // RTF allows -32768..=65535; anything else is dropped. + if (-32768..=65535).contains(&n) { + let unit = if n < 0 { (n + 65536) as u32 } else { n as u32 }; + if let Some(ch) = units.push(unit) { + buf.push(ch); + } } pending = stack.last().map_or(1, |s| s.uc); } @@ -282,6 +296,12 @@ fn cp1252(b: u8) -> char { mod tests { use super::*; + #[test] + fn out_of_range_unicode_escape_is_dropped() { + assert_eq!(import(br"{\rtf1 a\u4294967361?b}").unwrap().story.text, "ab"); + assert_eq!(import(br"{\rtf1 a\u-4294967231?b}").unwrap().story.text, "ab"); + } + #[test] fn imports_rtf_text_and_formatting() { let rtf = br"{\rtf1\ansi{\fonttbl{\f0 Times;}}{\colortbl;\red0\green0\blue0;}{\*\generator Test;} @@ -320,6 +340,18 @@ Caf\'e9 \u8364? price\tab tab\line next\par assert_eq!(i.story.text, "Rock\u{2014}-roll, x\t-y, ab"); } + /// A control word or control symbol is one character of the `\uN` fallback, so it is skipped + /// like a plain character and does not eat the text after it. + #[test] + fn control_fallback_after_unicode_is_skipped() { + let i = import(br"{\rtf1 \u8220\ldblquote Hi\u8221\rdblquote x\u8212\emdash y}").unwrap(); + assert_eq!(i.story.text, "\u{201C}Hi\u{201D} x\u{2014}y"); + let i = import(br"{\rtf1 \u8220\{Hi}").unwrap(); + assert_eq!(i.story.text, "\u{201C}Hi"); + let i = import(br"{\rtf1 \u8220{\b Hi}}").unwrap(); + assert_eq!(i.story.text, "\u{201C}Hi"); + } + /// Characters past U+FFFF are written as two `\uN` words, a UTF-16 surrogate pair (the form /// our own exporter and Word write). Each half was converted alone and dropped. #[test] diff --git a/crates/textimport/src/tagged.rs b/crates/textimport/src/tagged.rs index 9bc84b766..3a890734a 100644 --- a/crates/textimport/src/tagged.rs +++ b/crates/textimport/src/tagged.rs @@ -25,6 +25,37 @@ fn escape(out: &mut String, c: char) { } } +/// A tag value with '<', '>' and '\\' escaped by a backslash, so it keeps the tag intact. +fn escape_value(v: &str) -> String { + let mut out = String::with_capacity(v.len()); + for c in v.chars() { + if matches!(c, '<' | '>' | '\\') { + out.push('\\'); + } + out.push(c); + } + out +} + +/// A tag value with the escapes `escape_value` writes (`\\`, `\<`, `\>`) decoded. Any other +/// backslash, including a trailing one, stays literal, as older files and InDesign's `\:` expect. +fn unescape_value(v: &str) -> String { + let mut out = String::with_capacity(v.len()); + let mut it = v.chars().peekable(); + while let Some(c) = it.next() { + if c == '\\' + && let Some(&n) = it.peek() + && matches!(n, '\\' | '<' | '>') + { + out.push(n); + it.next(); + } else { + out.push(c); + } + } + out +} + /// `story` as Tagged Text. pub fn tagged_text(doc: &Document, story: &Story) -> String { let mut out = String::from("\r\n\r\n"); @@ -34,7 +65,7 @@ pub fn tagged_text(doc: &Document, story: &Story) -> String { out.push_str("\r\n"); } let pf = story.paras.get(i).cloned().unwrap_or_default(); - let _ = write!(out, "", pf.style.replace(['<', '>', ':'], "")); + let _ = write!(out, "", escape_value(&pf.style)); for (rr, f) in story.runs() { let (a, b) = (rr.start.max(r.start), rr.end.min(r.end)); if a >= b { @@ -45,7 +76,7 @@ pub fn tagged_text(doc: &Document, story: &Story) -> String { } let mut open = Vec::new(); if f.style != NO_CHAR_STYLE { - let _ = write!(out, "", f.style.replace(['<', '>', ':'], "")); + let _ = write!(out, "", escape_value(&f.style)); open.push("CharStyle"); } if let Some(v) = &f.over.font_family { @@ -72,7 +103,7 @@ pub fn tagged_text(doc: &Document, story: &Story) -> String { } /// Text of a Tagged Text file (ASCII, UTF-8 or UTF-16 by byte-order mark). -fn decode(bytes: &[u8]) -> String { +pub(crate) fn decode(bytes: &[u8]) -> String { if bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]) { let le = bytes[0] == 0xFF; let units: Vec = bytes[2..] @@ -153,7 +184,7 @@ pub fn import(bytes: &[u8]) -> Result { } "ParaStyle" => { started = true; - let v = value.trim().to_string(); + let v = unescape_value(value.trim()); if !v.is_empty() && !para_styles.contains(&v) { para_styles.push(v.clone()); } @@ -162,10 +193,10 @@ pub fn import(bytes: &[u8]) -> Result { } } "CharStyle" => { - let v = value.trim(); - fmt.style = if v.is_empty() { NO_CHAR_STYLE.into() } else { v.to_string() }; - if !v.is_empty() && !char_styles.contains(&v.to_string()) { - char_styles.push(v.to_string()); + let v = unescape_value(value.trim()); + fmt.style = if v.is_empty() { NO_CHAR_STYLE.into() } else { v.clone() }; + if !v.is_empty() && !char_styles.contains(&v) { + char_styles.push(v); } } "cFont" => fmt.over.font_family = (!value.is_empty()).then(|| value.to_string()), @@ -268,6 +299,34 @@ mod tests { assert_eq!(back.story.paras, story.paras); } + /// Style names with ':', '<', '>' or '\' were written with those characters dropped, so + /// re-import gave the paragraphs a different style. + #[test] + fn style_names_with_tag_characters_round_trip() { + let doc = Document::new(&designcraft_doc::build::NewDocument::default()); + for name in ["Body: Indent", "Ac", "x\\y", "end\\"] { + let mut story = Story::new(StoryId(0)); + story.insert_with(0, "Text", CharFormat { style: name.into(), ..Default::default() }); + story.paras[0].style = name.into(); + let out = tagged_text(&doc, &story); + let back = import(out.as_bytes()).unwrap(); + assert_eq!(back.story.text, "Text", "{out}"); + assert_eq!(back.story.paras[0].style, name, "{out}"); + assert_eq!(back.story.char_format_at(1).style, name, "{out}"); + assert_eq!(back.para_styles.iter().map(|s| s.name.as_str()).collect::>(), [name]); + assert_eq!(back.char_styles.iter().map(|s| s.name.as_str()).collect::>(), [name]); + } + } + + /// A backslash that isn't one of our escapes (older files, InDesign's `\:`) stays literal. + #[test] + fn unknown_backslashes_in_style_names_stay_literal() { + assert_eq!(unescape_value(r"x\y"), r"x\y"); + assert_eq!(unescape_value(r"a\:b"), r"a\:b"); + assert_eq!(unescape_value("end\\"), "end\\"); + assert_eq!(unescape_value(r"\\\"), r"\"); + } + #[test] fn tagged_text_round_trips() { let src = "\r\n\r\nCaf<0x00E9> \\<1\\>\r\nA word and big text"; diff --git a/crates/textimport/src/xlsx.rs b/crates/textimport/src/xlsx.rs index 3f20f3dee..83bea3c87 100644 --- a/crates/textimport/src/xlsx.rs +++ b/crates/textimport/src/xlsx.rs @@ -11,7 +11,7 @@ use crate::{ImportError, Imported}; /// "B12" → (row 11, column 1). fn cell_ref(r: &str) -> Option<(usize, usize)> { - let letters: String = r.chars().take_while(|c| c.is_ascii_alphabetic()).collect(); + let letters: String = r.chars().take_while(char::is_ascii_alphabetic).collect(); let row: usize = r[letters.len()..].parse().ok()?; let col = letters.chars().try_fold(0usize, |acc, c| { let digit = (c.to_ascii_uppercase() as u8).checked_sub(b'A')? as usize + 1; diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 866003102..cba5fc317 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -1,6 +1,7 @@ //! Selection tool (V) and Direct Selection tool (A). //! -//! Click selects the frontmost item (Shift toggles), drag moves (Alt duplicates, Shift constrains), +//! Click selects the frontmost item (Shift toggles; a click on one of several selected items makes +//! it the key object for Align, a second click drops it), drag moves (Alt duplicates, Shift constrains), //! the 8 bounding-box handles resize (Shift keeps proportions, Alt from centre, Cmd scales content), //! empty-canvas drags make a marquee (it selects the ruler guides it crosses when it touches no //! object), double-clicking a text frame switches to the Type tool and @@ -223,6 +224,9 @@ pub struct SelectionTool { guides: Vec, /// Selected item to toggle if this Shift press never becomes a drag. shift_release: Option, + /// One of several selected items to make (or stop being) the key object for Align if this + /// press never becomes a drag. + key_release: Option, /// The loaded text cursor. loaded: Option, /// Frame whose live corners are being edited (diamonds shown). @@ -231,7 +235,7 @@ pub struct SelectionTool { impl SelectionTool { pub fn new(direct: bool) -> Self { - Self { direct, drag: Drag::None, hover_handle: None, guides: vec![], shift_release: None, loaded: None, live: None } + Self { direct, drag: Drag::None, hover_handle: None, guides: vec![], shift_release: None, key_release: None, loaded: None, live: None } } /// [`bracket_at`] for this tool. With Direct Selection an anchor under the pointer wins: the @@ -566,8 +570,7 @@ fn finish_snapped_resize( proposed: Rect, mods: Mods, driver_x: bool, - x_edges: [bool; 3], - y_edges: [bool; 3], + (x_edges, y_edges): ([bool; 3], [bool; 3]), hit: &crate::Snap, ) -> Rect { if !mods.alt { @@ -735,6 +738,7 @@ impl Tool for SelectionTool { } PointerKind::Down => { self.shift_release = None; + self.key_release = None; // Type on a path: a bracket moves where the text starts or ends. if !ev.mods.shift && let Some((id, end)) = self.bracket_at(cx, p) @@ -825,6 +829,10 @@ impl Tool for SelectionTool { } } else if !cx.selection.contains(id) || (self.direct && !cx.selection.content) { out.push(Action::Exec("selection.set".into(), json!({"ids": [id.0], "content": self.direct}))); + } else if !self.direct && cx.selection.items.len() > 1 { + // InDesign: a click on one of several selected objects makes it the + // key object, on release so that a drag still moves them all. + self.key_release = Some(id.0); } let content = self.direct || on_selected_content(cx, p); self.drag = Drag::Pending { start: p, hit: true, content }; @@ -852,6 +860,7 @@ impl Tool for SelectionTool { return vec![]; } self.shift_release = None; + self.key_release = None; if hit && !cx.selection.items.is_empty() { // Direct Selection on items without content moves (or duplicates) them whole. let content = content && cx.selection.items.iter().any(|i| cx.doc.item(*i).is_some_and(holds_content)); @@ -1011,7 +1020,7 @@ impl Tool for SelectionTool { pointer, }, ); - to = finish_snapped_resize(from, handle, proposed, ev.mods, driver_x, x_edges, y_edges, &hit); + to = finish_snapped_resize(from, handle, proposed, ev.mods, driver_x, (x_edges, y_edges), &hit); crate::snap::lay_dimension_guides(&mut hit.guides, cx.layout.xf(spread), proposed, to, x_edges, y_edges, hit.length_delta); self.guides = hit.guides; } @@ -1124,6 +1133,7 @@ impl Tool for SelectionTool { PointerKind::Up => { self.guides.clear(); let release = self.shift_release.take(); + let key = self.key_release.take(); let d = std::mem::replace(&mut self.drag, Drag::None); match d { Drag::Move { .. } @@ -1135,9 +1145,10 @@ impl Tool for SelectionTool { | Drag::Bracket { .. } => { vec![Action::Commit] } - Drag::Pending { .. } => match release { - Some(id) => vec![Action::Exec("selection.toggle".into(), json!({"id": id}))], - None => vec![], + Drag::Pending { .. } => match (release, key) { + (Some(id), _) => vec![Action::Exec("selection.toggle".into(), json!({"id": id}))], + (None, Some(id)) => vec![Action::Exec("selection.key".into(), json!({"id": id}))], + (None, None) => vec![], }, Drag::Marquee { start, cur } => { let r = Rect::from_points(start, cur); diff --git a/crates/tools/src/snap.rs b/crates/tools/src/snap.rs index 11da46178..974081dd5 100644 --- a/crates/tools/src/snap.rs +++ b/crates/tools/src/snap.rs @@ -204,7 +204,7 @@ fn match_rotation(cx: &ToolContext, req: &SnapRequest<'_>, zone_length: f64) -> let mut seen = HashSet::new(); if let Some(spread) = cx.doc.spread(req.spread) { for item in &spread.items { - push_rotation(&mut best, cx.selection, req, item, Affine::IDENTITY, false, 0, &mut seen, proposed, tol); + push_rotation(&mut best, cx.selection, req, item, Walk::ROOT, &mut seen, (proposed, tol)); } } if shows_parent_items(cx, req) { @@ -223,7 +223,24 @@ fn push_parent_rotations( tol: f64, ) { for (item, _) in shown_parent_items(cx.doc, req.spread) { - push_rotation(best, cx.selection, req, item, Affine::IDENTITY, false, 0, seen_items, proposed, tol); + push_rotation(best, cx.selection, req, item, Walk::ROOT, seen_items, (proposed, tol)); + } +} + +/// Where a recursive walk over an item tree stands: the parent's absolute transform, whether an +/// ancestor was skipped, and how deep it is. +#[derive(Clone, Copy)] +struct Walk { + ancestor: Affine, + skipped: bool, + depth: usize, +} + +impl Walk { + const ROOT: Walk = Walk { ancestor: Affine::IDENTITY, skipped: false, depth: 0 }; + + fn child(self, ancestor: Affine, skipped: bool) -> Walk { + Walk { ancestor, skipped, depth: self.depth + 1 } } } @@ -232,13 +249,11 @@ fn push_rotation( selection: &Selection, req: &SnapRequest<'_>, item: &Item, - ancestor: Affine, - skipped: bool, - depth: usize, + walk: Walk, seen: &mut HashSet, - proposed: f64, - tol: f64, + (proposed, tol): (f64, f64), ) { + let Walk { ancestor, skipped, depth } = walk; if depth >= ALIGN_GROUP_DEPTH || !seen.insert(item.id) || item.hidden { return; } @@ -254,7 +269,7 @@ fn push_rotation( return; } for child in item.children() { - push_rotation(best, selection, req, child, abs, skip, depth + 1, seen, proposed, tol); + push_rotation(best, selection, req, child, walk.child(abs, skip), seen, (proposed, tol)); } } @@ -393,10 +408,11 @@ fn grid_origin(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis) -> Option= 0 while the line is inside the page -/// (the bottom edge is not a line, matching the canvas). An increment of 0 or less skips the -/// pass. `relative_to` and `view_threshold` are not read, and hiding the baseline grid does not -/// turn this off. Only a page the rect crosses on x contributes. A dragged ruler guide +/// Lines are `page.y0 + origin + n * increment` for n >= 0 while the line is inside the page +/// (the bottom edge is not a line, matching the canvas), `origin` from +/// [`BaselineGrid::origin`](designcraft_doc::BaselineGrid::origin). An increment of 0 or less +/// skips the pass. `view_threshold` is not read, and hiding the baseline grid does not turn this +/// off. Only a page the rect crosses on x contributes. A dragged ruler guide /// ([`Gesture::Guide`]) doesn't snap to it. fn pass_baseline(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) -> Option { if !matches!(axis, Axis::Y) || !cx.snap.snap_to_guides || matches!(req.gesture, Gesture::Guide) { @@ -417,7 +433,10 @@ fn pass_baseline(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) if !ranges_overlap(moving_lo, moving_hi, lo, hi) { continue; } - let origin = bounds.y0 + baseline.start; + let origin = bounds.y0 + baseline.origin(page); + if !origin.is_finite() { + continue; + } offer_enabled(&mut best, req.rect, flags, axis, tol, |v| nearest_baseline(v, origin, increment, bounds.y0, bounds.y1)); } best.map(|b| b.hit) @@ -907,7 +926,7 @@ fn dimension_lengths(cx: &ToolContext, req: &SnapRequest<'_>) -> Vec { let mut seen = HashSet::new(); if let Some(sp) = cx.doc.spread(req.spread) { for item in &sp.items { - push_own_lengths(&mut out, cx.selection, req, item, Affine::IDENTITY, false, 0, &mut seen); + push_own_lengths(&mut out, cx.selection, req, item, Walk::ROOT, &mut seen); } } if shows_parent_items(cx, req) { @@ -916,16 +935,8 @@ fn dimension_lengths(cx: &ToolContext, req: &SnapRequest<'_>) -> Vec { out } -fn push_own_lengths( - out: &mut Vec, - selection: &Selection, - req: &SnapRequest<'_>, - item: &Item, - ancestor: Affine, - skipped: bool, - depth: usize, - seen: &mut HashSet, -) { +fn push_own_lengths(out: &mut Vec, selection: &Selection, req: &SnapRequest<'_>, item: &Item, walk: Walk, seen: &mut HashSet) { + let Walk { ancestor, skipped, depth } = walk; if depth >= ALIGN_GROUP_DEPTH || !seen.insert(item.id) || item.hidden { return; } @@ -936,7 +947,7 @@ fn push_own_lengths( let skip = skipped || align_skips_item(selection, req, item.id); if item.is_group() && !item.children().is_empty() { for child in item.children() { - push_own_lengths(out, selection, req, child, abs, skip, depth + 1, seen); + push_own_lengths(out, selection, req, child, walk.child(abs, skip), seen); } return; } @@ -994,7 +1005,7 @@ fn stroke_outset(item: &Item) -> f64 { fn push_parent_lengths(out: &mut Vec, cx: &ToolContext, req: &SnapRequest<'_>, seen_items: &mut HashSet) { for (item, _) in shown_parent_items(cx.doc, req.spread) { - push_own_lengths(out, cx.selection, req, item, Affine::IDENTITY, false, 0, seen_items); + push_own_lengths(out, cx.selection, req, item, Walk::ROOT, seen_items); } } @@ -1084,11 +1095,11 @@ fn pass_align(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) -> let mut best: Option = None; if let Some(sp) = cx.doc.spread(req.spread) { for page in &sp.pages { - offer_box(&mut best, req.rect, flags, axis, page.bounds(), tol, cx.snap.align_edges, cx.snap.align_centers); + offer_box(&mut best, req.rect, flags, axis, page.bounds(), tol, (cx.snap.align_edges, cx.snap.align_centers)); } for item in &sp.items { let Some(rect) = item_target_rect(cx.selection, req, item) else { continue }; - offer_box(&mut best, req.rect, flags, axis, rect, tol, cx.snap.align_edges, cx.snap.align_centers); + offer_box(&mut best, req.rect, flags, axis, rect, tol, (cx.snap.align_edges, cx.snap.align_centers)); } } if shows_parent_items(cx, req) { @@ -1186,7 +1197,7 @@ fn group_box(item: &Item, seen: &mut HashSet, depth: usize, filter: Opti fn offer_parent_items(best: &mut Option, cx: &ToolContext, req: &SnapRequest<'_>, flags: [bool; 3], axis: Axis, tol: f64) { for (item, dx) in shown_parent_items(cx.doc, req.spread) { let Some(rect) = item_target_rect(cx.selection, req, item) else { continue }; - offer_box(best, req.rect, flags, axis, shift_x(rect, dx), tol, cx.snap.align_edges, cx.snap.align_centers); + offer_box(best, req.rect, flags, axis, shift_x(rect, dx), tol, (cx.snap.align_edges, cx.snap.align_centers)); } } @@ -1280,7 +1291,7 @@ fn offer_line(best: &mut Option, moving: Rect, flags: [bool; 3], axis: Axi } } -fn offer_box(best: &mut Option, moving: Rect, flags: [bool; 3], axis: Axis, target: Rect, tol: f64, edges: bool, centers: bool) { +fn offer_box(best: &mut Option, moving: Rect, flags: [bool; 3], axis: Axis, target: Rect, tol: f64, (edges, centers): (bool, bool)) { let (m0, m_mid, m1) = axis_triple(moving, axis); let (t0, t_mid, t1) = axis_triple(target, axis); let [use_left, use_mid, use_right] = flags; @@ -1790,6 +1801,22 @@ mod tests { assert_eq!(hit.delta.y, 0.0); } + #[test] + fn baseline_relative_to_the_top_margin_snaps_from_the_margin() { + let mut doc = Document::new(&NewDocument::default()); + std::sync::Arc::make_mut(&mut doc.spreads[0]).pages[0].margins.top = 37.0; + doc.settings.baseline_grid.start = 0.0; + doc.settings.baseline_grid.increment = 10.0; + doc.settings.baseline_grid.relative_to = designcraft_doc::GridRelative::TopMargin; + let cache = Cache::new(); + let layout = CanvasLayout::new(&doc, false); + let sel = Selection::default(); + let cx = ctx_on(&doc, &sel, &cache, &layout); + // Lines at 37 + 10n: a top at 58 goes up to 57, not down to the page-relative 60. + let hit = snap(&cx, y_req(Rect::new(100.0, 58.0, 140.0, 80.0))); + assert!((hit.delta.y - -1.0).abs() < 1e-6, "{}", hit.delta.y); + } + #[test] fn nearer_of_margin_and_baseline_wins() { // Bottom margin at 792 - 54 = 738, between baseline lines 732 and 744. diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index 56658f41e..f0503e912 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -212,6 +212,123 @@ fn underlines(cx: &ToolContext, ts: TextSel, r: Range) -> Vec<(Point, Poi out } +impl TypeTool { + fn pointer_down(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + self.start = Some(ev.pos); + self.drawing = false; + self.selecting = None; + self.cell_drag = None; + self.unit = None; + self.border = None; + if ev.kind == PointerKind::Down + && let Some(b) = border_at(cx, ev.pos) + { + self.border = Some(b); + return vec![]; + } + // Text frames take the caret (locked ones too, as in InDesign); an empty frame + // becomes one, unless it's locked: then the press draws a new text frame over it. + if let Some((_, id)) = cx.hit(ev.pos) + && let Some(it) = cx.doc.item(id) + && !matches!(it.content, designcraft_doc::Content::Graphic(_) | designcraft_doc::Content::Group { .. }) + && (it.is_text_frame() || !it.locked) + { + let sp = cx.layout.spread_at(ev.pos).map(|(_, p)| p).unwrap_or(ev.pos); + let clicks = ev.click_count(); + // Whole rows/columns selected: wait to see whether this is a drag. + if clicks == 1 && !ev.mods.shift && whole_rows_or_cols(cx) { + self.cell_drag = Some((id.0, sp)); + return vec![]; + } + self.selecting = Some(id.0); + // Two presses select a word (or a run of spaces), three a line, four a + // paragraph, five the story. + if clicks >= 2 { + let unit = match clicks { + 2 => "word", + 3 => "line", + 4 => "paragraph", + _ => "story", + }; + self.unit = Some(unit); + return vec![Action::Exec("text.selectAt".into(), json!({"frame": id.0, "point": [sp.x, sp.y], "unit": unit}))]; + } + let cmd = if ev.mods.shift { "text.extendTo" } else { "text.placeCaret" }; + return vec![Action::Exec(cmd.into(), json!({"frame": id.0, "point": [sp.x, sp.y]}))]; + } + vec![] + } + + fn pointer_drag(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + let Some(a) = self.start else { return vec![] }; + if let Some(b) = self.border.as_mut() { + // A click with a little wobble must not resize. + if !b.begun && (ev.pos - a).hypot() < cx.tol(3.0) { + return vec![]; + } + return b.drag(ev.pos, ev.mods.shift); + } + if self.cell_drag.is_some() { + return vec![]; + } + if let Some(fid) = self.selecting { + let sp = cx.layout.spread_at(ev.pos).map(|(_, p)| p).unwrap_or(ev.pos); + let mut p = json!({"frame": fid, "point": [sp.x, sp.y]}); + // After several clicks the drag grows the selection by whole units. + if let Some(unit) = self.unit { + p["unit"] = json!(unit); + } + return vec![Action::Exec("text.extendTo".into(), p)]; + } + if (ev.pos - a).hypot() < cx.tol(3.0) && !self.drawing { + return vec![]; + } + let Some((sr, sa)) = cx.layout.spread_at(a) else { return vec![] }; + // Drawn like the frame tools: the press point and the moving edges snap. + let sa = if cx.snap.any() { crate::snap::snap_point(cx, sr, sa).0 } else { sa }; + let (r, guides) = snapped_drag_rect(cx, sr, sa, cx.layout.to_spread(sr, ev.pos), ev.mods); + self.guides = guides; + let mut out = vec![]; + if !self.drawing { + self.drawing = true; + out.push(Action::Begin("Create Text Frame".into())); + } + out.push(Action::Preview( + "frame.create".into(), + json!({"spread": spread_json(sr), "shape": "rectangle", "content": "text", "rect": rect_json(r), "caret": true, "vertical": self.vertical}), + )); + out + } + + fn pointer_up(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + self.guides.clear(); + let start = self.start.take(); + if let Some(b) = self.border.take() { + return if b.begun { vec![Action::Commit] } else { vec![] }; + } + if let Some((fid, from)) = self.cell_drag.take() { + let sp = cx.layout.spread_at(ev.pos).map(|(_, p)| p).unwrap_or(ev.pos); + let moved = start.is_some_and(|a| (ev.pos - a).hypot() >= cx.tol(3.0)); + return if moved { + vec![Action::Exec("table.dropCells".into(), json!({"frame": fid, "from": [from.x, from.y], "to": [sp.x, sp.y]}))] + } else { + vec![Action::Exec("text.placeCaret".into(), json!({"frame": fid, "point": [from.x, from.y]}))] + }; + } + if std::mem::take(&mut self.drawing) { + self.selecting = None; + return vec![Action::Commit]; + } + // Ends a press in text: drops dragged text, or is ignored by the engine. + if let Some(fid) = self.selecting.take() { + let sp = cx.layout.spread_at(ev.pos).map(|(_, p)| p).unwrap_or(ev.pos); + let moved = start.is_some_and(|a| (ev.pos - a).hypot() >= cx.tol(3.0)); + return vec![Action::Exec("text.release".into(), json!({"frame": fid, "point": [sp.x, sp.y], "moved": moved, "copy": ev.mods.alt}))]; + } + vec![] + } +} + impl Tool for TypeTool { fn id(&self) -> &'static str { if self.vertical { "verticalType" } else { "type" } @@ -219,121 +336,9 @@ impl Tool for TypeTool { fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { match ev.kind { - PointerKind::Down | PointerKind::DoubleClick => { - self.start = Some(ev.pos); - self.drawing = false; - self.selecting = None; - self.cell_drag = None; - self.unit = None; - self.border = None; - if ev.kind == PointerKind::Down - && let Some(b) = border_at(cx, ev.pos) - { - self.border = Some(b); - return vec![]; - } - // Text frames take the caret (locked ones too, as in InDesign); an empty frame - // becomes one, unless it's locked: then the press draws a new text frame over it. - if let Some((_, id)) = cx.hit(ev.pos) - && let Some(it) = cx.doc.item(id) - && !matches!(it.content, designcraft_doc::Content::Graphic(_) | designcraft_doc::Content::Group { .. }) - && (it.is_text_frame() || !it.locked) - { - let sp = cx.layout.spread_at(ev.pos).map(|(_, p)| p).unwrap_or(ev.pos); - let clicks = ev.click_count(); - // Whole rows/columns selected: wait to see whether this is a drag. - if clicks == 1 && !ev.mods.shift && whole_rows_or_cols(cx) { - self.cell_drag = Some((id.0, sp)); - return vec![]; - } - self.selecting = Some(id.0); - // Two presses select a word (or a run of spaces), three a line, four a - // paragraph, five the story. - if clicks >= 2 { - let unit = match clicks { - 2 => "word", - 3 => "line", - 4 => "paragraph", - _ => "story", - }; - self.unit = Some(unit); - return vec![Action::Exec("text.selectAt".into(), json!({"frame": id.0, "point": [sp.x, sp.y], "unit": unit}))]; - } - let cmd = if ev.mods.shift { "text.extendTo" } else { "text.placeCaret" }; - return vec![Action::Exec(cmd.into(), json!({"frame": id.0, "point": [sp.x, sp.y]}))]; - } - vec![] - } - PointerKind::Drag => { - let Some(a) = self.start else { return vec![] }; - if let Some(b) = self.border.as_mut() { - // A click with a little wobble must not resize. - if !b.begun && (ev.pos - a).hypot() < cx.tol(3.0) { - return vec![]; - } - return b.drag(ev.pos, ev.mods.shift); - } - if self.cell_drag.is_some() { - return vec![]; - } - if let Some(fid) = self.selecting { - let sp = cx.layout.spread_at(ev.pos).map(|(_, p)| p).unwrap_or(ev.pos); - let mut p = json!({"frame": fid, "point": [sp.x, sp.y]}); - // After several clicks the drag grows the selection by whole units. - if let Some(unit) = self.unit { - p["unit"] = json!(unit); - } - return vec![Action::Exec("text.extendTo".into(), p)]; - } - if (ev.pos - a).hypot() < cx.tol(3.0) && !self.drawing { - return vec![]; - } - let Some((sr, sa)) = cx.layout.spread_at(a) else { return vec![] }; - // Drawn like the frame tools: the press point and the moving edges snap. - let sa = if cx.snap.any() { crate::snap::snap_point(cx, sr, sa).0 } else { sa }; - let (r, guides) = snapped_drag_rect(cx, sr, sa, cx.layout.to_spread(sr, ev.pos), ev.mods); - self.guides = guides; - let mut out = vec![]; - if !self.drawing { - self.drawing = true; - out.push(Action::Begin("Create Text Frame".into())); - } - out.push(Action::Preview( - "frame.create".into(), - json!({"spread": spread_json(sr), "shape": "rectangle", "content": "text", "rect": rect_json(r), "caret": true, "vertical": self.vertical}), - )); - out - } - PointerKind::Up => { - self.guides.clear(); - let start = self.start.take(); - if let Some(b) = self.border.take() { - return if b.begun { vec![Action::Commit] } else { vec![] }; - } - if let Some((fid, from)) = self.cell_drag.take() { - let sp = cx.layout.spread_at(ev.pos).map(|(_, p)| p).unwrap_or(ev.pos); - let moved = start.is_some_and(|a| (ev.pos - a).hypot() >= cx.tol(3.0)); - return if moved { - vec![Action::Exec("table.dropCells".into(), json!({"frame": fid, "from": [from.x, from.y], "to": [sp.x, sp.y]}))] - } else { - vec![Action::Exec("text.placeCaret".into(), json!({"frame": fid, "point": [from.x, from.y]}))] - }; - } - if std::mem::take(&mut self.drawing) { - self.selecting = None; - return vec![Action::Commit]; - } - // Ends a press in text: drops dragged text, or is ignored by the engine. - if let Some(fid) = self.selecting.take() { - let sp = cx.layout.spread_at(ev.pos).map(|(_, p)| p).unwrap_or(ev.pos); - let moved = start.is_some_and(|a| (ev.pos - a).hypot() >= cx.tol(3.0)); - return vec![Action::Exec( - "text.release".into(), - json!({"frame": fid, "point": [sp.x, sp.y], "moved": moved, "copy": ev.mods.alt}), - )]; - } - vec![] - } + PointerKind::Down | PointerKind::DoubleClick => self.pointer_down(cx, ev), + PointerKind::Drag => self.pointer_drag(cx, ev), + PointerKind::Up => self.pointer_up(cx, ev), PointerKind::Move => vec![], } } diff --git a/crates/ui-egui/src/about.rs b/crates/ui-egui/src/about.rs index 14745607a..75442b93c 100644 --- a/crates/ui-egui/src/about.rs +++ b/crates/ui-egui/src/about.rs @@ -77,16 +77,24 @@ pub fn paint_chat_icon(p: &egui::Painter, c: egui::Pos2, s: f32, color: Color32) p.circle_filled(body.center() + vec2(s * 0.18, 0.0), eye, bg); } -/// The big "Join our Discord" button. Returns true when clicked. +/// The Discord button's size for `label`: `min`, widened when the label would not fit inside it. +pub fn discord_button_size(ctx: &egui::Context, label: &str, min: egui::Vec2) -> egui::Vec2 { + let galley = crate::rtl::plain(ctx, label, semibold((min.y * 0.42).max(11.0)), Color32::WHITE); + egui::vec2(min.x.max(min.y * 0.5 + 8.0 + galley.size().x + min.y), min.y) +} + +/// The big "Join our Discord" button. Returns true when clicked. `size` is the minimum: a longer label +/// widens the button (see `discord_button_size`) rather than spilling out of it. pub fn discord_button(ui: &mut egui::Ui, label: &str, size: egui::Vec2) -> bool { - let (r, resp) = ui.allocate_exact_size(size, Sense::click()); - let resp = resp.on_hover_text(links::DISCORD).on_hover_cursor(egui::CursorIcon::PointingHand); - let fill = if resp.hovered() { Color32::from_rgb(0x47, 0x52, 0xc4) } else { DISCORD }; - ui.painter().rect_filled(r, size.y / 2.0, fill); + let size = discord_button_size(ui.ctx(), label, size); let icon = size.y * 0.5; let font = semibold((size.y * 0.42).max(11.0)); let galley = crate::rtl::plain(ui.ctx(), label, font, Color32::WHITE); let total = icon + 8.0 + galley.size().x; + let (r, resp) = ui.allocate_exact_size(size, Sense::click()); + let resp = resp.on_hover_text(links::DISCORD).on_hover_cursor(egui::CursorIcon::PointingHand); + let fill = if resp.hovered() { Color32::from_rgb(0x47, 0x52, 0xc4) } else { DISCORD }; + ui.painter().rect_filled(r, size.y / 2.0, fill); let x0 = r.center().x - total / 2.0; paint_chat_icon(ui.painter(), pos2(x0 + icon / 2.0, r.center().y), icon, Color32::WHITE); ui.painter().galley(pos2(x0 + icon + 8.0, r.center().y - galley.size().y / 2.0), galley, Color32::WHITE); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 318f82388..b1c71c056 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -962,7 +962,8 @@ fn dashed(painter: &egui::Painter, a: Pos2, b: Pos2, stroke: Stroke, dash: f32, const MAX_BASELINES: usize = 216 * 72; /// Baseline grid lines inside a page, measured from its top: `start + n * increment` for n >= 0 (the bottom -/// edge is not a line). Lines above the page are skipped without walking them, however far up the start is. +/// edge is not a line), `start` from [`BaselineGrid::origin`](designcraft_doc::BaselineGrid::origin). Lines +/// above the page are skipped without walking them, however far up the start is. fn baseline_grid_lines(start: f64, increment: f64, height: f64) -> impl Iterator { let step = increment.max(1.0); let first = if start < 0.0 { start.rem_euclid(step) } else { start }; @@ -1022,7 +1023,7 @@ fn draw_guides(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document // Baseline grid. if app.ui.baseline_grid && xf.zoom >= s.baseline_grid.view_threshold { let g = &s.baseline_grid; - for y in baseline_grid_lines(g.start, g.increment, p.height) { + for y in baseline_grid_lines(g.origin(p), g.increment, p.height) { painter.line_segment( [pt(Point::new(pr.x0, pr.y0 + y)), pt(Point::new(pr.x1, pr.y0 + y))], Stroke::new(hair(painter), c32(g.color).gamma_multiply(0.8)), @@ -1251,6 +1252,9 @@ fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Docum if sel.items.len() == 1 && sel.text.is_none() { // InDesign draws a 1 pt bounding box around the selection. painter.rect_stroke(xf.rect(a.transform_rect_bbox(it.bounds())), 0.0, Stroke::new(1.0, color), StrokeKind::Middle); + } else if sel.key == Some(*id) { + // The key object for Align: a thicker bounding box, as in InDesign. + painter.rect_stroke(xf.rect(a.transform_rect_bbox(it.bounds())), 0.0, Stroke::new(3.0, color), StrokeKind::Middle); } let b = xf.rect(a.transform_rect_bbox(it.bounds())); union = Some(union.map_or(b, |u| u.union(b))); @@ -5317,7 +5321,7 @@ mod tests { let (pen, direct, text) = (shown("pen"), shown("directSelection"), shown("type")); let ppp = h.ctx.pixels_per_point(); let images = &mut h.state_mut().cursors; - match crate::cursors::MODE { + match crate::cursors::mode() { crate::cursors::Mode::Os => { assert_eq!(pen.1, images.get(Cursor::Pen, ppp), "Pen"); assert_eq!(direct.1, images.get(Cursor::ArrowHollow, ppp), "Direct Selection"); diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index e853e6793..303c08703 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -7,7 +7,7 @@ use serde_json::json; use crate::panels; use crate::theme::{Tokens, semibold}; -use crate::widgets::{caption, measure, number}; +use crate::widgets::{NumField, caption, measure}; use crate::{DesignApp, icons}; /// Height of the application bar (InDesign 2026), then a 7 pt lower band and a 1 pt border. The @@ -477,6 +477,32 @@ pub fn control_bar(app: &mut DesignApp, ui: &mut egui::Ui) { }); } +/// Page-size preset menu and the portrait / landscape toggles of the Control panel. +fn page_preset_and_orientation(app: &mut DesignApp, ui: &mut egui::Ui, lang: &str, size: (f64, f64), n: usize, anchor: u8) { + let landscape = size.0 > size.1; + ui.vertical(|ui| { + let cur = panels::properties::preset_name(size.0, size.1); + egui::ComboBox::from_id_salt("ctl_page_preset").selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, cur))).width(130.0).show_ui( + ui, + |ui| { + for (name, w, h) in panels::properties::PAGE_PRESETS { + if ui.selectable_label(cur == *name, crate::rtl::widget(ui, crate::i18n::tr(lang, name))).clicked() { + let (w, h) = if landscape { (h, w) } else { (w, h) }; + let _ = app.run("layout.pageSize", json!({"pages": [n], "width": w, "height": h, "anchor": anchor})); + } + } + }, + ); + ui.horizontal(|ui| { + for (icon, tip, want) in [("orient-portrait", "Portrait", false), ("orient-landscape", "Landscape", true)] { + if crate::widgets::icon_toggle(ui, icon, landscape == want, crate::i18n::tr(lang, tip)).clicked() && landscape != want { + let _ = app.run("layout.pageSize", json!({"pages": [n], "width": size.1, "height": size.0, "anchor": anchor})); + } + } + }); + }); +} + /// The Control panel with the Page tool, as InDesign's: reference point, X/Y, W/H, page-size /// preset, orientation, Liquid Page Rule and Show Parent Page Overlay, for /// the page the Page tool selected (else the page in view). W/H keep the reference point in place. @@ -518,28 +544,7 @@ fn control_page(app: &mut DesignApp, ui: &mut egui::Ui) { }); vsep(ui); // Page-size preset and orientation. - let landscape = pg.width > pg.height; - ui.vertical(|ui| { - let cur = panels::properties::preset_name(pg.width, pg.height); - egui::ComboBox::from_id_salt("ctl_page_preset").selected_text(crate::rtl::widget(ui, crate::i18n::tr(&lang, cur))).width(130.0).show_ui( - ui, - |ui| { - for (name, w, h) in panels::properties::PAGE_PRESETS { - if ui.selectable_label(cur == *name, crate::rtl::widget(ui, crate::i18n::tr(&lang, name))).clicked() { - let (w, h) = if landscape { (h, w) } else { (w, h) }; - let _ = app.run("layout.pageSize", json!({"pages": [n], "width": w, "height": h, "anchor": anchor})); - } - } - }, - ); - ui.horizontal(|ui| { - for (icon, tip, want) in [("orient-portrait", "Portrait", false), ("orient-landscape", "Landscape", true)] { - if crate::widgets::icon_toggle(ui, icon, landscape == want, crate::i18n::tr(&lang, tip)).clicked() && landscape != want { - let _ = app.run("layout.pageSize", json!({"pages": [n], "width": pg.height, "height": pg.width, "anchor": anchor})); - } - } - }); - }); + page_preset_and_orientation(app, ui, &lang, (pg.width, pg.height), n, anchor); vsep(ui); ui.vertical(|ui| { caption(ui, crate::i18n::tr(&lang, "Liquid Page Rule:")); @@ -564,20 +569,101 @@ fn control_page(app: &mut DesignApp, ui: &mut egui::Ui) { }); } +/// The Control panel's font sizes (and leadings), as InDesign lists them. +const SIZES: &[f64] = &[6.0, 8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 18.0, 24.0, 30.0, 36.0, 48.0, 60.0, 72.0]; +/// The Control panel's tracking values (1/1000 em). +const TRACKING: &[f64] = &[-100.0, -75.0, -50.0, -25.0, -10.0, -5.0, 0.0, 5.0, 10.0, 25.0, 50.0, 75.0, 100.0, 200.0]; + +/// Whether the text Control panel shows Paragraph Formatting Controls (¶) rather than Character +/// Formatting Controls (A). +const PARA_MODE: &str = "ctl_text_para_mode"; + +/// An icon and a field after it, as the Control panel lays its fields out. +fn icon_field(ui: &mut egui::Ui, icon: &str, tip: &str, field: NumField) -> Option { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover()); + icons::paint(ui.painter(), r, icon, t.icon); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, tip)); + let label = resp.id; + resp.on_hover_text(tip); + field.spinner().labelled_by(label).show(ui) +} + +/// A column of two rows (the Control panel's two-row groups). +fn rows(ui: &mut egui::Ui, mut row: impl FnMut(&mut egui::Ui, usize)) { + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 3.0; + ui.horizontal(|ui| row(ui, 0)); + ui.horizontal(|ui| row(ui, 1)); + }); +} + +/// A small toggle button showing `text` (the case and position buttons). +fn text_toggle(ui: &mut egui::Ui, text: &str, strike: bool, on: bool, tip: &str) -> bool { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(24.0, 20.0), Sense::click()); + if on { + ui.painter().rect_filled(r, 2.0, t.tool_active); + } else if resp.hovered() { + ui.painter().rect_filled(r, 2.0, t.hover); + } + let col = if on { t.text_strong } else { t.icon }; + ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, text, egui::FontId::proportional(13.0), col); + if strike { + ui.painter().line_segment([r.center() + vec2(-6.0, 0.5), r.center() + vec2(6.0, 0.5)], Stroke::new(1.2, col)); + } + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, tip)); + resp.on_hover_text(tip).clicked() +} + +/// The text Control panel, as InDesign's: A shows the Character Formatting Controls (font, size +/// and leading, the case and position buttons, kerning and tracking, the scales, baseline shift +/// and skew, the character style and language); ¶ the Paragraph Formatting Controls (alignment, +/// the indents, space before and after, the drop cap, the paragraph style, Hyphenate and Align to +/// Baseline Grid). fn control_text(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); let attrs = panels::text_attrs(app); let chars = attrs.as_ref().map(|a| a["chars"].clone()).unwrap_or_default(); let para = attrs.as_ref().map(|a| a["para"].clone()).unwrap_or_default(); + let mode_id = egui::Id::new(PARA_MODE); + let para_mode = ui.data(|d| d.get_temp::(mode_id)).unwrap_or(false); ui.vertical(|ui| { - ui.horizontal(|ui| { - ui.label(egui::RichText::new("A").font(semibold(15.0)).color(t.text_strong)); - }); - ui.horizontal(|ui| { - ui.label(egui::RichText::new("¶").font(semibold(15.0)).color(t.text_dim)); - }); + ui.spacing_mut().item_spacing.y = 3.0; + for (para, text, tip) in [(false, "A", "Character Formatting Controls"), (true, "¶", "Paragraph Formatting Controls")] { + let on = para_mode == para; + let (r, resp) = ui.allocate_exact_size(vec2(24.0, 22.0), Sense::click()); + if on { + ui.painter().rect_filled(r, 2.0, t.tool_active); + } else if resp.hovered() { + ui.painter().rect_filled(r, 2.0, t.hover); + } + ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, text, semibold(15.0), if on { t.text_strong } else { t.text_dim }); + let tip = tr(tip); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, &tip)); + if resp.on_hover_text(&tip).clicked() { + ui.data_mut(|d| d.insert_temp(mode_id, para)); + } + } }); vsep(ui); + if para_mode { + control_paragraph(app, ui, &attrs, ¶, &tr); + } else { + control_character(app, ui, &attrs, &chars, ¶, &tr); + } +} + +fn control_character( + app: &mut DesignApp, + ui: &mut egui::Ui, + attrs: &Option, + chars: &serde_json::Value, + para: &serde_json::Value, + tr: &dyn Fn(&str) -> String, +) { ui.vertical(|ui| { let fam = chars["fontFamily"].as_str().unwrap_or("").to_string(); let sty = chars["fontStyle"].as_str().unwrap_or("").to_string(); @@ -585,84 +671,358 @@ fn control_text(app: &mut DesignApp, ui: &mut egui::Ui) { panels::font_style_picker(app, ui, &fam, &sty, 170.0); }); vsep(ui); - egui::Grid::new("ctl_char").num_columns(4).spacing(vec2(4.0, 3.0)).show(ui, |ui| { - caption(ui, "𝐓"); - if let Some(v) = number(ui, "csize", chars["size"].as_f64(), " pt", 54.0, 2) { - let _ = app.run("type.char", json!({"attrs": {"size": v}})); + // Size and leading: a spinner on the left and a list of common values on the right; + // automatic leading in parentheses. + rows(ui, |ui, row| { + if row == 0 { + if let Some(v) = icon_field( + ui, + "font-size", + &tr("Font Size"), + NumField::number("csize", chars["size"].as_f64(), " pt", 2).width(84.0).presets(SIZES).range(0.1, 1296.0), + ) { + let _ = app.run("type.char", json!({"attrs": {"size": v}})); + } + } else { + let auto = chars["leading"]["kind"].as_str() != Some("points"); + let lead = if auto { + chars["size"].as_f64().map(|s| s * para["autoLeading"].as_f64().unwrap_or(1.2)) + } else { + chars["leading"]["value"].as_f64() + }; + let lead = lead.map(|v| (v * 100.0).round() / 100.0); + let key = if auto { "cleadauto" } else { "clead" }; + if let Some(v) = icon_field( + ui, + "leading", + &tr("Leading"), + NumField::number(key, lead, " pt", 2).width(84.0).presets(SIZES).range(0.0, 5000.0).parens(auto), + ) { + let _ = app.run("type.char", json!({"attrs": {"leading": {"kind": "points", "value": v}}})); + } } - caption(ui, crate::i18n::tr(&app.ui.language, "VA")); - if let Some(v) = number(ui, "ctrack", chars["tracking"].as_f64(), "", 48.0, 0) { - let _ = app.run("type.char", json!({"attrs": {"tracking": v}})); + }); + vsep(ui); + // All Caps, Superscript, Underline / Small Caps, Subscript, Strikethrough. + let caps = chars["capitalization"].as_str().unwrap_or("normal").to_string(); + let pos = chars["position"].as_str().unwrap_or("normal").to_string(); + let (ul, st) = (chars["underline"].as_bool().unwrap_or(false), chars["strikethrough"].as_bool().unwrap_or(false)); + let mut set = None; + rows(ui, |ui, row| { + if row == 0 { + if text_toggle(ui, "TT", false, caps == "allCaps", &tr("All Caps")) { + set = Some(json!({"capitalization": if caps == "allCaps" { "normal" } else { "allCaps" }})); + } + if text_toggle(ui, "T¹", false, pos == "superscript", &tr("Superscript")) { + set = Some(json!({"position": if pos == "superscript" { "normal" } else { "superscript" }})); + } + if text_toggle(ui, "T̲", false, ul, &tr("Underline")) { + set = Some(json!({"underline": !ul})); + } + } else { + if text_toggle(ui, "Tᴛ", false, caps == "smallCaps", &tr("Small Caps")) { + set = Some(json!({"capitalization": if caps == "smallCaps" { "normal" } else { "smallCaps" }})); + } + if text_toggle(ui, "T₁", false, pos == "subscript", &tr("Subscript")) { + set = Some(json!({"position": if pos == "subscript" { "normal" } else { "subscript" }})); + } + if text_toggle(ui, "T", true, st, &tr("Strikethrough")) { + set = Some(json!({"strikethrough": !st})); + } } - ui.end_row(); - caption(ui, "Ā"); - let lead = match chars["leading"]["kind"].as_str() { - Some("points") => chars["leading"]["value"].as_f64(), - _ => chars["size"].as_f64().map(|s| s * para["autoLeading"].as_f64().unwrap_or(1.2)), - }; - if let Some(v) = number(ui, "clead", lead, " pt", 54.0, 2) { - let _ = app.run("type.char", json!({"attrs": {"leading": {"kind": "points", "value": v}}})); + }); + if let Some(a) = set { + let _ = app.run("type.char", json!({"attrs": a})); + } + vsep(ui); + rows(ui, |ui, row| { + if row == 0 { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover()); + icons::paint(ui.painter(), r, "kerning", t.icon); + resp.on_hover_text(tr("Kerning")); + crate::panels::properties::kerning_control(app, ui, "ckern", &chars["kerning"], 92.0); + } else { + if let Some(v) = icon_field( + ui, + "tracking", + &tr("Tracking"), + NumField::number("ctrack", chars["tracking"].as_f64(), "", 0).width(92.0).presets(TRACKING).step(5.0).range(-1000.0, 10000.0), + ) { + let _ = app.run("type.char", json!({"attrs": {"tracking": v}})); + } } - caption(ui, crate::i18n::tr(&app.ui.language, "IT")); - if let Some(v) = number(ui, "chs", chars["hScale"].as_f64().map(|v| v * 100.0), "%", 48.0, 0) { - let _ = app.run("type.char", json!({"attrs": {"hScale": v / 100.0}})); + }); + vsep(ui); + const SCALES: &[f64] = &[25.0, 50.0, 75.0, 80.0, 90.0, 100.0, 110.0, 120.0, 125.0, 150.0, 200.0]; + let pct = |v: &serde_json::Value| v.as_f64().map(|s| (s * 1000.0).round() / 10.0); + rows(ui, |ui, row| { + if row == 0 { + if let Some(v) = icon_field( + ui, + "vscale", + &tr("Vertical Scale"), + NumField::number("cvs", pct(&chars["vScale"]), "%", 1).width(84.0).presets(SCALES).range(1.0, 1000.0), + ) { + let _ = app.run("type.char", json!({"attrs": {"vScale": v / 100.0}})); + } + } else { + if let Some(v) = icon_field( + ui, + "hscale", + &tr("Horizontal Scale"), + NumField::number("chs", pct(&chars["hScale"]), "%", 1).width(84.0).presets(SCALES).range(1.0, 1000.0), + ) { + let _ = app.run("type.char", json!({"attrs": {"hScale": v / 100.0}})); + } + } + }); + vsep(ui); + rows(ui, |ui, row| { + if row == 0 { + if let Some(v) = icon_field( + ui, + "baseline-shift", + &tr("Baseline Shift"), + NumField::number("cbs", chars["baselineShift"].as_f64(), " pt", 2).width(76.0).range(-5000.0, 5000.0), + ) { + let _ = app.run("type.char", json!({"attrs": {"baselineShift": v}})); + } + } else { + if let Some(v) = icon_field( + ui, + "skew", + &tr("Skew (False Italic)"), + NumField::number("csk", chars["skew"].as_f64(), "°", 1).width(76.0).range(-85.0, 85.0), + ) { + let _ = app.run("type.char", json!({"attrs": {"skew": v}})); + } } - ui.end_row(); }); vsep(ui); ui.vertical(|ui| { - let cur: Align = serde_json::from_value(para["align"].clone()).unwrap_or_default(); - ui.horizontal(|ui| { - for (a, icon) in [(Align::Left, "align-left"), (Align::Center, "align-center"), (Align::Right, "align-right")] { - if icons::button(ui, icon, 20.0, cur == a, a.label()).clicked() { - let _ = app.run("type.para", json!({"attrs": {"align": a}})); + ui.spacing_mut().item_spacing.y = 3.0; + // The character style, then the language. + let cs = attrs.as_ref().and_then(|a| a["characterStyle"].as_str()).unwrap_or(designcraft_doc::NO_CHAR_STYLE).to_string(); + let names: Vec = app.session.active().map(|d| d.doc.styles.character.iter().map(|c| c.name.clone()).collect()).unwrap_or_default(); + let mut pick = None; + egui::ComboBox::from_id_salt("ctl_char_style") + .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &cs))) + .width(150.0) + .show_ui(ui, |ui| { + for n in &names { + if ui.selectable_label(*n == cs, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, n))).clicked() { + pick = Some(n.clone()); + } } - } - }); - ui.horizontal(|ui| { - for (a, icon) in [(Align::LeftJustified, "align-justify"), (Align::FullyJustified, "align-justify-all")] { - if icons::button(ui, icon, 20.0, cur == a, a.label()).clicked() { - let _ = app.run("type.para", json!({"attrs": {"align": a}})); - } - } + }); + if let Some(n) = pick { + let _ = app.run("style.character.apply", json!({"name": n})); + } + let lang = chars["language"].as_str().unwrap_or("English: USA").to_string(); + egui::ComboBox::from_id_salt("ctl_language").selected_text(crate::rtl::widget(ui, tr(&lang))).width(150.0).show_ui(ui, |ui| { + egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| crate::panels::properties::language_menu(app, ui, &lang)); }); }); +} + +fn control_paragraph( + app: &mut DesignApp, + ui: &mut egui::Ui, + attrs: &Option, + para: &serde_json::Value, + tr: &dyn Fn(&str) -> String, +) { + let cur: Align = serde_json::from_value(para["align"].clone()).unwrap_or_default(); + let mut align = None; + rows(ui, |ui, row| { + if row == 0 { + for (a, icon) in [ + (Align::Left, "palign-left"), + (Align::Center, "palign-center"), + (Align::Right, "palign-right"), + (Align::TowardsSpine, "palign-spine-towards"), + ] { + if icons::button(ui, icon, 20.0, cur == a, &tr(a.label())).clicked() { + align = Some(a); + } + } + } else { + for (a, icon) in [ + (Align::LeftJustified, "palign-justify-left"), + (Align::CenterJustified, "palign-justify-center"), + (Align::RightJustified, "palign-justify-right"), + (Align::FullyJustified, "palign-justify-all"), + (Align::AwayFromSpine, "palign-spine-away"), + ] { + if icons::button(ui, icon, 20.0, cur == a, &tr(a.label())).clicked() { + align = Some(a); + } + } + } + }); + if let Some(a) = align { + let _ = app.run("type.para", json!({"attrs": {"align": a}})); + } vsep(ui); let units = app.session.active().map(|d| d.doc.settings.horizontal_units).unwrap_or(Unit::Picas); - egui::Grid::new("ctl_para").num_columns(4).spacing(vec2(4.0, 3.0)).show(ui, |ui| { - caption(ui, "→|"); - if let Some(v) = measure(ui, "cli", para["leftIndent"].as_f64(), units, 54.0) { - let _ = app.run("type.para", json!({"attrs": {"leftIndent": v}})); + let mut set = None; + for ((ia, ta, ka), (ib, tb, kb)) in [ + (("indent-left", "Left Indent", "leftIndent"), ("indent-first", "First Line Left Indent", "firstLineIndent")), + (("indent-right", "Right Indent", "rightIndent"), ("indent-last", "Last Line Right Indent", "lastLineIndent")), + (("space-before", "Space Before", "spaceBefore"), ("space-after", "Space After", "spaceAfter")), + ] { + rows(ui, |ui, row| { + if row == 0 { + if let Some(v) = icon_field(ui, ia, &tr(ta), NumField::measure(ka, para[ka].as_f64(), units).width(84.0).range(0.0, 10_000.0)) { + set = Some((ka, v)); + } + } else { + let min = crate::panels::properties::para_field_min(kb); + if let Some(v) = icon_field(ui, ib, &tr(tb), NumField::measure(kb, para[kb].as_f64(), units).width(84.0).range(min, 10_000.0)) { + set = Some((kb, v)); + } + } + }); + vsep(ui); + } + if let Some((k, v)) = set { + let _ = app.run("type.para", json!({"attrs": {k: v}})); + } + let mut drop = None; + rows(ui, |ui, row| { + if row == 0 { + let max = designcraft_compose::MAX_DROP_CAP_LINES as f64; + if let Some(v) = icon_field( + ui, + "drop-lines", + &tr("Drop Cap Number of Lines"), + NumField::number("cdl", para["dropCapLines"].as_f64(), "", 0).width(64.0).range(0.0, max), + ) { + drop = Some(("dropCapLines", v)); + } + } else { + let max = designcraft_compose::MAX_DROP_CAP_CHARS as f64; + if let Some(v) = icon_field( + ui, + "drop-chars", + &tr("Drop Cap One or More Characters"), + NumField::number("cdc", para["dropCapChars"].as_f64(), "", 0).width(64.0).range(0.0, max), + ) { + drop = Some(("dropCapChars", v)); + } } - caption(ui, "↑¶"); - if let Some(v) = measure(ui, "csb", para["spaceBefore"].as_f64(), units, 54.0) { - let _ = app.run("type.para", json!({"attrs": {"spaceBefore": v}})); - } - ui.end_row(); - caption(ui, "→¶"); - if let Some(v) = measure(ui, "cfi", para["firstLineIndent"].as_f64(), units, 54.0) { - let _ = app.run("type.para", json!({"attrs": {"firstLineIndent": v}})); - } - caption(ui, "↓¶"); - if let Some(v) = measure(ui, "csa", para["spaceAfter"].as_f64(), units, 54.0) { - let _ = app.run("type.para", json!({"attrs": {"spaceAfter": v}})); - } - ui.end_row(); }); + if let Some((k, v)) = drop { + let _ = app.run("type.para", json!({"attrs": {k: v.max(0.0) as u64}})); + } + vsep(ui); + // Bulleted List over Numbered List, as InDesign's (a list with the default hanging indent, or, + // pressed, none). Pressed when every selected paragraph has it, as Type › Apply Bullets shows. + use designcraft_doc::ListType; + let pressed = [ListType::Bullets, ListType::Numbers].map(|k| app.session.selection_has_list(k)); + let mut list = None; + rows(ui, |ui, row| { + let (icon, cmd, tip) = if row == 0 { ("bullets", "list.bullets", "Bulleted List") } else { ("numbering", "list.numbers", "Numbered List") }; + let on = pressed.get(row).copied().unwrap_or(false); + if crate::widgets::icon_toggle(ui, icon, on, &tr(tip)).clicked() { + list = Some((cmd, !on)); + } + }); + if let Some((cmd, on)) = list { + let _ = app.run(cmd, json!({"on": on})); + } + vsep(ui); + // Shading over Border: on or off, the Paragraph Borders and Shading dialog on its tab, and the + // colour, as InDesign's. + let doc = app.session.active().map(|d| d.doc.clone()); + let mut shade = None; + let mut dialog = None; + // Both checkboxes as wide as the wider label, so the icons and colours line up in columns. + let check_w = ["Shading", "Border"] + .iter() + .map(|l| ui.fonts_mut(|f| f.layout_no_wrap(tr(l), egui::FontId::proportional(11.5), Color32::WHITE).size().x)) + .fold(0.0, f32::max) + + 28.0; + rows(ui, |ui, row| { + let (on_key, color_key, label, icon, tab) = if row == 0 { + ("shadingOn", "shadingColor", "Shading", "para-shading", "shading") + } else { + ("borderOn", "borderColor", "Border", "para-border", "border") + }; + let mut on = para[on_key].as_bool().unwrap_or(false); + ui.allocate_ui_with_layout(vec2(check_w, crate::widgets::FIELD_H), egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.set_min_width(check_w); + if crate::widgets::checkbox(ui, &mut on, &tr(label)).changed() { + shade = Some(json!({ on_key: on })); + } + }); + if crate::widgets::icon_toggle(ui, icon, false, &tr("Paragraph Borders and Shading…")).clicked() { + dialog = Some(tab); + } + if let Some(doc) = &doc { + let cur = para[color_key].as_str().unwrap_or("[Black]").to_string(); + if let Some(w) = swatch_field(ui, doc, &cur, 96.0, &tr(if row == 0 { "Paragraph Shading" } else { "Paragraph Border" })) { + shade = Some(json!({ color_key: w })); + } + } + }); + if let Some(attrs) = shade { + let _ = app.run("type.para", json!({ "attrs": attrs })); + } + if let Some(tab) = dialog { + let _ = app.run("app.paragraphBordersDialog", json!({ "tab": tab })); + } vsep(ui); ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 3.0; let ps = attrs.as_ref().and_then(|a| a["paragraphStyle"].as_str()).unwrap_or("").to_string(); let ov = attrs.as_ref().and_then(|a| a["paraOverrides"].as_u64()).unwrap_or(0) + attrs.as_ref().and_then(|a| a["charOverrides"].as_u64()).unwrap_or(0); panels::para_style_picker(app, ui, &ps, ov > 0, 170.0); - let hy = para["hyphenate"].as_bool().unwrap_or(true); - let mut h = hy; - if ui.checkbox(&mut h, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Hyphenate"))).changed() { - let _ = app.run("type.para", json!({"attrs": {"hyphenate": h}})); - } + ui.horizontal(|ui| { + let mut h = para["hyphenate"].as_bool().unwrap_or(true); + if crate::widgets::checkbox(ui, &mut h, &tr("Hyphenate")).changed() { + let _ = app.run("type.para", json!({"attrs": {"hyphenate": h}})); + } + // First Line Only is aligned too: the button shows it pressed, and a click turns alignment off. + let grid = matches!(para["gridAlign"].as_str(), Some("allLines" | "firstLineOnly")); + if crate::widgets::icon_toggle(ui, "grid-baseline", grid, &tr("Align to Baseline Grid")).clicked() { + let _ = app.run("type.para", json!({"attrs": {"gridAlign": if grid { "none" } else { "allLines" }}})); + } + }); }); } +/// A swatch field of the Control panel (Shading and Border colours): the swatch's chip and name, +/// and the document's swatches in its menu. Returns the swatch picked. +pub(crate) fn swatch_field(ui: &mut egui::Ui, doc: &designcraft_doc::Document, cur: &str, width: f32, label: &str) -> Option { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(width, crate::widgets::FIELD_H), Sense::click()); + let resp = resp.on_hover_text(label); + resp.widget_info(|| { + let mut info = egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, label); + info.current_text_value = Some(cur.to_string()); + info + }); + let p = ui.painter(); + p.rect_filled(r, 0.0, if resp.hovered() { t.hover } else { t.input }); + p.rect_stroke(r, 0.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); + let seg = egui::Rect::from_min_max(egui::pos2(r.max.x - crate::widgets::SEG_W, r.min.y), r.max); + p.line_segment([seg.left_top(), seg.left_bottom()], Stroke::new(1.0, t.field_border)); + icons::paint(p, egui::Rect::from_center_size(seg.center(), vec2(12.0, 12.0)), "chevron-down", t.icon); + let (c, g) = crate::widgets::swatch_colors(doc, cur, 1.0); + let chip = egui::Rect::from_min_size(egui::pos2(r.min.x + 4.0, r.center().y - 6.0), vec2(12.0, 12.0)); + crate::widgets::paint_chip(p, chip, c, g); + let clip = egui::Rect::from_min_max(r.min, egui::pos2(seg.min.x - 2.0, r.max.y)); + p.with_clip_rect(clip).text(egui::pos2(chip.max.x + 5.0, r.center().y), egui::Align2::LEFT_CENTER, cur, egui::FontId::proportional(11.5), t.text); + let mut picked = None; + egui::Popup::menu(&resp).show(|ui| { + picked = crate::panels::swatch_list(ui, doc, cur); + }); + picked +} + /// The widest a document tab grows: a longer name is shortened so its close button and the /// tabs after it stay in view. const DOC_TAB_MAX_W: f32 = 260.0; @@ -852,85 +1212,97 @@ pub fn start_screen(app: &mut DesignApp, ui: &mut egui::Ui) { egui::Align::Min })), |ui| { - ui.label(crate::rtl::widget( - ui, - egui::RichText::new(crate::i18n::tr(&app.ui.language, "Welcome to DesignCraft")).font(semibold(28.0)).color(t.text_strong), - )); - ui.label(crate::rtl::widget( - ui, - egui::RichText::new(crate::i18n::tr(&app.ui.language, "Page layout for print and screen — fast, open and scriptable.")) - .size(15.0) - .color(t.text_dim), - )); - ui.add_space(24.0); - ui.horizontal(|ui| { - if ui - .add( - egui::Button::new(crate::rtl::widget( - ui, - egui::RichText::new(crate::i18n::tr(&app.ui.language, " New file ")).size(14.0).color(Color32::WHITE), - )) - .fill(t.accent_strong) - .corner_radius(16.0) - .min_size(vec2(0.0, 32.0)), - ) - .clicked() - { - let _ = app.run("app.newDocumentDialog", json!({})); - } - if ui - .add( - egui::Button::new(crate::rtl::widget(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, " Open ")).size(14.0))) + egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + ui.label(crate::rtl::widget( + ui, + egui::RichText::new(crate::i18n::tr(&app.ui.language, "Welcome to DesignCraft")).font(semibold(28.0)).color(t.text_strong), + )); + ui.label(crate::rtl::widget( + ui, + egui::RichText::new(crate::i18n::tr(&app.ui.language, "Page layout for print and screen — fast, open and scriptable.")) + .size(15.0) + .color(t.text_dim), + )); + ui.add_space(24.0); + ui.horizontal(|ui| { + if ui + .add( + egui::Button::new(crate::rtl::widget( + ui, + egui::RichText::new(crate::i18n::tr(&app.ui.language, " New file ")).size(14.0).color(Color32::WHITE), + )) + .fill(t.accent_strong) .corner_radius(16.0) .min_size(vec2(0.0, 32.0)), - ) - .clicked() - { - let _ = app.run("app.openDialog", json!({})); - } - if ui - .add( - egui::Button::new(crate::rtl::widget( - ui, - egui::RichText::new(crate::i18n::tr(&app.ui.language, " Open sample magazine ")).size(14.0), - )) - .corner_radius(16.0) - .min_size(vec2(0.0, 32.0)), - ) - .clicked() - { - let _ = app.run("file.newSample", json!({})); - } - }); - ui.add_space(20.0); - community_card(app, ui); - recent_files(app, ui); - ui.add_space(24.0); - ui.label(crate::rtl::widget( - ui, - egui::RichText::new(crate::i18n::tr(&app.ui.language, "Start a new document")).font(semibold(15.0)).color(t.text_strong), - )); - ui.add_space(8.0); - ui.horizontal_wrapped(|ui| { - for p in designcraft_doc::build::PRESETS.iter().filter(|p| p.intent == designcraft_doc::Intent::Print) { - let (cr, resp) = ui.allocate_exact_size(vec2(120.0, 150.0), Sense::click()); - let hov = resp.hovered(); - ui.painter().rect_filled(cr, 6.0, if hov { t.hover } else { t.panel }); - let k = (80.0 / p.width.max(p.height)) as f32; - let pr = egui::Rect::from_center_size(cr.center() - vec2(0.0, 14.0), vec2(p.width as f32 * k, p.height as f32 * k)); - ui.painter().rect_filled(pr, 0.0, Color32::from_gray(245)); - ui.painter().text(egui::pos2(cr.center().x, cr.max.y - 26.0), egui::Align2::CENTER_CENTER, p.name, semibold(12.0), t.text_strong); - ui.painter().text( - egui::pos2(cr.center().x, cr.max.y - 11.0), - egui::Align2::CENTER_CENTER, - format!("{} × {}", designcraft_geom::format_measure(p.width, p.units), designcraft_geom::format_measure(p.height, p.units)), - egui::FontId::proportional(10.0), - t.text_dim, - ); - if resp.clicked() { - let _ = app.run("file.new", json!({"preset": p.name})); + ) + .clicked() + { + let _ = app.run("app.newDocumentDialog", json!({})); } - } + if ui + .add( + egui::Button::new(crate::rtl::widget(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, " Open ")).size(14.0))) + .corner_radius(16.0) + .min_size(vec2(0.0, 32.0)), + ) + .clicked() + { + let _ = app.run("app.openDialog", json!({})); + } + if ui + .add( + egui::Button::new(crate::rtl::widget( + ui, + egui::RichText::new(crate::i18n::tr(&app.ui.language, " Open sample magazine ")).size(14.0), + )) + .corner_radius(16.0) + .min_size(vec2(0.0, 32.0)), + ) + .clicked() + { + let _ = app.run("file.newSample", json!({})); + } + }); + ui.add_space(20.0); + community_card(app, ui); + recent_files(app, ui); + ui.add_space(24.0); + ui.label(crate::rtl::widget( + ui, + egui::RichText::new(crate::i18n::tr(&app.ui.language, "Start a new document")).font(semibold(15.0)).color(t.text_strong), + )); + ui.add_space(8.0); + ui.horizontal_wrapped(|ui| { + for p in designcraft_doc::build::PRESETS.iter().filter(|p| p.intent == designcraft_doc::Intent::Print) { + let (cr, resp) = ui.allocate_exact_size(vec2(120.0, 150.0), Sense::click()); + let hov = resp.hovered(); + ui.painter().rect_filled(cr, 6.0, if hov { t.hover } else { t.panel }); + let k = (80.0 / p.width.max(p.height)) as f32; + let pr = egui::Rect::from_center_size(cr.center() - vec2(0.0, 14.0), vec2(p.width as f32 * k, p.height as f32 * k)); + ui.painter().rect_filled(pr, 0.0, Color32::from_gray(245)); + ui.painter().text( + egui::pos2(cr.center().x, cr.max.y - 26.0), + egui::Align2::CENTER_CENTER, + p.name, + semibold(12.0), + t.text_strong, + ); + ui.painter().text( + egui::pos2(cr.center().x, cr.max.y - 11.0), + egui::Align2::CENTER_CENTER, + format!( + "{} × {}", + designcraft_geom::format_measure(p.width, p.units), + designcraft_geom::format_measure(p.height, p.units) + ), + egui::FontId::proportional(10.0), + t.text_dim, + ); + if resp.clicked() { + let _ = app.run("file.new", json!({"preset": p.name})); + } + } + }); }); }, ); @@ -1060,7 +1432,9 @@ fn community_card(app: &mut DesignApp, ui: &mut egui::Ui) { let (r, _) = ui.allocate_exact_size(vec2(44.0, 44.0), Sense::hover()); crate::about::paint_mark(ui, r, crate::about::BRAND); ui.add_space(8.0); - let text_width = (ui.available_width() - BUTTON.x - 16.0).max(120.0); + let label = crate::i18n::tr(&app.ui.language, "Join our Discord"); + let button = crate::about::discord_button_size(ui.ctx(), label, BUTTON); + let text_width = (ui.available_width() - button.x - 16.0).max(120.0); ui.vertical(|ui| { ui.set_max_width(text_width); ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap); @@ -1090,7 +1464,7 @@ fn community_card(app: &mut DesignApp, ui: &mut egui::Ui) { }); }); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if crate::about::discord_button(ui, crate::i18n::tr(&app.ui.language, "Join our Discord"), BUTTON) { + if crate::about::discord_button(ui, label, BUTTON) { let _ = app.run("help.discord", json!({})); } }); @@ -1100,6 +1474,28 @@ fn community_card(app: &mut DesignApp, ui: &mut egui::Ui) { #[cfg(test)] mod tests { + + /// A Control panel field is labelled by its icon, so assistive technology reads "Tracking" + /// (or "Font Size", …) with the value. + #[test] + fn icon_fields_are_labelled_by_their_icon() { + use egui::accesskit::Role; + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, "en"); + ctx.enable_accesskit(); + let mut update = None; + for _ in 0..2 { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + icon_field(ui, "tracking", "Tracking", NumField::number("tracking_test", Some(0.0), "", 0)); + }); + out.textures_delta.clear(); + update = out.platform_output.accesskit_update; + } + let update = update.expect("an AccessKit tree"); + let named = |n: &egui::accesskit::Node| n.label() == Some("Tracking") || n.value() == Some("Tracking"); + let icon = update.nodes.iter().find(|(_, n)| named(n)).map(|(id, _)| *id).expect("the icon's node"); + assert!(update.nodes.iter().any(|(_, n)| n.role() == Role::TextInput && n.labelled_by().contains(&icon)), "the editor names its icon"); + } use egui::{pos2, vec2}; use egui_kittest::kittest::{NodeT, Queryable}; @@ -1304,6 +1700,233 @@ mod tests { assert!(short.min.x - first.min.x <= 2.0 * super::DOC_TAB_MAX_W, "the last tab {short:?} starts too far right"); } + /// Zero2Graphic's page layout video (0:48): with the Type tool, the Control panel's font size + /// has a list of sizes; the instructor picks 72 pt from it. Leading has the same list. + #[test] + fn the_type_control_panel_lists_font_sizes_and_leadings() { + let mut app = app(); + app.run("file.new", serde_json::json!({})).unwrap(); + let f = app.run("frame.create", serde_json::json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Headline"})).unwrap(); + app.run("text.select", serde_json::json!({"story": f["story"], "anchor": 0, "focus": 8})).unwrap(); + app.session.set_tool("type"); + let mut h = test_window::open(app, vec2(1800.0, 700.0)); + let bar = test_window::panel_rect(&h, "control_bar"); + let in_bar = |r: &egui::Rect| bar.y_range().contains(r.center().y); + let field = |h: &egui_kittest::Harness<'static, test_window::Window>, value: &str| { + h.query_all_by_value(value).map(|n| n.rect()).find(in_bar).unwrap_or_else(|| panic!("no {value:?} field in the Control panel")) + }; + let press = |h: &mut egui_kittest::Harness<'static, test_window::Window>, p: egui::Pos2| { + h.hover_at(p); + h.drag_at(p); + h.drop_at(p); + }; + let size_of = |h: &egui_kittest::Harness<'static, test_window::Window>| { + let st = h.state().app.session.doc().unwrap(); + let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); + st.doc.story(sid).unwrap().format_after(0).over.size + }; + // The ⌄ segment right of the size field opens the list; choosing 72 pt sets the size. + let size = field(&h, "12 pt"); + let chevron = pos2(size.max.x + 1.0 + crate::widgets::SEG_W / 2.0, size.center().y); + press(&mut h, chevron); + h.run_steps(3); + h.get_by_label("72 pt").click(); + h.run_steps(3); + assert_eq!(size_of(&h), Some(72.0)); + // Auto leading shows in parentheses, with the same list beside it. + let lead = field(&h, "(86.4 pt)"); + press(&mut h, pos2(lead.max.x + 1.0 + crate::widgets::SEG_W / 2.0, lead.center().y)); + h.run_steps(3); + h.get_by_label("60 pt").click(); + h.run_steps(3); + let st = h.state().app.session.doc().unwrap(); + let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); + assert_eq!(st.doc.story(sid).unwrap().format_after(0).over.leading, Some(designcraft_doc::Leading::Points(60.0))); + // The spinner left of the size steps it by a point. + let size = field(&h, "72 pt"); + press(&mut h, pos2(size.min.x - 5.0 - crate::widgets::SEG_W / 2.0, size.min.y + 3.0)); + h.run_steps(3); + assert_eq!(size_of(&h), Some(73.0)); + // Typing the automatic value itself over "(87.6 pt)" fixes the leading at it. + h.state_mut().app.run("type.char", serde_json::json!({"attrs": {"leading": {"kind": "auto"}}})).unwrap(); + h.run_steps(3); + let lead = field(&h, "(87.6 pt)"); + press(&mut h, lead.center()); + h.run_steps(2); + h.event(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }); + h.event(egui::Event::Text("87.6".into())); + h.key_press(egui::Key::Enter); + h.run_steps(3); + let st = h.state().app.session.doc().unwrap(); + let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); + assert_eq!(st.doc.story(sid).unwrap().format_after(0).over.leading, Some(designcraft_doc::Leading::Points(87.6))); + // Escape cancels: the same typing, then Escape, leaves the leading automatic. + h.state_mut().app.run("type.char", serde_json::json!({"attrs": {"leading": {"kind": "auto"}}})).unwrap(); + h.run_steps(3); + let auto = h.state().app.session.doc().unwrap().doc.story(sid).unwrap().format_after(0).over.leading; + let lead = field(&h, "(87.6 pt)"); + press(&mut h, lead.center()); + h.run_steps(2); + h.event(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }); + h.event(egui::Event::Text("87.6".into())); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert_eq!(h.state().app.session.doc().unwrap().doc.story(sid).unwrap().format_after(0).over.leading, auto, "Escape leaves it automatic"); + // Escape also reverts a different number, in the leading field and in a plain one. (It + // leaves the text too, so each try selects it again.) + for (shown, typed) in [("(87.6 pt)", "20"), ("73 pt", "50")] { + h.state_mut().app.run("text.select", serde_json::json!({"story": f["story"], "anchor": 0, "focus": 8})).unwrap(); + h.state_mut().app.session.set_tool("type"); + h.run_steps(3); + let r = field(&h, shown); + press(&mut h, r.center()); + h.run_steps(2); + h.event(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }); + h.event(egui::Event::Text(typed.into())); + h.key_press(egui::Key::Escape); + h.run_steps(3); + } + assert_eq!(h.state().app.session.doc().unwrap().doc.story(sid).unwrap().format_after(0).over.leading, auto, "still automatic"); + assert_eq!(size_of(&h), Some(73.0), "the size is unchanged"); + } + + /// The Control panel's ¶ mode on a First Line Only paragraph: Align to Baseline Grid shows + /// pressed, and a click turns alignment off instead of making it All Lines. + #[test] + fn the_control_panel_grid_button_keeps_first_line_only() { + use egui_kittest::kittest::{NodeT, Queryable}; + let mut app = app(); + app.run("file.new", serde_json::json!({})).unwrap(); + let f = app.run("frame.create", serde_json::json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Bulbs"})).unwrap(); + app.run("text.select", serde_json::json!({"story": f["story"], "anchor": 0, "focus": 5})).unwrap(); + app.run("type.para", serde_json::json!({"attrs": {"gridAlign": "firstLineOnly"}})).unwrap(); + app.session.set_tool("type"); + let mut h = test_window::open(app, vec2(2400.0, 700.0)); + let bar = test_window::panel_rect(&h, "control_bar"); + let in_bar = move |r: egui::Rect| bar.y_range().contains(r.center().y); + let click = |h: &mut egui_kittest::Harness<'static, test_window::Window>, label: &str| { + let r = h.query_all_by_label(label).map(|n| n.rect()).find(|r| in_bar(*r)).unwrap_or_else(|| panic!("no {label:?} in the Control panel")); + h.hover_at(r.center()); + h.drag_at(r.center()); + h.drop_at(r.center()); + h.run_steps(3); + }; + click(&mut h, "Paragraph Formatting Controls"); + let pressed = h.query_all_by_label("Align to Baseline Grid").find(|n| in_bar(n.rect())).map(|n| n.accesskit_node().toggled()); + assert_eq!(pressed, Some(Some(egui::accesskit::Toggled::True))); + assert_eq!(crate::panels::text_attrs(&mut h.state_mut().app).unwrap()["para"]["gridAlign"], "firstLineOnly"); + click(&mut h, "Align to Baseline Grid"); + assert_eq!(crate::panels::text_attrs(&mut h.state_mut().app).unwrap()["para"]["gridAlign"], "none"); + } + + /// The course's chapter 16: the text Control panel has InDesign's two modes. A shows the + /// Character Formatting Controls (the case buttons, kerning and tracking, the scales…), ¶ the + /// Paragraph Formatting Controls (alignment, indents, spacing, the drop cap…). + #[test] + fn the_type_control_panel_has_character_and_paragraph_modes() { + let mut app = app(); + app.run("file.new", serde_json::json!({})).unwrap(); + let f = app.run("frame.create", serde_json::json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Bulbs"})).unwrap(); + app.run("text.select", serde_json::json!({"story": f["story"], "anchor": 0, "focus": 5})).unwrap(); + app.session.set_tool("type"); + let mut h = test_window::open(app, vec2(2400.0, 700.0)); + let bar = test_window::panel_rect(&h, "control_bar"); + let in_bar = move |r: egui::Rect| bar.y_range().contains(r.center().y); + let click = |h: &mut egui_kittest::Harness<'static, test_window::Window>, label: &str| { + let r = h.query_all_by_label(label).map(|n| n.rect()).find(|r| in_bar(*r)).unwrap_or_else(|| panic!("no {label:?} in the Control panel")); + h.hover_at(r.center()); + h.drag_at(r.center()); + h.drop_at(r.center()); + h.run_steps(3); + }; + let has = |h: &egui_kittest::Harness<'static, test_window::Window>, label: &str| h.query_all_by_label(label).any(|n| in_bar(n.rect())); + assert!(has(&h, "Small Caps") && has(&h, "Vertical Scale") && !has(&h, "Drop Cap Number of Lines")); + click(&mut h, "Small Caps"); + click(&mut h, "Paragraph Formatting Controls"); + assert!(has(&h, "Drop Cap Number of Lines") && has(&h, "Space Before") && !has(&h, "Small Caps")); + click(&mut h, "Align to Baseline Grid"); + click(&mut h, "Align center"); + let a = crate::panels::text_attrs(&mut h.state_mut().app).unwrap(); + assert_eq!(a["chars"]["capitalization"], "smallCaps"); + assert_eq!(a["para"]["gridAlign"], "allLines"); + assert_eq!(a["para"]["align"], "center"); + click(&mut h, "Character Formatting Controls"); + assert!(has(&h, "Small Caps")); + } + + /// The course's chapter 21: the ¶ Control panel has InDesign's Bulleted and Numbered List + /// buttons (with the default hanging indent), and Shading and Border with their colours and + /// the Paragraph Borders and Shading dialog. + #[test] + fn the_paragraph_control_panel_has_lists_shading_and_border() { + let mut app = app(); + app.run("file.new", serde_json::json!({})).unwrap(); + let f = app + .run( + "frame.create", + serde_json::json!({"rect": [72, 72, 400, 300], "content": "text", "text": "4 eggs +1 cup milk"}), + ) + .unwrap(); + app.run("text.select", serde_json::json!({"story": f["story"], "anchor": 0, "focus": 15})).unwrap(); + app.session.set_tool("type"); + let mut h = test_window::open(app, vec2(2400.0, 700.0)); + let bar = test_window::panel_rect(&h, "control_bar"); + let in_bar = move |r: egui::Rect| bar.y_range().contains(r.center().y); + let click = |h: &mut egui_kittest::Harness<'static, test_window::Window>, label: &str, nth: usize| { + let r = h + .query_all_by_label(label) + .map(|n| n.rect()) + .filter(|r| in_bar(*r)) + .nth(nth) + .unwrap_or_else(|| panic!("no {label:?} in the Control panel")); + h.hover_at(r.center()); + h.drag_at(r.center()); + h.drop_at(r.center()); + h.run_steps(3); + }; + click(&mut h, "Paragraph Formatting Controls", 0); + click(&mut h, "Bulleted List", 0); + let a = crate::panels::text_attrs(&mut h.state_mut().app).unwrap(); + assert_eq!( + (a["para"]["listType"].as_str(), a["para"]["leftIndent"].as_f64(), a["para"]["firstLineIndent"].as_f64()), + (Some("bullets"), Some(18.0), Some(-18.0)) + ); + click(&mut h, "Numbered List", 0); + assert_eq!(crate::panels::text_attrs(&mut h.state_mut().app).unwrap()["para"]["listType"], "numbers"); + click(&mut h, "Shading", 0); + click(&mut h, "Border", 0); + let a = crate::panels::text_attrs(&mut h.state_mut().app).unwrap(); + assert_eq!((a["para"]["shadingOn"].as_bool(), a["para"]["borderOn"].as_bool()), (Some(true), Some(true))); + assert!(h.query_all_by_label("Paragraph Shading").any(|n| in_bar(n.rect())), "the shading colour"); + // The icon beside Border opens the dialog on its Border tab. + click(&mut h, "Paragraph Borders and Shading…", 1); + let d = h.state().app.ui.dialog.clone().expect("the dialog"); + assert_eq!((d.id.as_str(), d.s("section").as_str()), ("paragraphLocal", "border")); + // The Last Line Right Indent takes a negative value, as the First Line Left Indent does. + h.state_mut().app.ui.dialog = None; + h.state_mut().app.run("text.select", serde_json::json!({"story": f["story"], "anchor": 0, "focus": 15})).unwrap(); + h.state_mut().app.session.set_tool("type"); + h.run_steps(3); + // The field is labelled by its icon. + let at = h + .query_all_by_label("Last Line Right Indent") + .filter(|n| n.accesskit_node().role() == egui::accesskit::Role::TextInput) + .map(|n| n.rect()) + .find(|r| in_bar(*r)) + .expect("the last line indent") + .center(); + h.hover_at(at); + h.drag_at(at); + h.drop_at(at); + h.run_steps(2); + h.event(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }); + h.event(egui::Event::Text("-0p6".into())); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(crate::panels::text_attrs(&mut h.state_mut().app).unwrap()["para"]["lastLineIndent"].as_f64(), Some(-6.0)); + } + #[test] fn the_page_tool_has_its_own_control_panel() { // Chris Cicchino's Page Tool and Liquid Layout video (1:02): with the Page tool, the diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index b9efa035a..d4828b1f4 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -212,10 +212,8 @@ impl Default for View { } } -/// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table. -pub fn contributors_ui(ui: &mut egui::Ui, lang: &str) { - let id = egui::Id::new("credits_view"); - let mut v = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_default(); +/// The name toggle, sort and layout controls above the list. +fn controls(ui: &mut egui::Ui, v: &mut View, lang: &str) { ui.horizontal_wrapped(|ui| { ui.label(tr(lang, "Show")); for m in NameMode::ALL { @@ -244,6 +242,13 @@ pub fn contributors_ui(ui: &mut egui::Ui, lang: &str) { v.table = true; } }); +} + +/// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table. +pub fn contributors_ui(ui: &mut egui::Ui, lang: &str) { + let id = egui::Id::new("credits_view"); + let mut v = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_default(); + controls(ui, &mut v, lang); let list = sorted(CONTRIBUTORS, v.names, v.key, v.ascending); let totals = tr(lang, "{contributors} contributors · {commits} commits") .replace("{contributors}", &list.len().to_string()) diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs index 566fe65f9..28f98a5e0 100644 --- a/crates/ui-egui/src/cursors.rs +++ b/crates/ui-egui/src/cursors.rs @@ -3,10 +3,10 @@ //! what a click does (add, delete, close, convert, continue), and a curved double arrow for rotating. //! Cursors without a glyph stay system cursors. //! -//! Linux and Windows show the glyphs as OS cursors ([`Images`]): the system moves them itself, so -//! they never trail the pointer. macOS sizes a cursor bitmap in points (a Retina one would show -//! twice as big, or blurred), so there the canvas paints the glyph at the pointer. The -//! web build keeps the system cursors. +//! Linux (X11) and Windows show the glyphs as OS cursors ([`Images`]): the system moves them itself, +//! so they never trail the pointer. macOS sizes a cursor bitmap in points (a Retina one would show +//! twice as big, or blurred) and a Wayland session (native or through XWayland) scales it by its own +//! rules, so there the canvas paints the glyph at the pointer. The web build keeps the system cursors. use egui::epaint::{Mesh, TessellationOptions, Tessellator, Vertex}; use egui::{Color32, CustomCursorImage, Pos2, Shape, Stroke, Vec2, pos2, vec2}; @@ -27,13 +27,32 @@ pub enum Mode { System, } -pub const MODE: Mode = if cfg!(target_arch = "wasm32") { - Mode::System -} else if cfg!(target_os = "macos") { - Mode::Painted -} else { - Mode::Os -}; +/// The mode for a platform: `wayland` is true on a Linux Wayland session. +fn mode_for(wasm: bool, macos: bool, wayland: bool) -> Mode { + if wasm { + Mode::System + } else if macos || wayland { + Mode::Painted + } else { + Mode::Os + } +} + +/// Whether the environment describes a Wayland session: a Wayland display, unless the session says X11. +fn is_wayland(wayland_display: Option<&str>, session_type: Option<&str>) -> bool { + wayland_display.is_some_and(|d| !d.is_empty()) && !session_type.is_some_and(|t| t.eq_ignore_ascii_case("x11")) +} + +/// How cursor glyphs are shown on this system. On Wayland the compositor sizes a cursor bitmap by +/// its own scale, so the OS glyph came out far too big; there the canvas paints it, as on macOS. +pub fn mode() -> Mode { + static MODE: std::sync::OnceLock = std::sync::OnceLock::new(); + *MODE.get_or_init(|| { + let wayland = cfg!(target_os = "linux") + && is_wayland(std::env::var("WAYLAND_DISPLAY").ok().as_deref(), std::env::var("XDG_SESSION_TYPE").ok().as_deref()); + mode_for(cfg!(target_arch = "wasm32"), cfg!(target_os = "macos"), wayland) + }) +} /// The shapes of one glyph: every halo goes under all the ink, so one part's halo never cuts /// through another part's ink. @@ -289,9 +308,9 @@ pub fn system(c: Cursor) -> egui::CursorIcon { } /// Show cursor `c` with the pointer at `hover` (screen points): set the OS cursor bitmap or paint -/// the glyph per [`MODE`], and return the cursor icon to set. +/// the glyph per [`mode`], and return the cursor icon to set. pub fn show(ctx: &egui::Context, images: &mut Images, c: Cursor, hover: Pos2) -> egui::CursorIcon { - match MODE { + match mode() { Mode::Os => { // The glyphs keep the system cursors' size whatever the interface scale. let ppp = ctx.pixels_per_point() / ctx.zoom_factor().max(0.01); @@ -359,6 +378,19 @@ mod tests { assert!(rasterize(Cursor::Pen, f32::NAN).is_none() && rasterize(Cursor::Pen, 0.0).is_none()); } + #[test] + fn wayland_paints_the_glyphs_and_x11_keeps_os_cursors() { + assert_eq!(mode_for(false, false, true), Mode::Painted); + assert_eq!(mode_for(false, false, false), Mode::Os); + assert_eq!(mode_for(false, true, false), Mode::Painted); + assert_eq!(mode_for(true, false, true), Mode::System); + assert!(is_wayland(Some("wayland-0"), Some("wayland"))); + assert!(is_wayland(Some("wayland-0"), None)); + assert!(!is_wayland(Some("wayland-0"), Some("x11"))); + assert!(!is_wayland(Some(""), Some("wayland"))); + assert!(!is_wayland(None, Some("wayland"))); + } + #[test] fn the_selection_arrows_differ_in_fill() { // A pixel inside the arrow's body, below the tip: ink for Selection, halo for Direct. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 40787954f..5245e00be 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -527,6 +527,15 @@ fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) { _ => ((inner - o.gutter * (n - 1.0)) / n).max(0.0), }; let kind = |v: Value| v.as_str().unwrap_or_default().to_string(); + // A frame without a custom baseline grid shows the document grid's start and increment. + let grid = o.baseline_grid.unwrap_or_else(|| { + let g = app.session.active().map(|s| s.doc.settings.baseline_grid.clone()).unwrap_or_default(); + let relative_to = match g.relative_to { + designcraft_doc::GridRelative::TopOfPage => designcraft_doc::FrameGridRelative::TopOfPage, + designcraft_doc::GridRelative::TopMargin => designcraft_doc::FrameGridRelative::TopMargin, + }; + designcraft_doc::FrameBaselineGrid { start: g.start, increment: g.increment, relative_to } + }); let shown = json!({ "columns": o.columns, "gutter": fm(o.gutter), "columnsKind": kind(serde_json::to_value(o.columns_kind).unwrap_or_default()), "columnWidth": fm(width), @@ -542,6 +551,8 @@ fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) { "useMinimumHeight": o.auto_size_use_min_height, "minimumHeight": fm(o.auto_size_min_height), "useMinimumWidth": o.auto_size_use_min_width, "minimumWidth": fm(o.auto_size_min_width), "noLineBreaks": o.auto_size_no_line_breaks, + "customGrid": o.baseline_grid.is_some(), "gridStart": fm(grid.start), "gridIncrement": fm(grid.increment), + "gridRelativeTo": grid.relative_to.name(), }); if let Value::Object(m) = &shown { for (k, v) in m { @@ -593,6 +604,28 @@ fn text_frame_options_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) text_field(ui, d, "firstBaselineMin", 70.0); ui.end_row(); }); + ui.add_space(8.0); + crate::rtl::label(ui, egui::RichText::new(tr("Baseline Grid")).font(semibold(12.0))); + check(ui, d, "customGrid", tr("Use Custom Baseline Grid")); + ui.add_enabled_ui(d.b("customGrid"), |ui| { + egui::Grid::new("tfo_grid").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, tr("Start:")); + text_field(ui, d, "gridStart", 70.0); + ui.end_row(); + crate::rtl::label(ui, tr("Relative To:")); + translated_combo( + &lang, + ui, + d, + "gridRelativeTo", + &[("page", "Top of Page"), ("margin", "Top Margin"), ("frame", "Top of Frame"), ("inset", "Top Inset")], + ); + ui.end_row(); + crate::rtl::label(ui, tr("Increment Every:")); + text_field(ui, d, "gridIncrement", 70.0); + ui.end_row(); + }); + }); } "autosize" => auto_size(&lang, ui, d), "rules" => column_rules(app, ui, d), @@ -857,6 +890,30 @@ fn confirm_text_frame_options(app: &mut DesignApp, d: &mut Dialog) -> Result= 0.0).ok_or_else(|| bad(what))?)); } } + // The custom baseline grid: off, or the fields the user changed (all of them when it is + // turned on, since it shows the document grid's). + let grid_keys = ["customGrid", "gridStart", "gridIncrement", "gridRelativeTo"]; + if grid_keys.iter().any(|k| edited(d, k)) { + if d.b("customGrid") { + let all = edited(d, "customGrid"); + let mut g = Map::new(); + if all || edited(d, "gridStart") { + g.insert("start".into(), json!(d.m("gridStart").ok_or_else(|| bad("the baseline grid start is not a measure"))?)); + } + if all || edited(d, "gridIncrement") { + g.insert( + "increment".into(), + json!(d.m("gridIncrement").filter(|v| *v > 0.0).ok_or_else(|| bad("the baseline grid increment is not a measure"))?), + ); + } + if all || edited(d, "gridRelativeTo") { + g.insert("relativeTo".into(), json!(d.s("gridRelativeTo"))); + } + p.insert("baselineGrid".into(), Value::Object(g)); + } else { + p.insert("baselineGrid".into(), Value::Null); + } + } if p.is_empty() { return Ok(Value::Null); } @@ -1991,8 +2048,8 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_he egui::ComboBox::from_id_salt("pref_language").width(180.0).height(400.0).selected_text(crate::rtl::widget(ui, current)).show_ui( ui, |ui| { - for (code, name) in crate::i18n::LANGUAGES { - if ui.selectable_label(*code == app.ui.language, crate::rtl::widget(ui, *name)).clicked() { + for (code, name) in crate::i18n::picker_languages() { + if ui.selectable_label(code == app.ui.language, crate::rtl::widget(ui, name)).clicked() { // Applied by `show` right after this frame, which has `&mut DesignApp`. d.fields.insert("applyLanguage".into(), json!(code)); } @@ -2064,10 +2121,14 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { match d.s("section").as_str() { "span" => "Span Columns", "bullets" => "Bullets and Numbering", + "keep" => "Keep Options", + "nested" => "Drop Caps and Nested Styles", + "grep" => "GREP Styles", _ => "Paragraph Borders and Shading", }, ), "justification" => crate::i18n::tr(&app.ui.language, "Justification"), + "hyphenation" => crate::i18n::tr(&app.ui.language, "Hyphenation Settings"), "insertXref" => crate::i18n::tr(&app.ui.language, "New Cross-Reference"), "findFont" => crate::i18n::tr(&app.ui.language, "Find/Replace Font"), "polygonSettings" => crate::i18n::tr(&app.ui.language, "Polygon Settings"), @@ -2109,6 +2170,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "alert" => 440.0, "closeDocument" | "updateLink" | "deletePages" | "newWorkspace" | "pagesPanelOptions" => 380.0, "newDocument" => 960.0, + "hyphenation" => 560.0, "numberingSection" => 560.0, crate::effects_dialog::ID => 720.0, _ => 640.0, @@ -2361,6 +2423,21 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let current = d.fields.get("current").cloned().unwrap_or_default(); justification(&app.ui.language, ui, &mut d, "", ¤t); } + "hyphenation" => { + let current = d.fields.get("current").cloned().unwrap_or_default(); + let units = app.session.active().map_or(Unit::Picas, |st| st.doc.settings.horizontal_units); + let preview = crate::i18n::tr(&app.ui.language, "Preview").to_string(); + ui.horizontal_top(|ui| { + ui.vertical(|ui| hyphenation(&app.ui.language, ui, &mut d, "", ¤t, units)); + ui.add_space(24.0); + check(ui, &mut d, "preview", &preview); + }); + hyphenation_preview(app, &mut d); + if let Some(st) = d.fields.get("status").and_then(Value::as_str) { + ui.add_space(6.0); + crate::rtl::label(ui, egui::RichText::new(st).color(ui.visuals().error_fg_color)); + } + } "insertXref" => insert_xref(app, ui, &mut d), "findFont" => find_font(app, ui, &mut d), "colorPicker" => color_picker(ui, &mut d), @@ -2456,6 +2533,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { { match app.session.execute("color.loadProfile", &json!({"path": path})) { Ok(_) => { + // Replayed in load order at start-up, so the latest load of a path comes last. + app.ui.color_profiles.retain(|p| p != &path); + app.ui.color_profiles.push(path); let f = app.session.execute("color.settings", &json!({})).unwrap_or_default(); d.fields.insert("profiles".into(), f["profiles"].clone()); } @@ -2836,6 +2916,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { ui.horizontal(|ui| { if matches!(d.id.as_str(), "paragraphStyleOptions" | "characterStyleOptions") && !d.b("new") { check(ui, &mut d, "preview", crate::i18n::tr(&app.ui.language, "Preview")); + app.ui.style_preview = d.b("preview"); style_options_preview(app, &mut d); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -3169,6 +3250,24 @@ pub fn confirm(app: &mut DesignApp) -> Result { } app.run("type.para", json!({"attrs": attrs})) } + "hyphenation" => { + // The preview goes; the edits are made once, as one undo step. + if d.fields.contains_key(PREVIEWED) { + app.session.cancel_interaction(); + } + let attrs = hyphenation_edits(&d); + if attrs.is_empty() { + return Ok(Value::Null); + } + app.run("type.para", json!({"attrs": attrs})).inspect_err(|e| { + // The dialog stays open, with the reason, to be corrected. + app.status(e.clone()); + let mut d = d; + d.fields.remove(PREVIEWED); + d.fields.insert("status".into(), json!(e)); + app.ui.dialog = Some(d); + }) + } "justification" => { let current = d.fields.get("current").cloned().unwrap_or_default(); if let Err(e) = check_justification(&d, "", ¤t) { @@ -3275,13 +3374,29 @@ pub fn confirm(app: &mut DesignApp) -> Result { } "pdfImport" => { let crop = d.s("crop"); + // One page number, 1 or more; a blank field is page 1. A range or list ("4-5", "2,5"), a + // fraction or a negative is refused rather than placing some other page. + let raw = if d.fields.get("page").is_none_or(Value::is_null) { String::new() } else { d.s("page") }; + let page = match raw.trim() { + "" => 1, + t => match t.parse::() { + Ok(n) if n >= 1 => n, + _ => { + let e = "Enter a single page number; one page is placed at a time".to_string(); + let mut d = d; + d.fields.insert("status".into(), json!(e)); + app.ui.dialog = Some(d); + return Err(e); + } + }, + }; if d.b("async") { - return app.confirm_pdf_import(d.n("page").unwrap_or(1.0).max(1.0) as u64, if crop.is_empty() { "crop".into() } else { crop }); + return app.confirm_pdf_import(page, if crop.is_empty() { "crop".into() } else { crop }); } if let Some(target) = d.fields.get("target").and_then(Value::as_u64) { app.activate_import_target(&crate::ImportRequest { purpose: "place".into(), target: Some(target) })?; } - app.place_file(json!({"path": d.s("path"), "pdfPage": d.n("page").unwrap_or(1.0).max(1.0) as u64, "pdfCrop": if crop.is_empty() { "crop".to_string() } else { crop }})) + app.place_file(json!({"path": d.s("path"), "pdfPage": page, "pdfCrop": if crop.is_empty() { "crop".to_string() } else { crop }})) } "print" => { let pages = if d.s("range") == "all" { Value::Null } else { json!(d.s("pages")) }; @@ -3337,7 +3452,7 @@ pub fn confirm(app: &mut DesignApp) -> Result { params["title"] = json!(title); } let name = if title.is_empty() { "Export.pdf".to_string() } else { format!("{title}.pdf") }; - match app.services.pick_save.as_mut().and_then(|f| f(&name)) { + match app.pick_save(&name) { Some(path) => { params["path"] = json!(path); crate::menus::export_pdf(app, ¶ms) @@ -3659,18 +3774,13 @@ fn check_paragraph_style_justification(app: &DesignApp, d: &Dialog) -> Result<() check_justification(d, "p.", &base) } -fn confirm_paragraph_style_options(app: &mut DesignApp, d: &mut Dialog) -> Result { - let name = d.s("name"); - if let Err(e) = check_paragraph_style_justification(app, d) { - // The caller shows the reason in the dialog's status line. - d.fields.insert("section".into(), json!("justify")); - return Err(e); - } +/// The `p.*` and `c.*` dialog fields as the paragraph and character attribute maps a style +/// command takes. `p.ruleAbove.weight` is one field of a rule (the command keeps the others). +fn style_option_maps(d: &Dialog) -> (serde_json::Map, serde_json::Map) { let mut para = serde_json::Map::new(); let mut chars = serde_json::Map::new(); for (k, v) in &d.fields { if let Some(a) = k.strip_prefix("p.") { - // `p.ruleAbove.weight`: one field of a rule (the command keeps the others). match a.split_once('.') { Some((attr, field)) => { if let Value::Object(m) = para.entry(attr).or_insert_with(|| json!({})) { @@ -3685,6 +3795,17 @@ fn confirm_paragraph_style_options(app: &mut DesignApp, d: &mut Dialog) -> Resul chars.insert(a.into(), v.clone()); } } + (para, chars) +} + +fn confirm_paragraph_style_options(app: &mut DesignApp, d: &mut Dialog) -> Result { + let name = d.s("name"); + if let Err(e) = check_paragraph_style_justification(app, d) { + // The caller shows the reason in the dialog's status line. + d.fields.insert("section".into(), json!("justify")); + return Err(e); + } + let (para, chars) = style_option_maps(d); let based = d.s("basedOn"); let rename = d.s("rename"); let (r, style) = if d.b("new") { @@ -3719,6 +3840,26 @@ fn confirm_paragraph_style_options(app: &mut DesignApp, d: &mut Dialog) -> Resul Ok(r) } +/// Copies the dialog's `c.*` (and, for a paragraph style, `p.*`) fields into the preview params. +fn fill_style_preview_fields(params: &mut Value, d: &Dialog, paragraph: bool) { + for (key, value) in &d.fields { + if let Some(attr) = key.strip_prefix("c.") { + params["chars"][attr] = value.clone(); + } else if paragraph && let Some(attr) = key.strip_prefix("p.") { + if let Some((attr, field)) = attr.split_once('.') { + if params["para"][attr].is_null() { + params["para"][attr] = json!({}); + } + if let Some(fields) = params["para"][attr].as_object_mut() { + fields.insert(field.into(), value.clone()); + } + } else { + params["para"][attr] = value.clone(); + } + } + } +} + /// Preview the changed style attributes through the same engine commands as OK. Renaming and /// export tagging have no visual effect and wait for OK, so the dialog can keep looking up the /// original style while the preview is visible. The interaction restores its starting document @@ -3748,22 +3889,7 @@ fn style_options_preview(app: &mut DesignApp, d: &mut Dialog) { if paragraph { params["para"] = json!({}); } - for (key, value) in &d.fields { - if let Some(attr) = key.strip_prefix("c.") { - params["chars"][attr] = value.clone(); - } else if paragraph && let Some(attr) = key.strip_prefix("p.") { - if let Some((attr, field)) = attr.split_once('.') { - if params["para"][attr].is_null() { - params["para"][attr] = json!({}); - } - if let Some(fields) = params["para"][attr].as_object_mut() { - fields.insert(field.into(), value.clone()); - } - } else { - params["para"][attr] = value.clone(); - } - } - } + fill_style_preview_fields(&mut params, d, paragraph); if let Some(based) = d.fields.get("basedOn") { params["basedOn"] = match based.as_str() { Some(designcraft_doc::NO_PARA_STYLE | designcraft_doc::NO_CHAR_STYLE) => Value::Null, @@ -3880,270 +4006,8 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo text_field(ui, d, "x.class", 160.0); }); } - "nested" => { - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Drop Caps")).font(semibold(12.0))); - egui::Grid::new("psdc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - for (label, key, max) in [ - ("Lines:", "dropCapLines", designcraft_compose::MAX_DROP_CAP_LINES), - ("Characters:", "dropCapChars", designcraft_compose::MAX_DROP_CAP_CHARS), - ] { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label)); - let v = cur(d, &format!("p.{key}"), &pv[key]).as_f64(); - if let Some(n) = crate::widgets::number(ui, &format!("ps{key}"), v, "", 60.0, 0) { - d.fields.insert(format!("p.{key}"), json!((n.max(0.0) as u64).min(max as u64))); - } - ui.end_row(); - } - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Character Style:")); - let style = cur(d, "p.dropCapStyle", &pv["dropCapStyle"]).as_str().unwrap_or("").to_string(); - let style = if style.is_empty() { designcraft_doc::NO_CHAR_STYLE.to_string() } else { style }; - egui::ComboBox::from_id_salt("ps_dropcap_style") - .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &style))) - .width(160.0) - .show_ui(ui, |ui| { - for c in &cnames { - if ui.selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, c))).clicked() { - d.fields.insert("p.dropCapStyle".into(), json!(c)); - } - } - }); - ui.end_row(); - }); - for (label, key) in [("Align Left Edge", "dropCapAlignLeft"), ("Scale for Descenders", "dropCapScaleDescenders")] { - let mut on = cur(d, &format!("p.{key}"), &pv[key]).as_bool().unwrap_or(false); - if ui.checkbox(&mut on, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).changed() { - d.fields.insert(format!("p.{key}"), json!(on)); - } - } - ui.add_space(8.0); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Nested Styles")).font(semibold(12.0))); - let mut list: Vec = cur(d, "p.nestedStyles", &pv["nestedStyles"]).as_array().cloned().unwrap_or_default(); - let mut changed = false; - let mut remove = None; - for (i, ns) in list.iter_mut().enumerate() { - ui.horizontal(|ui| { - let style = ns["style"].as_str().unwrap_or("[None]").to_string(); - egui::ComboBox::from_id_salt(("ns_style", i)) - .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &style))) - .width(110.0) - .show_ui(ui, |ui| { - for c in &cnames { - if ui - .selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, c))) - .clicked() - { - ns["style"] = json!(c); - changed = true; - } - } - }); - let through = ns["through"].as_bool().unwrap_or(true); - egui::ComboBox::from_id_salt(("ns_thr", i)) - .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, if through { "through" } else { "up to" }))) - .width(70.0) - .show_ui(ui, |ui| { - for (v, l) in [(true, "through"), (false, "up to")] { - if ui.selectable_label(v == through, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, l))).clicked() { - ns["through"] = json!(v); - changed = true; - } - } - }); - let mut n = ns["count"].as_u64().unwrap_or(1) as u32; - let mut count_value = |ui: &mut egui::Ui, ns: &mut Value, changed: &mut bool| { - if ui.add(egui::DragValue::new(&mut n).range(1..=999)).changed() { - ns["count"] = json!(n); - *changed = true; - } - }; - if !crate::i18n::category_first(&app.ui.language) { - count_value(ui, ns, &mut changed); - } - let kind = ns["until"]["kind"].as_str().unwrap_or("words").to_string(); - egui::ComboBox::from_id_salt(("ns_until", i)) - .selected_text(crate::rtl::widget( - ui, - if crate::i18n::category_first(&app.ui.language) { - format!("{}:", crate::i18n::tr(&app.ui.language, &kind)) - } else { - crate::i18n::tr(&app.ui.language, &kind).to_owned() - }, - )) - .width(100.0) - .show_ui(ui, |ui| { - for k in [ - "sentences", - "words", - "characters", - "letters", - "digits", - "tab", - "forcedLineBreak", - "emSpace", - "enSpace", - "dropcap", - "chars", - ] { - if ui.selectable_label(k == kind, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, k))).clicked() { - ns["until"] = if k == "chars" { json!({"kind": "chars", "chars": ":"}) } else { json!({"kind": k}) }; - changed = true; - } - } - }); - if crate::i18n::category_first(&app.ui.language) { - count_value(ui, ns, &mut changed); - } - if kind == "chars" { - let mut c = ns["until"]["chars"].as_str().unwrap_or("").to_string(); - if ui.add(egui::TextEdit::singleline(&mut c).desired_width(40.0)).changed() { - ns["until"]["chars"] = json!(c); - changed = true; - } - } - if ui - .small_button("×") - .on_hover_ui(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Delete")); - }) - .clicked() - { - remove = Some(i); - } - }); - } - if let Some(i) = remove { - list.remove(i); - changed = true; - } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Nested Style"))).clicked() { - list.push(json!({"style": cnames.get(1).cloned().unwrap_or_default(), "through": true, "count": 1, "until": {"kind": "words"}})); - changed = true; - } - if changed { - d.fields.insert("p.nestedStyles".into(), Value::Array(list)); - } - ui.add_space(8.0); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Nested Line Styles")).font(semibold(12.0))); - let mut lines: Vec = cur(d, "p.nestedLineStyles", &pv["nestedLineStyles"]).as_array().cloned().unwrap_or_default(); - let mut changed = false; - let mut remove = None; - for (i, l) in lines.iter_mut().enumerate() { - ui.horizontal(|ui| { - let style = l["style"].as_str().unwrap_or("").to_string(); - egui::ComboBox::from_id_salt(("nls_style", i)) - .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &style))) - .width(130.0) - .show_ui(ui, |ui| { - for c in &cnames { - if ui - .selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, c))) - .clicked() - { - l["style"] = json!(c); - changed = true; - } - } - }); - crate::rtl::label( - ui, - if crate::i18n::category_first(&app.ui.language) { - format!("{}:", crate::i18n::tr(&app.ui.language, "lines")) - } else { - crate::i18n::tr(&app.ui.language, "for").to_owned() - }, - ); - let mut n = l["lines"].as_u64().unwrap_or(1) as u32; - if ui.add(egui::DragValue::new(&mut n).range(1..=999)).changed() { - l["lines"] = json!(n); - changed = true; - } - if !crate::i18n::category_first(&app.ui.language) { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, if n == 1 { "line" } else { "lines" })); - } - if ui - .small_button("×") - .on_hover_ui(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Delete")); - }) - .clicked() - { - remove = Some(i); - } - }); - } - if let Some(i) = remove { - lines.remove(i); - changed = true; - } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Line Style"))).clicked() { - lines.push(json!({"style": cnames.get(1).cloned().unwrap_or_default(), "lines": 1})); - changed = true; - } - if changed { - d.fields.insert("p.nestedLineStyles".into(), Value::Array(lines)); - } - } - "grep" => { - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "GREP Styles")).font(semibold(12.0))); - let mut list: Vec = cur(d, "p.grepStyles", &pv["grepStyles"]).as_array().cloned().unwrap_or_default(); - let mut changed = false; - let mut remove = None; - for (i, g) in list.iter_mut().enumerate() { - ui.horizontal(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Apply Style:")); - let style = g["style"].as_str().unwrap_or("").to_string(); - egui::ComboBox::from_id_salt(("gs_style", i)) - .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &style))) - .width(110.0) - .show_ui(ui, |ui| { - for c in &cnames { - if ui - .selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, c))) - .clicked() - { - g["style"] = json!(c); - changed = true; - } - } - }); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "To Text:")); - let mut pat = g["pattern"].as_str().unwrap_or("").to_string(); - let bad = grep_error(&pat).is_some(); - let r = ui.add( - egui::TextEdit::singleline(&mut pat) - .desired_width(150.0) - .text_color_opt(bad.then_some(egui::Color32::from_rgb(240, 90, 90))), - ); - if r.changed() { - g["pattern"] = json!(pat); - changed = true; - } - if ui - .small_button("×") - .on_hover_ui(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Delete")); - }) - .clicked() - { - remove = Some(i); - } - }); - if let Some(e) = grep_error(g["pattern"].as_str().unwrap_or("")) { - crate::rtl::label(ui, egui::RichText::new(e).size(11.0).color(egui::Color32::from_rgb(240, 90, 90))); - } - } - if let Some(i) = remove { - list.remove(i); - changed = true; - } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New GREP Style"))).clicked() { - list.push(json!({"style": cnames.get(1).cloned().unwrap_or_default(), "pattern": "\\d+"})); - changed = true; - } - if changed { - d.fields.insert("p.grepStyles".into(), Value::Array(list)); - } - } + "nested" => drop_caps_and_nested_styles(&app.ui.language, ui, d, &pv, &cnames), + "grep" => grep_styles(&app.ui.language, ui, d, &pv, &cnames), "chars" => basic_character_formats(app, ui, d, &CharFields { base: &cv, sparse: false }), "indents" => { egui::Grid::new("psi").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { @@ -4174,31 +4038,31 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo } ui.end_row(); } + // The CJK Grid Settings section edits the same `p.gridAlign` field. + use designcraft_doc::GridAlign; + const GRID_ALIGN: [(GridAlign, &str); 3] = + [(GridAlign::None, "None"), (GridAlign::AllLines, "All Lines"), (GridAlign::FirstLineOnly, "First Line Only")]; + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Align to Grid:")); + let g: GridAlign = serde_json::from_value(cur(d, "p.gridAlign", &pv["gridAlign"])).unwrap_or_default(); + let shown = GRID_ALIGN.iter().find(|o| o.0 == g).map_or("", |o| o.1); + egui::ComboBox::from_id_salt("psgridalign") + .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, shown))) + .width(240.0) + .show_ui(ui, |ui| { + for (v, label) in GRID_ALIGN { + if ui.selectable_label(v == g, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() + && let Ok(v) = serde_json::to_value(v) + { + d.fields.insert("p.gridAlign".into(), v); + } + } + }); + ui.end_row(); }); } "tabs" => style_tabs(&app.ui.language, ui, d, &pv, &doc), "rules" => paragraph_rules(app, ui, d, "p.", &pv), - "hyph" => { - let mut h = cur(d, "p.hyphenate", &pv["hyphenate"]).as_bool().unwrap_or(true); - if ui.checkbox(&mut h, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Hyphenate"))).changed() { - d.fields.insert("p.hyphenate".into(), json!(h)); - } - egui::Grid::new("psh").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - for (label, key) in [ - ("Words with at Least:", "hyphMinWord"), - ("After First:", "hyphAfterFirst"), - ("Before Last:", "hyphBeforeLast"), - ("Hyphen Limit:", "hyphLimit"), - ] { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label)); - let v = cur(d, &format!("p.{key}"), &pv[key]).as_f64(); - if let Some(n) = crate::widgets::number(ui, &format!("ps{key}"), v, "", 60.0, 0) { - d.fields.insert(format!("p.{key}"), json!(n.max(0.0) as u64)); - } - ui.end_row(); - } - }); - } + "hyph" => hyphenation(&app.ui.language, ui, d, "p.", &pv, units), "justify" => justification(&app.ui.language, ui, d, "p.", &pv), "color" => character_color(&app.ui.language, ui, d, &CharFields { base: &cv, sparse: false }, &doc), "advanced" => advanced_character_formats(&app.ui.language, ui, d, &CharFields { base: &cv, sparse: false }), @@ -4694,6 +4558,254 @@ fn border_shading(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, doc }); } +/// Drop Caps and Nested Styles: the drop cap, nested styles and nested line styles (`p.` fields). +fn drop_caps_and_nested_styles(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, cnames: &[String]) { + let cur = |d: &Dialog, k: &str, base: &Value| d.fields.get(k).cloned().unwrap_or_else(|| base.clone()); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "Drop Caps")).font(semibold(12.0))); + egui::Grid::new("psdc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + for (label, key, max) in [ + ("Lines:", "dropCapLines", designcraft_compose::MAX_DROP_CAP_LINES), + ("Characters:", "dropCapChars", designcraft_compose::MAX_DROP_CAP_CHARS), + ] { + crate::rtl::label(ui, crate::i18n::tr(lang, label)); + let v = cur(d, &format!("p.{key}"), &pv[key]).as_f64(); + if let Some(n) = crate::widgets::number(ui, &format!("ps{key}"), v, "", 60.0, 0) { + d.fields.insert(format!("p.{key}"), json!((n.max(0.0) as u64).min(max as u64))); + } + ui.end_row(); + } + crate::rtl::label(ui, crate::i18n::tr(lang, "Character Style:")); + let style = cur(d, "p.dropCapStyle", &pv["dropCapStyle"]).as_str().unwrap_or("").to_string(); + let style = if style.is_empty() { designcraft_doc::NO_CHAR_STYLE.to_string() } else { style }; + egui::ComboBox::from_id_salt("ps_dropcap_style") + .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, &style))) + .width(160.0) + .show_ui(ui, |ui| { + for c in cnames { + if ui.selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(lang, c))).clicked() { + d.fields.insert("p.dropCapStyle".into(), json!(c)); + } + } + }); + ui.end_row(); + }); + for (label, key) in [("Align Left Edge", "dropCapAlignLeft"), ("Scale for Descenders", "dropCapScaleDescenders")] { + let mut on = cur(d, &format!("p.{key}"), &pv[key]).as_bool().unwrap_or(false); + if ui.checkbox(&mut on, crate::rtl::widget(ui, crate::i18n::tr(lang, label))).changed() { + d.fields.insert(format!("p.{key}"), json!(on)); + } + } + ui.add_space(8.0); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "Nested Styles")).font(semibold(12.0))); + let mut list: Vec = cur(d, "p.nestedStyles", &pv["nestedStyles"]).as_array().cloned().unwrap_or_default(); + let mut changed = false; + let mut remove = None; + for (i, ns) in list.iter_mut().enumerate() { + ui.horizontal(|ui| { + let style = ns["style"].as_str().unwrap_or("[None]").to_string(); + egui::ComboBox::from_id_salt(("ns_style", i)) + .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, &style))) + .width(110.0) + .show_ui(ui, |ui| { + for c in cnames { + if ui.selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(lang, c))).clicked() { + ns["style"] = json!(c); + changed = true; + } + } + }); + let through = ns["through"].as_bool().unwrap_or(true); + egui::ComboBox::from_id_salt(("ns_thr", i)) + .selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, if through { "through" } else { "up to" }))) + .width(70.0) + .show_ui(ui, |ui| { + for (v, l) in [(true, "through"), (false, "up to")] { + if ui.selectable_label(v == through, crate::rtl::widget(ui, crate::i18n::tr(lang, l))).clicked() { + ns["through"] = json!(v); + changed = true; + } + } + }); + let mut n = ns["count"].as_u64().unwrap_or(1) as u32; + let mut count_value = |ui: &mut egui::Ui, ns: &mut Value, changed: &mut bool| { + if ui.add(egui::DragValue::new(&mut n).range(1..=999)).changed() { + ns["count"] = json!(n); + *changed = true; + } + }; + if !crate::i18n::category_first(lang) { + count_value(ui, ns, &mut changed); + } + let kind = ns["until"]["kind"].as_str().unwrap_or("words").to_string(); + egui::ComboBox::from_id_salt(("ns_until", i)) + .selected_text(crate::rtl::widget( + ui, + if crate::i18n::category_first(lang) { + format!("{}:", crate::i18n::tr(lang, &kind)) + } else { + crate::i18n::tr(lang, &kind).to_owned() + }, + )) + .width(100.0) + .show_ui(ui, |ui| { + for k in + ["sentences", "words", "characters", "letters", "digits", "tab", "forcedLineBreak", "emSpace", "enSpace", "dropcap", "chars"] + { + if ui.selectable_label(k == kind, crate::rtl::widget(ui, crate::i18n::tr(lang, k))).clicked() { + ns["until"] = if k == "chars" { json!({"kind": "chars", "chars": ":"}) } else { json!({"kind": k}) }; + changed = true; + } + } + }); + if crate::i18n::category_first(lang) { + count_value(ui, ns, &mut changed); + } + if kind == "chars" { + let mut c = ns["until"]["chars"].as_str().unwrap_or("").to_string(); + if ui.add(egui::TextEdit::singleline(&mut c).desired_width(40.0)).changed() { + ns["until"]["chars"] = json!(c); + changed = true; + } + } + if ui + .small_button("×") + .on_hover_ui(|ui| { + crate::rtl::label(ui, crate::i18n::tr(lang, "Delete")); + }) + .clicked() + { + remove = Some(i); + } + }); + } + if let Some(i) = remove { + list.remove(i); + changed = true; + } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(lang, "New Nested Style"))).clicked() { + list.push(json!({"style": cnames.get(1).cloned().unwrap_or_default(), "through": true, "count": 1, "until": {"kind": "words"}})); + changed = true; + } + if changed { + d.fields.insert("p.nestedStyles".into(), Value::Array(list)); + } + ui.add_space(8.0); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "Nested Line Styles")).font(semibold(12.0))); + let mut lines: Vec = cur(d, "p.nestedLineStyles", &pv["nestedLineStyles"]).as_array().cloned().unwrap_or_default(); + let mut changed = false; + let mut remove = None; + for (i, l) in lines.iter_mut().enumerate() { + ui.horizontal(|ui| { + let style = l["style"].as_str().unwrap_or("").to_string(); + egui::ComboBox::from_id_salt(("nls_style", i)) + .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, &style))) + .width(130.0) + .show_ui(ui, |ui| { + for c in cnames { + if ui.selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(lang, c))).clicked() { + l["style"] = json!(c); + changed = true; + } + } + }); + crate::rtl::label( + ui, + if crate::i18n::category_first(lang) { + format!("{}:", crate::i18n::tr(lang, "lines")) + } else { + crate::i18n::tr(lang, "for").to_owned() + }, + ); + let mut n = l["lines"].as_u64().unwrap_or(1) as u32; + if ui.add(egui::DragValue::new(&mut n).range(1..=999)).changed() { + l["lines"] = json!(n); + changed = true; + } + if !crate::i18n::category_first(lang) { + crate::rtl::label(ui, crate::i18n::tr(lang, if n == 1 { "line" } else { "lines" })); + } + if ui + .small_button("×") + .on_hover_ui(|ui| { + crate::rtl::label(ui, crate::i18n::tr(lang, "Delete")); + }) + .clicked() + { + remove = Some(i); + } + }); + } + if let Some(i) = remove { + lines.remove(i); + changed = true; + } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(lang, "New Line Style"))).clicked() { + lines.push(json!({"style": cnames.get(1).cloned().unwrap_or_default(), "lines": 1})); + changed = true; + } + if changed { + d.fields.insert("p.nestedLineStyles".into(), Value::Array(lines)); + } +} + +/// GREP Styles: a character style applied to text matching each pattern (`p.` fields). +fn grep_styles(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, cnames: &[String]) { + let cur = |d: &Dialog, k: &str, base: &Value| d.fields.get(k).cloned().unwrap_or_else(|| base.clone()); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "GREP Styles")).font(semibold(12.0))); + let mut list: Vec = cur(d, "p.grepStyles", &pv["grepStyles"]).as_array().cloned().unwrap_or_default(); + let mut changed = false; + let mut remove = None; + for (i, g) in list.iter_mut().enumerate() { + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(lang, "Apply Style:")); + let style = g["style"].as_str().unwrap_or("").to_string(); + egui::ComboBox::from_id_salt(("gs_style", i)) + .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, &style))) + .width(110.0) + .show_ui(ui, |ui| { + for c in cnames { + if ui.selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(lang, c))).clicked() { + g["style"] = json!(c); + changed = true; + } + } + }); + crate::rtl::label(ui, crate::i18n::tr(lang, "To Text:")); + let mut pat = g["pattern"].as_str().unwrap_or("").to_string(); + let bad = grep_error(&pat).is_some(); + let r = + ui.add(egui::TextEdit::singleline(&mut pat).desired_width(150.0).text_color_opt(bad.then_some(egui::Color32::from_rgb(240, 90, 90)))); + if r.changed() { + g["pattern"] = json!(pat); + changed = true; + } + if ui + .small_button("×") + .on_hover_ui(|ui| { + crate::rtl::label(ui, crate::i18n::tr(lang, "Delete")); + }) + .clicked() + { + remove = Some(i); + } + }); + if let Some(e) = grep_error(g["pattern"].as_str().unwrap_or("")) { + crate::rtl::label(ui, egui::RichText::new(e).size(11.0).color(egui::Color32::from_rgb(240, 90, 90))); + } + } + if let Some(i) = remove { + list.remove(i); + changed = true; + } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(lang, "New GREP Style"))).clicked() { + list.push(json!({"style": cnames.get(1).cloned().unwrap_or_default(), "pattern": "\\d+"})); + changed = true; + } + if changed { + d.fields.insert("p.grepStyles".into(), Value::Array(list)); + } +} + /// Paragraph Style Options › Keep Options: lines kept with the next paragraph, the paragraph's /// lines kept together (all of them, or its first and last lines), and where it starts. fn keep_options(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value) { @@ -5103,6 +5215,15 @@ fn paragraph_local(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.set_min_width(480.0); match d.s("section").as_str() { "span" => span_columns(&lang, ui, d, &pv, &doc), + "keep" => keep_options(&lang, ui, d, &pv), + "nested" | "grep" => { + let cnames: Vec = doc.styles.character.iter().map(|c| c.name.clone()).collect(); + if d.s("section") == "nested" { + drop_caps_and_nested_styles(&lang, ui, d, &pv, &cnames); + } else { + grep_styles(&lang, ui, d, &pv, &cnames); + } + } "bullets" => { let (char_styles, families, fonts, scope) = ListUi::new_parts(app); let (family, style) = (d.s("family"), d.s("style")); @@ -5154,7 +5275,7 @@ pub fn open_style_options(app: &mut DesignApp, para: bool, name: &str) { return; } let id = if para { "paragraphStyleOptions" } else { "characterStyleOptions" }; - app.ui.dialog = Some(Dialog::new(id, json!({"name": name}))); + app.ui.dialog = Some(Dialog::new(id, json!({"name": name, "preview": app.ui.style_preview}))); } /// New Paragraph Style… / New Character Style…: the style options for a style made on OK, named @@ -5166,6 +5287,57 @@ pub fn open_new_style(app: &mut DesignApp, para: bool) { app.ui.dialog = Some(Dialog::new(id, json!({"new": true, "rename": name}))); } +/// The General section of the Character Style Options dialog: name, Based On, and a summary of +/// the style's settings. +fn character_style_general( + lang: &str, + ui: &mut egui::Ui, + d: &mut Dialog, + styles: &designcraft_doc::Styles, + style: &designcraft_doc::CharacterStyle, + based: &str, +) { + let (name, new) = (d.s("name"), d.b("new")); + egui::Grid::new("csg").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, crate::i18n::tr(lang, "Style Name:")); + text_field(ui, d, "rename", 220.0); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(lang, "Based On:")); + egui::ComboBox::from_id_salt("csbased").selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, based))).width(220.0).show_ui( + ui, + |ui| { + // Not itself, nor a style based on it. + for s in styles.character.iter().filter(|s| new || !styles.char_based_on_cycles(&name, &s.name)) { + if ui.selectable_label(s.name == based, crate::rtl::widget(ui, crate::i18n::style_name(lang, &s.name))).clicked() { + d.fields.insert("basedOn".into(), json!(s.name)); + } + } + }, + ); + ui.end_row(); + }); + let status = d.s("status"); + if !status.is_empty() { + crate::rtl::label(ui, egui::RichText::new(status).color(crate::theme::Tokens::get(ui.ctx()).text_strong)); + } + ui.add_space(8.0); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "Style Settings:")).font(semibold(12.0))); + // What OK would leave the style setting, after its Based On. + let mut own = serde_json::to_value(&style.chars).unwrap_or_default(); + if let Some(o) = own.as_object_mut() { + for (k, v) in d.fields.iter().filter_map(|(k, v)| Some((k.strip_prefix("c.")?, v))) { + if v.is_null() { + o.remove(k); + } else { + o.insert(k.to_string(), v.clone()); + } + } + } + let mut parts = vec![crate::i18n::style_name(lang, based).to_string()]; + parts.extend(own.as_object().into_iter().flatten().map(|(k, v)| format!("{}: {}", setting_label(lang, k), setting_text(v)))); + ui.add(egui::Label::new(egui::RichText::new(parts.join(" + ")).color(crate::theme::Tokens::get(ui.ctx()).text_dim)).wrap()); +} + /// Character Style Options: General, then the character sections it shares with Paragraph Style /// Options. A character style sets only some attributes: the fields show those it and its Based /// On set and leave the rest blank. Edits are `c.` fields (`null` = no longer set), applied @@ -5228,46 +5400,7 @@ fn character_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo "underline" => line_options(&lang, ui, d, &cf, &doc, "underline"), "strikethrough" => line_options(&lang, ui, d, &cf, &doc, "strikethrough"), s if style_cjk::is_section(s) => style_cjk::section_ui(app, ui, d, s, &cf, None, &doc), - _ => { - egui::Grid::new("csg").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, crate::i18n::tr(&lang, "Style Name:")); - text_field(ui, d, "rename", 220.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&lang, "Based On:")); - egui::ComboBox::from_id_salt("csbased") - .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&lang, &based))) - .width(220.0) - .show_ui(ui, |ui| { - // Not itself, nor a style based on it. - for s in styles.character.iter().filter(|s| new || !styles.char_based_on_cycles(&name, &s.name)) { - if ui.selectable_label(s.name == based, crate::rtl::widget(ui, crate::i18n::style_name(&lang, &s.name))).clicked() { - d.fields.insert("basedOn".into(), json!(s.name)); - } - } - }); - ui.end_row(); - }); - let status = d.s("status"); - if !status.is_empty() { - crate::rtl::label(ui, egui::RichText::new(status).color(crate::theme::Tokens::get(ui.ctx()).text_strong)); - } - ui.add_space(8.0); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "Style Settings:")).font(semibold(12.0))); - // What OK would leave the style setting, after its Based On. - let mut own = serde_json::to_value(&style.chars).unwrap_or_default(); - if let Some(o) = own.as_object_mut() { - for (k, v) in d.fields.iter().filter_map(|(k, v)| Some((k.strip_prefix("c.")?, v))) { - if v.is_null() { - o.remove(k); - } else { - o.insert(k.to_string(), v.clone()); - } - } - } - let mut parts = vec![crate::i18n::style_name(&lang, &based).to_string()]; - parts.extend(own.as_object().into_iter().flatten().map(|(k, v)| format!("{}: {}", setting_label(&lang, k), setting_text(v)))); - ui.add(egui::Label::new(egui::RichText::new(parts.join(" + ")).color(crate::theme::Tokens::get(ui.ctx()).text_dim)).wrap()); - } + _ => character_style_general(&lang, ui, d, styles, &style, &based), }); }); } @@ -5788,6 +5921,158 @@ fn check_justification(d: &Dialog, prefix: &str, current: &Value) -> Result<(), Ok(()) } +/// Paragraph attributes the Hyphenation Settings dialog sets. +pub const HYPH_ATTRS: [&str; 10] = [ + "hyphenate", + "hyphMinWord", + "hyphAfterFirst", + "hyphBeforeLast", + "hyphLimit", + "hyphZone", + "hyphWeight", + "hyphCapitalized", + "hyphLastWord", + "hyphAcrossColumn", +]; + +/// InDesign's Hyphenation Settings: Hyphenate; Words with at Least, After First and Before Last +/// (letters), Hyphen Limit (hyphens) and Hyphenation Zone; the Better Spacing ↔ Fewer Hyphens +/// slider (0–10, as InDesign's HyphenWeight); Hyphenate Capitalized Words, Last Word and Across +/// Column. Edited fields are stored as `{prefix}{key}`; the others show `current` (a mixed value +/// is null and shows blank). The Hyphenation dialog for the selected paragraphs and Paragraph +/// Style Options › Hyphenation both use it. +fn hyphenation(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, prefix: &str, current: &Value, units: Unit) { + let tr = |s: &str| crate::i18n::tr(lang, s).to_string(); + let cur = |d: &Dialog, k: &str| d.fields.get(&format!("{prefix}{k}")).cloned().unwrap_or_else(|| current[k].clone()); + // Labels right-aligned against their fields, as InDesign's. + let label_right = |ui: &mut egui::Ui, text: &str| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add(egui::Label::new(crate::rtl::widget(ui, text)).wrap_mode(egui::TextWrapMode::Extend)) + }); + }; + let checkbox = |ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str| { + let v = cur(d, key); + // Mixed shows indeterminate and starts off, so the first click turns it on for all. + let mut on = v.as_bool().unwrap_or(false); + if ui.add(egui::Checkbox::new(&mut on, crate::rtl::widget(ui, tr(label))).indeterminate(v.is_null())).changed() { + d.fields.insert(format!("{prefix}{key}"), json!(on)); + } + }; + checkbox(ui, d, "hyphenate", "Hyphenate"); + ui.add_space(4.0); + egui::Grid::new(("hyph", prefix)).num_columns(3).spacing([8.0, 6.0]).show(ui, |ui| { + // InDesign's ranges. + for (label, key, unit, max) in [ + ("Words with at Least:", "hyphMinWord", "letters", 25.0), + ("After First:", "hyphAfterFirst", "letters", 15.0), + ("Before Last:", "hyphBeforeLast", "letters", 15.0), + ("Hyphen Limit:", "hyphLimit", "hyphens", 25.0), + ] { + label_right(ui, &tr(label)); + let min = if key == "hyphLimit" { 0.0 } else { 1.0 }; + if let Some(n) = crate::widgets::spin_number(ui, &format!("hy{prefix}{key}"), cur(d, key).as_f64(), "", 60.0, 0) { + d.fields.insert(format!("{prefix}{key}"), json!(n.round().clamp(min, max) as u64)); + } + crate::rtl::label(ui, tr(unit)); + ui.end_row(); + } + label_right(ui, &tr("Hyphenation Zone:")); + if let Some(n) = crate::widgets::spin_measure(ui, &format!("hy{prefix}zone"), cur(d, "hyphZone").as_f64(), units, 60.0) { + d.fields.insert(format!("{prefix}hyphZone"), json!(if n.is_finite() { n.clamp(0.0, 8640.0) } else { 36.0 })); + } + ui.end_row(); + }); + ui.add_space(6.0); + // Better Spacing ↔ Fewer Hyphens, in InDesign's eleven steps. + ui.horizontal(|ui| { + crate::rtl::label(ui, tr("Better Spacing")); + let w = cur(d, "hyphWeight").as_f64(); + let mut step = w.map_or(5.0, |w| (w * 10.0).round()).clamp(0.0, 10.0); + // Mixed across the selection: the slider shows dimmed, and a click or drag on it, even + // one that leaves the knob in place, sets every paragraph to that step. + let mixed = w.is_none(); + // The ticks go under the knob: their place in the paint order is taken first. + let ticks = ui.painter().add(egui::Shape::Noop); + let r = ui + .scope(|ui| { + ui.spacing_mut().slider_width = 180.0; + if mixed { + ui.multiply_opacity(0.4); + } + ui.add(egui::Slider::new(&mut step, 0.0..=10.0).step_by(1.0).show_value(false).handle_shape(egui::style::HandleShape::Circle)) + }) + .inner; + // The tick marks under the track. + let t = crate::theme::Tokens::get(ui.ctx()); + let inner = r.rect.shrink2(egui::vec2(r.rect.height() / 2.0, 0.0)); + let y = r.rect.center().y; + let marks: Vec = (0..=10) + .map(|i| { + let x = inner.left() + inner.width() * i as f32 / 10.0; + egui::Shape::line_segment([egui::pos2(x, y - 8.0), egui::pos2(x, y + 8.0)], egui::Stroke::new(1.0, t.text_dim)) + }) + .collect(); + ui.painter().set(ticks, egui::Shape::Vec(marks)); + if r.changed() || (mixed && (r.clicked() || r.drag_stopped())) { + d.fields.insert(format!("{prefix}hyphWeight"), json!(step / 10.0)); + } + crate::rtl::label(ui, tr("Fewer Hyphens")); + }); + ui.add_space(6.0); + egui::Grid::new(("hyphc", prefix)).num_columns(2).spacing([16.0, 6.0]).show(ui, |ui| { + checkbox(ui, d, "hyphCapitalized", "Hyphenate Capitalized Words"); + checkbox(ui, d, "hyphLastWord", "Hyphenate Last Word"); + ui.end_row(); + checkbox(ui, d, "hyphAcrossColumn", "Hyphenate Across Column"); + ui.end_row(); + }); +} + +/// The Hyphenation dialog's edits, as `type.para` attrs. +fn hyphenation_edits(d: &Dialog) -> Map { + HYPH_ATTRS.iter().filter_map(|k| d.fields.get(*k).map(|v| (k.to_string(), v.clone()))).collect() +} + +/// Preview on: the edits show on the page (redone when a field changes) without an undo step; +/// off, the paragraphs go back. +fn hyphenation_preview(app: &mut DesignApp, d: &mut Dialog) { + let shown = d.fields.get(PREVIEWED).cloned(); + if !d.b("preview") { + if shown.is_some() { + app.session.cancel_interaction(); + d.fields.remove(PREVIEWED); + } + return; + } + let params = json!({"attrs": hyphenation_edits(d)}); + if shown.as_ref() == Some(¶ms) || d.fields.get(PREVIEW_FAILED) == Some(¶ms) { + return; + } + if shown.is_none() + && let Err(e) = app.session.begin_interaction("Hyphenation") + { + d.fields.insert("status".into(), json!(e.to_string())); + return; + } + match app.session.preview("type.para", params.clone()) { + Ok(_) => { + d.fields.remove("status"); + d.fields.remove(PREVIEW_FAILED); + d.fields.insert(PREVIEWED.into(), params); + } + // Rejected settings: the paragraphs go back, and the reason shows until they change. + Err(e) => { + app.session.cancel_interaction(); + d.fields.remove(PREVIEWED); + d.fields.insert("status".into(), json!(e.to_string())); + d.fields.insert(PREVIEW_FAILED.into(), params); + } + } +} + +/// The hyphenation settings a preview was refused for (not tried again until they change). +const PREVIEW_FAILED: &str = "previewFailed"; + /// The Justification controls (the Justification dialog, and the Paragraph Style Options section). /// `current` holds the resolved paragraph attributes, `null` where the selected paragraphs differ /// (the field shows empty); each edit is stored as `{prefix}{attr}`, so OK changes only the fields @@ -7613,6 +7898,146 @@ mod tests { app.run("type.selectionAttrs", json!({})).unwrap()["para"][key].clone() } + /// The hyphenation video (Graphd Tutorials, 0:39–1:13): the Paragraph panel menu's + /// Hyphenation… opens InDesign's Hyphenation Settings for the selected paragraphs. Preview + /// shows the slider moved to Fewer Hyphens on the page without an undo step, OK makes it one + /// step on the selected paragraph only, and Cancel puts it back. + #[test] + fn hyphenation_settings_preview_apply_and_cancel_on_the_selected_paragraph() { + let (mut app, story) = two_paragraphs(); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0)); + let sid = designcraft_doc::StoryId(story.as_u64().unwrap()); + let weight = |app: &DesignApp, para: usize| { + let d = &app.session.doc().unwrap().doc; + d.styles.resolve_para(&d.story(sid).unwrap().paras[para]).0.hyph_weight + }; + assert!(crate::menus::menu_enabled(&app, "app.hyphenationDialog")); + app.run("app.hyphenationDialog", json!({})).unwrap(); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!( + (d.id.as_str(), d.fields["current"]["hyphWeight"].as_f64(), d.fields["current"]["hyphenate"].as_bool()), + ("hyphenation", Some(0.5), Some(true)) + ); + for label in [ + "Hyphenation Settings", + "Hyphenate", + "Words with at Least:", + "letters", + "Hyphen Limit:", + "hyphens", + "Hyphenation Zone:", + "Better Spacing", + "Fewer Hyphens", + "Hyphenate Capitalized Words", + "Hyphenate Last Word", + "Hyphenate Across Column", + "Preview", + ] { + assert!(labels.iter().any(|(t, _, _)| t == label), "{label}: {labels:?}"); + } + // Fewer Hyphens (9 of 10) with Preview: on the page, no undo step. + let before = undo_steps(&app); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("hyphWeight".into(), json!(0.9)); + d.fields.insert("preview".into(), json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert_eq!(weight(&app, 0), 0.9, "previewed"); + assert_eq!(undo_steps(&app), before, "a preview is no step"); + // Cancel: back as it was. + cancel(&mut app); + assert_eq!(weight(&app, 0), 0.5); + // Again, and OK: one step, the selected paragraph only. + app.run("app.hyphenationDialog", json!({})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("hyphWeight".into(), json!(0.9)); + d.fields.insert("preview".into(), json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + confirm(&mut app).unwrap(); + assert_eq!((weight(&app, 0), weight(&app, 1)), (0.9, 0.5)); + assert_eq!(undo_steps(&app), before + 1, "one undo step"); + // Paragraph Style Options › Hyphenation has the same form. + app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": designcraft_doc::story::BASIC_PARAGRAPH, "section": "hyph"}))); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + for label in ["Hyphenation Zone:", "Better Spacing", "Fewer Hyphens", "Hyphenate Last Word", "Hyphenate Across Column"] { + assert!(labels.iter().any(|(t, _, _)| t == label), "style options: {label}"); + } + app.ui.dialog = None; + app.run("edit.deselectAll", json!({})).unwrap(); + assert!(!crate::menus::menu_enabled(&app, "app.hyphenationDialog"), "no paragraph"); + } + + /// Hyphenate differs across the selection: it shows mixed, and the first click turns it on for + /// all. Settings the command refuses show why: a preview goes back and isn't retried until they + /// change, and OK keeps the dialog open. + /// A mixed Better Spacing ↔ Fewer Hyphens slider: a click on its middle step sets every + /// selected paragraph to it, though the knob already sat there. + #[test] + fn hyphenation_settings_mixed_slider_click_sets_the_step() { + let (mut app, story) = two_paragraphs(); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0)); + app.run("type.para", json!({"attrs": {"hyphWeight": 0.2}})).unwrap(); + app.run("text.select", json!({"story": story, "anchor": 0, "focus": 7})).unwrap(); + app.run("app.hyphenationDialog", json!({})).unwrap(); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + let rect = |l: &str| labels.iter().find(|(t, _, _)| t == l).map(|(_, r, _)| *r).unwrap(); + let (better, fewer) = (rect("Better Spacing"), rect("Fewer Hyphens")); + click_dialog(&mut app, &ctx, screen, egui::pos2((better.right() + fewer.left()) / 2.0, better.center().y)); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields.get("hyphWeight"), Some(&json!(0.5))); + } + + #[test] + fn hyphenation_settings_mixed_checkbox_and_refused_settings() { + let (mut app, story) = two_paragraphs(); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0)); + app.run("type.para", json!({"attrs": {"hyphenate": false}})).unwrap(); + app.run("text.select", json!({"story": story, "anchor": 0, "focus": 7})).unwrap(); + app.run("app.hyphenationDialog", json!({})).unwrap(); + assert!(app.ui.dialog.as_ref().unwrap().fields["current"]["hyphenate"].is_null(), "mixed"); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + let at = labels.iter().find(|(t, _, _)| t == "Hyphenate").map(|(_, r, _)| r.left_center() - egui::vec2(10.0, 0.0)).unwrap(); + click_dialog(&mut app, &ctx, screen, at); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields.get("hyphenate"), Some(&json!(true)), "the first click turns it on"); + // A refused setting (as a control-channel caller could send it): no preview, the reason. + let before = undo_steps(&app); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("hyphWeight".into(), json!("heavy")); + d.fields.insert("preview".into(), json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + let d = app.ui.dialog.as_ref().unwrap(); + assert!(!d.fields.contains_key(PREVIEWED) && d.fields.contains_key("status"), "{:?}", d.fields); + assert!(app.session.doc().unwrap().interaction.is_none(), "the preview went back"); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert!(app.session.doc().unwrap().interaction.is_none(), "not tried again"); + // OK with it: an error, and the dialog stays open with the reason. + assert!(confirm(&mut app).is_err()); + let d = app.ui.dialog.as_ref().expect("the dialog stays open"); + assert!(d.fields.contains_key("status")); + assert_eq!(undo_steps(&app), before); + // Corrected, it previews and applies. + app.ui.dialog.as_mut().unwrap().fields.insert("hyphWeight".into(), json!(0.9)); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert!(!app.ui.dialog.as_ref().unwrap().fields.contains_key("status")); + confirm(&mut app).unwrap(); + assert_eq!(para_attr(&mut app, &story, 0, "hyphenate"), true); + assert_eq!(para_attr(&mut app, &story, 4, "hyphWeight"), 0.9); + } + #[test] fn justification_dialog_applies_to_a_local_paragraph_and_cancel_changes_nothing() { let (mut app, story) = two_paragraphs(); @@ -7770,6 +8195,33 @@ mod tests { assert!(dialog.fields["status"].as_str().unwrap().contains("page size out of range")); } + #[test] + fn style_options_preview_choice_is_remembered_on_reopen() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("style.paragraph.create", json!({"name":"Tutorial","chars":{"size":10}})).unwrap(); + open_style_options(&mut app, true, "Tutorial"); + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 800.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::new("style_preview_remember_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui.ctx()); + }, + app, + ); + h.run_steps(3); + h.get_by_label("Preview").click(); + h.run_steps(3); + cancel(h.state_mut()); + open_style_options(h.state_mut(), true, "Tutorial"); + assert!(h.state().ui.dialog.as_ref().unwrap().b("preview"), "Preview stays checked on reopen"); + } + #[test] fn style_options_preview_updates_all_uses_cancels_and_commits_once() { use egui_kittest::{Harness, kittest::Queryable}; @@ -7928,6 +8380,35 @@ mod tests { assert_eq!((opts(&app).columns_kind, opts(&app).column_width), (designcraft_doc::ColumnsKind::FixedNumber, 120.0)); } + #[test] + fn text_frame_options_set_a_custom_baseline_grid_relative_to() { + use designcraft_doc::{FrameBaselineGrid, FrameGridRelative}; + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let r = app.run("frame.create", json!({"rect": [36, 36, 336, 236], "content": "text", "text": "Hello"})).unwrap(); + let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap()); + let grid = |app: &DesignApp| app.session.doc().unwrap().doc.item(id).unwrap().text_frame().unwrap().options.baseline_grid; + let open = |app: &mut DesignApp, fields: &[(&str, Value)]| { + let mut d = Dialog::new("textFrameOptions", json!({})); + seed_text_frame_options(app, &mut d); + for (k, v) in fields { + d.fields.insert((*k).into(), v.clone()); + } + app.ui.dialog = Some(d); + confirm(app) + }; + app.run("selection.set", json!({"ids": [id.0]})).unwrap(); + // Turned on, the grid takes the shown start and increment (the document grid's: 3p0 and 1p0). + open(&mut app, &[("customGrid", json!(true)), ("gridRelativeTo", json!("frame"))]).unwrap(); + assert_eq!(grid(&app), Some(FrameBaselineGrid { start: 36.0, increment: 12.0, relative_to: FrameGridRelative::TopOfFrame })); + // The dialog shows the frame's grid; changing Relative To keeps its start and increment. + open(&mut app, &[("gridStart", json!("0p10"))]).unwrap(); + open(&mut app, &[("gridRelativeTo", json!("inset"))]).unwrap(); + assert_eq!(grid(&app), Some(FrameBaselineGrid { start: 10.0, increment: 12.0, relative_to: FrameGridRelative::TopInset })); + open(&mut app, &[("customGrid", json!(false))]).unwrap(); + assert_eq!(grid(&app), None); + } + #[test] fn text_frame_options_change_only_what_was_edited() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); @@ -9102,7 +9583,11 @@ mod tests { for _ in 0..3 { dialog_frame(&mut app, &ctx, screen, vec![]); } - let height = visible_label(&dialog_frame(&mut app, &ctx, screen, vec![]), screen, "66p1"); + // With craft-fonts' Noto Sans Arabic the rows above are taller, so at the smallest size the + // Height field starts partly below the body's clip: scroll to it like the other fields. + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + let (_, _, body) = labels.iter().find(|(text, _, _)| text.trim() == "66p1").expect("66p1 is painted"); + let height = scroll_to_label(&mut app, &ctx, screen, body.center(), "66p1"); click_dialog(&mut app, &ctx, screen, height.center()); dialog_frame( &mut app, @@ -9279,6 +9764,31 @@ mod tests { assert_eq!(st.doc.styles.para(&name).unwrap().para.align, Some(designcraft_doc::Align::Center)); } + #[test] + fn paragraph_style_options_align_to_grid_shows_and_sets_the_style_grid_alignment() { + use designcraft_doc::GridAlign; + use egui_kittest::kittest::Queryable; + let mut app = test_app(); + app.session.execute("style.paragraph.create", &json!({"name": "Grid Body", "para": {"gridAlign": "firstLineOnly"}})).unwrap(); + app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Grid Body", "section": "indents"}))); + let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); + let grid = |h: &egui_kittest::Harness<'static, crate::test_window::Window>| { + h.state().app.session.active().unwrap().doc.styles.para("Grid Body").unwrap().para.grid_align + }; + h.run_steps(4); + h.get_by_value("First Line Only"); + confirm(&mut h.state_mut().app).unwrap(); + assert_eq!(grid(&h), Some(GridAlign::FirstLineOnly), "OK without edits keeps the style's alignment"); + h.state_mut().app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Grid Body", "section": "indents"}))); + h.run_steps(4); + h.get_by_value("First Line Only").click(); + h.run_steps(3); + h.get_by_label("All Lines").click(); + h.run_steps(3); + confirm(&mut h.state_mut().app).unwrap(); + assert_eq!(grid(&h), Some(GridAlign::AllLines)); + } + #[test] fn delete_paragraph_style_dialog_removes_the_style_and_reassigns_text() { let mut app = test_app(); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 6057abc97..e74cc0f73 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -34,8 +34,83 @@ pub const LANGUAGES: &[(&str, &str)] = &[ ("ka", "ქართული"), ]; +/// The interface languages in the order the Preferences picker lists them: English first (the +/// source language), then the rest by their own name with accents folded, so Čeština sits under +/// C. Names in other scripts follow the Latin ones in code point order: Cyrillic, Arabic, +/// Georgian, then the CJK languages. +pub fn picker_languages() -> Vec<(&'static str, &'static str)> { + let mut rest: Vec<(&str, &str)> = LANGUAGES.iter().copied().filter(|(code, _)| !code.is_empty()).collect(); + rest.sort_by_cached_key(|(_, name)| name_sort_key(name)); + std::iter::once(LANGUAGES[0]).chain(rest).collect() +} + /// English → [German, French, Spanish, Japanese, Simplified Chinese]. const TABLE: &[(&str, [&str; 5])] = &[ + ( + "Character Formatting Controls", + [ + "Steuerelemente für Zeichenformatierung", + "Commandes de mise en forme des caractères", + "Controles de formato de carácter", + "文字形式コントロール", + "字符格式控制", + ], + ), + ( + "Paragraph Formatting Controls", + [ + "Steuerelemente für Absatzformatierung", + "Commandes de mise en forme des paragraphes", + "Controles de formato de párrafo", + "段落形式コントロール", + "段落格式控制", + ], + ), + ("Font Size", ["Schriftgrad", "Corps", "Tamaño de fuente", "フォントサイズ", "字体大小"]), + ("Vertical Scale", ["Vertikal skalieren", "Échelle verticale", "Escala vertical", "垂直比率", "垂直缩放"]), + ("Horizontal Scale", ["Horizontal skalieren", "Échelle horizontale", "Escala horizontal", "水平比率", "水平缩放"]), + ("Baseline Shift", ["Grundlinienversatz", "Décalage vertical", "Desplazamiento vertical", "ベースラインシフト", "基线偏移"]), + ( + "Skew (False Italic)", + ["Verzerren (falscher Kursivstil)", "Inclinaison (faux italique)", "Sesgar (cursiva falsa)", "歪み(斜体)", "倾斜(伪斜体)"], + ), + ("Left Indent", ["Einzug links", "Retrait gauche", "Sangría izquierda", "左インデント", "左缩进"]), + ("Right Indent", ["Einzug rechts", "Retrait droit", "Sangría derecha", "右インデント", "右缩进"]), + ( + "First Line Left Indent", + ["Einzug links in erster Zeile", "Retrait gauche de première ligne", "Sangría izquierda en primera línea", "1行目左インデント", "首行左缩进"], + ), + ( + "Last Line Right Indent", + ["Einzug rechts in letzter Zeile", "Retrait droit de dernière ligne", "Sangría derecha en última línea", "最終行右インデント", "末行右缩进"], + ), + ( + "Drop Cap Number of Lines", + ["Initiale: Anzahl der Zeilen", "Lettrine (nombre de lignes)", "Capitular: número de líneas", "ドロップキャップの行数", "首字下沉行数"], + ), + ( + "Drop Cap One or More Characters", + [ + "Initiale: ein oder mehrere Zeichen", + "Lettrine (un ou plusieurs caractères)", + "Capitular: uno o más caracteres", + "ドロップキャップの文字数", + "首字下沉一个或多个字符", + ], + ), + ( + "Align to Baseline Grid", + [ + "An Grundlinienraster ausrichten", + "Aligner sur la grille de ligne de base", + "Alinear con cuadrícula base", + "ベースライングリッドに揃える", + "对齐基线网格", + ], + ), + // Properties › Paragraph (•••). + ("Space Before", ["Abstand davor", "Espace avant", "Espacio anterior", "段落前の間隔", "段前间距"]), + ("Space After", ["Abstand danach", "Espace après", "Espacio posterior", "段落後の間隔", "段后间距"]), // Menus. ("File", ["Datei", "Fichier", "Archivo", "ファイル", "文件"]), ("Edit", ["Bearbeiten", "Édition", "Edición", "編集", "编辑"]), @@ -1000,6 +1075,102 @@ const TABLE: &[(&str, [&str; 5])] = &[ ], ), ("Load Styles", ["Formate laden", "Charger les styles", "Cargar estilos", "スタイルを読み込み", "加载样式"]), + ("Restart Numbering", ["Nummerierung neu beginnen", "Recommencer la numérotation", "Reiniciar numeración", "番号の振り直し", "重新开始编号"]), + ("Continue Numbering", ["Nummerierung fortführen", "Poursuivre la numérotation", "Continuar numeración", "番号の継続", "继续编号"]), + ("Keep Options…", ["Umbruchoptionen …", "Options d'enchaînement…", "Opciones de separación…", "段落分離禁止オプション…", "保持选项…"]), + ( + "Space Between Paragraphs Using Same Style", + [ + "Abstand zwischen Absätzen mit gleichem Format", + "Espace entre les paragraphes de même style", + "Espacio entre párrafos con el mismo estilo", + "同じスタイルの段落間のスペース", + "使用相同样式的段落间距", + ], + ), + ("Ignore", ["Ignorieren", "Ignorer", "Ignorar", "無視", "忽略"]), + ( + "Do Not Align to Baseline Grid", + [ + "Nicht am Grundlinienraster ausrichten", + "Ne pas aligner sur la grille de ligne de base", + "No alinear con cuadrícula base", + "ベースライングリッドに揃えない", + "不对齐基线网格", + ], + ), + ( + "Only Align First Line to Grid", + [ + "Nur erste Zeile am Raster ausrichten", + "Aligner uniquement la première ligne sur la grille", + "Alinear solo primera línea con cuadrícula", + "最初の行のみグリッドに揃える", + "仅首行对齐网格", + ], + ), + ( + "Balance Ragged Lines", + ["Unausgeglichene Zeilen ausgleichen", "Équilibrer les lignes irrégulières", "Equilibrar líneas desiguales", "行末を調整", "平衡未对齐的行"], + ), + ( + "Drop Caps and Nested Styles…", + [ + "Initialen und verschachtelte Formate …", + "Lettrines et styles imbriqués…", + "Capitulares y estilos anidados…", + "ドロップキャップと先頭文字スタイル…", + "首字下沉和嵌套样式…", + ], + ), + ("GREP Styles…", ["GREP-Stile …", "Styles GREP…", "Estilos GREP…", "正規表現スタイル…", "GREP 样式…"]), + ( + "Restart/Continue Numbering", + [ + "Nummerierung neu beginnen/fortführen", + "Recommencer/Poursuivre la numérotation", + "Reiniciar/Continuar numeración", + "番号の振り直し/継続", + "重新开始/继续编号", + ], + ), + ( + "Convert Bullets and Numbering to Text", + [ + "Aufzählungszeichen und Nummerierung in Text konvertieren", + "Convertir les puces et la numérotation en texte", + "Convertir viñetas y numeración en texto", + "箇条書きと段落番号をテキストに変換", + "将项目符号和编号转换为文本", + ], + ), + ("Hyphenation…", ["Silbentrennung …", "Césure…", "Separación por sílabas…", "ハイフネーション…", "连字…"]), + ( + "Hyphenation Settings", + ["Silbentrennungseinstellungen", "Paramètres de césure", "Configuración de separación por sílabas", "ハイフネーション設定", "连字设置"], + ), + ("hyphens", ["Trennstriche", "tirets", "guiones", "ハイフン", "连字符"]), + ("Hyphenation Zone:", ["Silbentrennungsbereich:", "Zone de césure :", "Zona de separación:", "ハイフネーション領域 :", "连字区:"]), + ("Better Spacing", ["Bessere Abstände", "Meilleur espacement", "Mejor espaciado", "スペースを優先", "更好的间距"]), + ("Fewer Hyphens", ["Weniger Trennstriche", "Moins de tirets", "Menos guiones", "ハイフンを減らす", "更少的连字符"]), + ( + "Hyphenate Capitalized Words", + [ + "Großgeschriebene Wörter trennen", + "Césure des mots en capitales", + "Separar palabras en mayúscula", + "大文字で始まる単語をハイフネーション", + "连字大写的单词", + ], + ), + ( + "Hyphenate Last Word", + ["Letztes Wort trennen", "Césure du dernier mot", "Separar última palabra", "最後の単語をハイフネーション", "连字最后一个单词"], + ), + ( + "Hyphenate Across Column", + ["Über Spalten trennen", "Césure entre colonnes", "Separar entre columnas", "段をまたいでハイフネーション", "跨栏连字"], + ), ( "Differs from this document", ["Weicht von diesem Dokument ab", "Diffère de ce document", "Difiere de este documento", "このドキュメントと異なります", "与此文档不同"], @@ -1638,6 +1809,8 @@ const TABLE: &[(&str, [&str; 5])] = &[ ], ), ("Define Lists…", ["Define Lists…", "Définir des listes…", "Define Lists…", "Define Lists…", "定义列表…"]), + ("Apply Bullets", ["Aufzählungszeichen anwenden", "Appliquer des puces", "Aplicar viñetas", "箇条書きを適用", "应用项目符号"]), + ("Apply Numbers", ["Nummerierung anwenden", "Appliquer la numérotation", "Aplicar números", "段落番号を適用", "应用编号"]), ( "Define Text Variable…", ["Define Text Variable…", "Définir une variable de texte…", "Define Text Variable…", "Define Text Variable…", "定义文本变量…"], @@ -4449,36 +4622,42 @@ fn column(lang: &str) -> Option { /// `s` in `lang` (English, or the string itself, when there's no translation). pub fn tr<'a>(lang: &str, s: &'a str) -> &'a str { - static GERMAN: OnceLock> = OnceLock::new(); - static JAPANESE: OnceLock> = OnceLock::new(); - static TRANSLATIONS: OnceLock> = OnceLock::new(); - static ARABIC: OnceLock> = OnceLock::new(); - static UKRAINIAN: OnceLock> = OnceLock::new(); - static PORTUGUESE_BR: OnceLock> = OnceLock::new(); - static ITALIAN: OnceLock> = OnceLock::new(); - static POLISH: OnceLock> = OnceLock::new(); - static RUSSIAN: OnceLock> = OnceLock::new(); - static CZECH: OnceLock> = OnceLock::new(); - static FRENCH: OnceLock> = OnceLock::new(); - static GEORGIAN: OnceLock> = OnceLock::new(); + let text = lookup(lang, s); + // An empty row is a translation nobody has written yet. + if text.trim().is_empty() { s } else { text } +} + +fn lookup<'a>(lang: &str, s: &'a str) -> &'a str { + static GERMAN: OnceLock> = OnceLock::new(); + static JAPANESE: OnceLock> = OnceLock::new(); + static TRANSLATIONS: OnceLock> = OnceLock::new(); + static ARABIC: OnceLock> = OnceLock::new(); + static UKRAINIAN: OnceLock> = OnceLock::new(); + static PORTUGUESE_BR: OnceLock> = OnceLock::new(); + static ITALIAN: OnceLock> = OnceLock::new(); + static POLISH: OnceLock> = OnceLock::new(); + static RUSSIAN: OnceLock> = OnceLock::new(); + static CZECH: OnceLock> = OnceLock::new(); + static FRENCH: OnceLock> = OnceLock::new(); + static GEORGIAN: OnceLock> = OnceLock::new(); if lang == "ar" { return ARABIC.get_or_init(|| ar::TABLE.iter().copied().collect()).get(s).copied().unwrap_or(s); } // The German and Japanese tables cover the whole interface; the shared table's German and Japanese // columns are the fallback for keys added there after them. if lang == "de" - && let Some(text) = GERMAN.get_or_init(|| de::TABLE.iter().copied().collect()).get(s).copied() + && let Some(text) = GERMAN.get_or_init(|| de::TABLE.iter().copied().collect()).get(s).copied().filter(|text| !text.trim().is_empty()) { return text; } if lang == "ja" - && let Some(text) = JAPANESE.get_or_init(|| ja::TABLE.iter().copied().collect()).get(s).copied() + && let Some(text) = JAPANESE.get_or_init(|| ja::TABLE.iter().copied().collect()).get(s).copied().filter(|text| !text.trim().is_empty()) { return text; } // `fr::TABLE` holds the strings the shared table has no row for. if lang == "fr" - && let Some(text) = FRENCH.get_or_init(|| fr::TABLE.iter().copied().collect()).get(s).copied() + && let Some(text) = FRENCH.get_or_init(|| fr::TABLE.iter().copied().collect()).get(s).copied().filter(|text| !text.trim().is_empty()) { return text; } @@ -4524,11 +4703,13 @@ pub fn tr_context<'a>(lang: &str, s: &'a str, context: &str) -> &'a str { "ar" => ar::CONTEXT, _ => &[], }; - if let Some((_, _, text)) = contexts.iter().find(|(key, scope, _)| *key == s && *scope == context) { + let written = |text: &&str| !text.trim().is_empty(); + if let Some(text) = contexts.iter().find(|(key, scope, _)| *key == s && *scope == context).map(|(_, _, text)| *text).filter(written) { return text; } if let Some(c) = column(lang) - && let Some(text) = CONTEXT.iter().find(|(key, scope, _)| *key == s && *scope == context).and_then(|(_, _, row)| row.get(c)) + && let Some(text) = + CONTEXT.iter().find(|(key, scope, _)| *key == s && *scope == context).and_then(|(_, _, row)| row.get(c)).copied().filter(written) { return text; } @@ -4567,7 +4748,9 @@ pub fn count_label(lang: &str, noun: &str, count: usize) -> String { 2..=4 => few, _ => other, }; - return format!("{count} {form}"); + if !form.trim().is_empty() { + return format!("{count} {form}"); + } } format!("{count} {}", tr(lang, noun)) } @@ -4625,15 +4808,9 @@ mod tests { /// The German and Japanese tables translate the whole interface and win over the shared columns. const WHOLE_INTERFACE: [&str; 2] = ["de", "ja"]; - #[test] - fn every_interface_language_has_a_catalog() { - for (lang, i) in SHARED_COLUMNS.iter().zip(0..) { - assert_eq!(column(lang), Some(i), "{lang}"); - } - for (code, name) in LANGUAGES.iter().filter(|(code, _)| !code.is_empty()) { - assert!(!name.is_empty(), "{code}"); - assert!(LANGUAGE_TABLES.iter().any(|(lang, _)| lang == code) || column(code).is_some(), "{code} has no catalog in LANGUAGE_TABLES"); - } + /// What `tr` shows for a row: an empty one is still untranslated. + fn or_english<'a>(text: &'a str, english: &'a str) -> &'a str { + if text.trim().is_empty() { english } else { text } } #[test] @@ -4644,13 +4821,16 @@ mod tests { for (key, translations) in TABLE { for (lang, shared) in SHARED_COLUMNS.into_iter().zip(translations) { // A language's own table is looked up before the shared column. - assert_eq!(tr(lang, key), own(lang, key).unwrap_or(*shared), "{lang}: {key}"); + let expected = own(lang, key).filter(|text| !text.trim().is_empty()).unwrap_or(*shared); + assert_eq!(tr(lang, key), or_english(expected, key), "{lang}: {key}"); } assert_eq!(tr("", key), *key); } for (lang, table) in LANGUAGE_TABLES { for (key, expected) in *table { - assert_eq!(tr(lang, key), *expected, "{lang}: {key}"); + if !expected.trim().is_empty() { + assert_eq!(tr(lang, key), *expected, "{lang}: {key}"); + } assert_eq!(tr("", key), *key); } } @@ -4667,7 +4847,9 @@ mod tests { fn context_captions_come_from_their_context_or_the_plain_translation() { for (lang, table) in CONTEXT_TABLES { for (key, scope, text) in *table { - assert_eq!(tr_context(lang, key, scope), *text, "{lang}: {key} ({scope})"); + if !text.trim().is_empty() { + assert_eq!(tr_context(lang, key, scope), *text, "{lang}: {key} ({scope})"); + } } } for (key, scope, row) in CONTEXT { @@ -4678,8 +4860,10 @@ mod tests { .filter(|(code, _)| *code == lang) .flat_map(|(_, table)| table.iter()) .find(|(k, s, _)| k == key && s == scope) - .map(|(_, _, text)| *text); - assert_eq!(tr_context(lang, key, scope), own.unwrap_or(*shared), "{lang}: {key} ({scope})"); + .map(|(_, _, text)| *text) + .filter(|text| !text.trim().is_empty()); + let expected = own.or(Some(*shared).filter(|text| !text.trim().is_empty())).unwrap_or_else(|| tr(lang, key)); + assert_eq!(tr_context(lang, key, scope), expected, "{lang}: {key} ({scope})"); } } // An unknown context, or a language without one, keeps the plain translation. @@ -4692,33 +4876,37 @@ mod tests { } } - /// Every catalog: no duplicate or stale keys, no empty translations, and placeholders, padding, - /// ellipses and label colons as in English. A string a catalog doesn't translate yet falls back - /// to English, so missing translations don't fail here. - #[test] - fn every_catalog_is_unique_current_and_keeps_placeholders_and_markers() { - // (language, key, translation) of every catalog, and every catalog's keys by name. - let mut entries: Vec<(&str, &str, &str)> = Vec::new(); - let mut catalogs: Vec<(&str, HashSet<&str>)> = Vec::new(); - for (lang, table) in LANGUAGE_TABLES { - let keys: HashSet<_> = table.iter().map(|(key, _)| *key).collect(); - assert_eq!(keys.len(), table.len(), "{lang}: duplicate keys"); - catalogs.push((*lang, keys)); - entries.extend(table.iter().map(|(key, text)| (*lang, *key, *text))); - } - let shared: HashSet<_> = TABLE.iter().map(|(key, _)| *key).collect(); - assert_eq!(shared.len(), TABLE.len(), "duplicate keys in the shared table"); - catalogs.push(("shared", shared.clone())); + /// The tables `tr` reads, as (language, key, translation), context captions included. + fn entries() -> Vec<(&'static str, &'static str, &'static str)> { + let mut entries: Vec<_> = LANGUAGE_TABLES.iter().flat_map(|(lang, table)| table.iter().map(|(key, text)| (*lang, *key, *text))).collect(); for (lang, i) in SHARED_COLUMNS.into_iter().zip(0..) { entries.extend(TABLE.iter().filter_map(|(key, row)| row.get(i).map(|text| (lang, *key, *text)))); entries.extend(CONTEXT.iter().filter_map(|(key, _, row)| row.get(i).map(|text| (lang, *key, *text)))); } - let mut contexts = vec![("shared", CONTEXT.iter().map(|(key, scope, _)| (*key, *scope)).collect::>())]; - for (lang, table) in CONTEXT_TABLES { - contexts.push((*lang, table.iter().map(|(key, scope, _)| (*key, *scope)).collect())); - entries.extend(table.iter().map(|(key, _, text)| (*lang, *key, *text))); - } + entries.extend(CONTEXT_TABLES.iter().flat_map(|(lang, table)| table.iter().map(|(key, _, text)| (*lang, *key, *text)))); + entries + } + /// Every catalog has one row per key and keeps the English placeholders. A string a catalog + /// doesn't translate shows in English, so a missing or empty row fails nothing here; the + /// `missing_translations_report` lists them. + #[test] + fn every_catalog_has_unique_keys_and_keeps_placeholders() { + for (lang, i) in SHARED_COLUMNS.iter().zip(0..) { + assert_eq!(column(lang), Some(i), "{lang}"); + } + for (lang, table) in LANGUAGE_TABLES { + let keys: HashSet<_> = table.iter().map(|(key, _)| *key).collect(); + assert_eq!(keys.len(), table.len(), "{lang}: duplicate keys"); + } + let shared: HashSet<_> = TABLE.iter().map(|(key, _)| *key).collect(); + assert_eq!(shared.len(), TABLE.len(), "duplicate keys in the shared table"); + let shared_contexts: HashSet<_> = CONTEXT.iter().map(|(key, scope, _)| (*key, *scope)).collect(); + assert_eq!(shared_contexts.len(), CONTEXT.len(), "duplicate keys in the shared context table"); + for (lang, table) in CONTEXT_TABLES { + let keys: HashSet<_> = table.iter().map(|(key, scope, _)| (*key, *scope)).collect(); + assert_eq!(keys.len(), table.len(), "{lang}: duplicate context keys"); + } // A per-language table is looked up before the shared table, so a key in both hides the // shared column, except in the tables that translate the whole interface. for (lang, table) in LANGUAGE_TABLES.iter().filter(|(lang, _)| column(lang).is_some() && !WHOLE_INTERFACE.contains(lang)) { @@ -4726,41 +4914,58 @@ mod tests { assert!(both.is_empty(), "{lang} keys also in the shared table: {both:?}"); } - // A key is current while the interface or another catalog has it. - let interface = interface_strings(); - let current = |key: &str, own: &str| interface.contains(key) || catalogs.iter().any(|(name, keys)| *name != own && keys.contains(key)); - for (name, keys) in &catalogs { - let mut stale: Vec<_> = keys.iter().filter(|key| !current(key, name)).collect(); - stale.sort(); - assert!(stale.is_empty(), "{name}: entries for unknown keys: {stale:?}"); - } - for (name, keys) in &contexts { - let unique: HashSet<_> = keys.iter().collect(); - assert_eq!(unique.len(), keys.len(), "{name}: duplicate context keys"); - let stale: Vec<_> = keys.iter().filter(|(key, _)| !current(key, "")).collect(); - assert!(stale.is_empty(), "{name}: contexts for unknown keys: {stale:?}"); - } - let placeholders = |s: &'static str| { let mut names: Vec<_> = s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name)).collect(); names.sort_unstable(); names }; - let ellipsis = |s: &str| s.ends_with('…') || s.ends_with("..."); - let colon = |s: &str| s.ends_with(':') || s.ends_with(':'); - // Full-width punctuation carries its own spacing. - let full_width = |c: char| ('\u{3000}'..='\u{303F}').contains(&c) || ('\u{FF00}'..='\u{FFEF}').contains(&c); - for (lang, key, text) in entries { - assert!(!text.trim().is_empty(), "{lang}: empty translation of {key:?}"); + for (lang, key, text) in entries().into_iter().filter(|(_, _, text)| !text.trim().is_empty()) { assert_eq!(placeholders(key), placeholders(text), "{lang}: placeholders of {key:?}"); - // Dialog ellipses, label colons and padded captions stay as in English. - assert_eq!(ellipsis(key), ellipsis(text), "{lang}: ellipsis of {key:?}"); - assert_eq!(colon(key), colon(text), "{lang}: colon of {key:?}"); - if !text.starts_with(full_width) { - assert_eq!(key.starts_with(' '), text.starts_with(' '), "{lang}: leading space of {key:?}"); - } - if !text.ends_with(full_width) { - assert_eq!(key.ends_with(' '), text.ends_with(' '), "{lang}: trailing space of {key:?}"); + } + } + + /// The strings each interface language shows in English, for the `cargo xtask ci` log. It + /// always passes: `cargo xtask i18n-report` runs it. + #[test] + #[ignore = "a report, run by `cargo xtask i18n-report`"] + fn missing_translations_report() { + let interface = interface_strings(); + let catalogs: Vec<(&str, HashSet<&str>)> = LANGUAGE_TABLES + .iter() + .map(|(lang, table)| (*lang, table.iter().map(|(key, _)| *key).collect())) + .chain([("shared", TABLE.iter().map(|(key, _)| *key).collect())]) + .collect(); + let mut keys: Vec<&str> = interface.iter().copied().chain(catalogs.iter().flat_map(|(_, keys)| keys.iter().copied())).collect(); + keys.sort_unstable(); + keys.dedup(); + // `tr` hands back the string it was given when it has no translation. + let missing = |lang: &str| -> Vec<&str> { + keys.iter() + .copied() + .filter(|key| { + let english = (*key).to_owned(); + std::ptr::eq(tr(lang, &english), english.as_str()) + }) + .collect() + }; + let report: Vec<(&str, Vec<&str>)> = LANGUAGES.iter().filter(|(code, _)| !code.is_empty()).map(|(code, _)| (*code, missing(code))).collect(); + println!("i18n report: {} interface strings; a missing translation shows in English.", keys.len()); + for (lang, missing) in &report { + println!(" {lang:<6} {:>5} missing", missing.len()); + } + for (lang, missing) in report.iter().filter(|(_, missing)| !missing.is_empty()) { + println!("{lang} ({}): {}", missing.len(), missing.iter().map(|key| format!("{key:?}")).collect::>().join(", ")); + } + // A catalog key neither the interface list nor another catalog knows is stale, or a label + // `interface_strings` doesn't list yet. + for (name, own) in &catalogs { + let mut unknown: Vec<_> = own + .iter() + .filter(|key| !interface.contains(*key) && !catalogs.iter().any(|(other, keys)| other != name && keys.contains(*key))) + .collect(); + unknown.sort_unstable(); + if !unknown.is_empty() { + println!("{name}: {} rows for keys the interface list doesn't have: {unknown:?}", unknown.len()); } } } @@ -4788,47 +4993,21 @@ mod tests { } } + /// Languages with several plural forms put the category first, so every number reads the same. #[test] - fn georgian_counts_use_category_captions_for_every_number() { - for n in [1, 2, 5, 21] { - assert_eq!(count_label("ka", "pages", n), format!("გვერდი: {n}")); - } - } - - #[test] - fn ukrainian_counts_use_category_captions_for_every_number() { - for n in [0, 1, 2, 4, 5, 11, 21, 22, 25] { - assert_eq!(count_label("uk", "pages", n), format!("сторінок: {n}")); - assert_eq!(count_label("uk", "objects", n), format!("об’єктів: {n}")); - assert_eq!(count_label("uk", "errors", n), format!("помилок: {n}")); - let pages = tr("uk", "{pages} Pages in {spreads} Spreads").replace("{pages}", &n.to_string()).replace("{spreads}", &n.to_string()); - assert_eq!(pages, format!("Сторінок: {n}; розворотів: {n}")); + fn category_first_counts_put_the_category_before_every_number() { + assert!(!category_first("de") && !category_first("")); + for lang in ["ka", "uk", "ru", "pl"] { + assert!(category_first(lang), "{lang}"); + for noun in ["pages", "objects", "errors"] { + for n in [0, 1, 2, 4, 5, 11, 12, 21, 22, 25, 101, 111] { + assert_eq!(count_label(lang, noun, n), format!("{}: {n}", tr(lang, noun)), "{lang}: {noun}"); + } + } } assert_eq!(count_label("", "objects", 2), "2 objects"); } - #[test] - fn russian_counts_use_category_captions_for_every_number() { - assert!(category_first("ru") && category_first("uk") && !category_first("de") && !category_first("")); - for n in [0, 1, 2, 4, 5, 11, 12, 21, 22, 25, 101, 111] { - assert_eq!(count_label("ru", "pages", n), format!("страниц: {n}")); - assert_eq!(count_label("ru", "objects", n), format!("объектов: {n}")); - assert_eq!(count_label("ru", "errors", n), format!("ошибок: {n}")); - let pages = tr("ru", "{pages} Pages in {spreads} Spreads").replace("{pages}", &n.to_string()).replace("{spreads}", &n.to_string()); - assert_eq!(pages, format!("Страниц: {n}; разворотов: {n}")); - } - } - - #[test] - fn polish_counts_use_category_captions_for_every_number() { - for n in [0, 1, 2, 5, 12, 22, 25] { - assert_eq!(count_label("pl", "pages", n), format!("Strony: {n}")); - assert_eq!(count_label("pl", "objects", n), format!("Obiekty: {n}")); - assert_eq!(count_label("pl", "errors", n), format!("Błędy: {n}")); - } - assert_eq!(tr("pl", "{pages} Pages in {spreads} Spreads"), "Strony: {pages}, rozkładówki: {spreads}"); - } - /// Every English string the interface passes to `tr`, as far as the command and tool tables and /// the list of UI-only labels know them. fn interface_strings() -> HashSet<&'static str> { @@ -4929,26 +5108,52 @@ mod tests { #[test] fn czech_counts_use_the_plural_form_for_the_number() { - for (n, form) in [(0, "chyb"), (1, "chyba"), (2, "chyby"), (4, "chyby"), (5, "chyb"), (11, "chyb"), (22, "chyb")] { - assert_eq!(count_label("cs", "errors", n), format!("{n} {form}")); + for (noun, [one, few, other]) in cs::PLURALS { + for (n, form) in [(0, other), (1, one), (2, few), (4, few), (5, other), (11, other), (22, other)] { + let expected = if form.trim().is_empty() { format!("{n} {}", tr("cs", noun)) } else { format!("{n} {form}") }; + assert_eq!(count_label("cs", noun, n), expected, "{noun}"); + } } - // The status bar passes the singular noun for one error. - assert_eq!(count_label("cs", "error", 1), "1 chyba"); - assert_eq!(count_label("cs", "pages", 1), "1 stránka"); - assert_eq!(count_label("cs", "pages", 3), "3 stránky"); - assert_eq!(count_label("cs", "pages", 5), "5 stránek"); - assert_eq!(count_label("cs", "stories", 2), "2 články"); - assert_eq!(count_label("cs", "items", 1), "1 položka"); - assert_eq!(count_label("cs", "objects", 7), "7 objektů"); - assert_eq!(count_label("cs", "warnings", 3), "3 varování"); - assert_eq!(count_label("cs", "layer", 1), "1 vrstva"); - assert_eq!(count_label("cs", "layers", 3), "3 vrstvy"); - assert_eq!(count_label("cs", "layers", 8), "8 vrstev"); // A noun without plural forms keeps the plain translation. assert_eq!(count_label("cs", "a new noun", 3), "3 a new noun"); - for (noun, forms) in cs::PLURALS { - assert!(forms.iter().all(|form| !form.is_empty()), "{noun}"); - assert!(forms.contains(&tr("cs", noun)), "{noun}: the catalog row is one of the plural forms"); - } + } +} + +/// `name` lowercased with the Latin accents that occur in language names folded away, so a +/// byte-wise sort reads alphabetically. Other scripts are left as they are. +fn name_sort_key(name: &str) -> String { + name.chars() + .flat_map(char::to_lowercase) + .map(|c| match c { + 'à'..='å' | 'ā' | 'ă' | 'ą' => 'a', + 'ç' | 'ć' | 'č' => 'c', + 'è'..='ë' | 'ē' | 'ě' | 'ę' | 'ế' | 'ệ' => 'e', + 'ì'..='ï' | 'ī' => 'i', + 'ñ' | 'ń' | 'ň' => 'n', + 'ò'..='ö' | 'ø' | 'ő' => 'o', + 'ś' | 'š' => 's', + 'ù'..='ü' | 'ū' | 'ů' | 'ű' => 'u', + 'ý' | 'ÿ' => 'y', + 'ź' | 'ż' | 'ž' => 'z', + c => c, + }) + .collect() +} + +#[cfg(test)] +mod picker_order_tests { + use super::*; + + #[test] + fn picker_lists_english_first_then_names_alphabetically() { + let order = picker_languages(); + assert_eq!(order.first(), Some(&("", "English"))); + assert_eq!(order.len(), LANGUAGES.len()); + let keys: Vec = order[1..].iter().map(|(_, name)| name_sort_key(name)).collect(); + assert!(keys.windows(2).all(|w| w[0] <= w[1]), "{keys:?}"); + let pos = |code: &str| order.iter().position(|(c, _)| *c == code).unwrap_or(usize::MAX); + assert!(pos("cs") < pos("de"), "Čeština sorts under C"); + assert!(pos("pl") < pos("ru"), "Latin names come before Cyrillic"); + assert!(pos("ru") < pos("ja"), "Cyrillic names come before CJK"); } } diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 31f7fb70d..49fd3046e 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,43 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Restart Numbering", "إعادة الترقيم"), + ("Continue Numbering", "متابعة الترقيم"), + ("Keep Options…", "خيارات الإبقاء…"), + ("Space Between Paragraphs Using Same Style", "المسافة بين الفقرات ذات النمط نفسه"), + ("Ignore", "تجاهل"), + ("Do Not Align to Baseline Grid", "عدم المحاذاة إلى شبكة خط الأساس"), + ("Only Align First Line to Grid", "محاذاة السطر الأول فقط إلى الشبكة"), + ("Balance Ragged Lines", "موازنة الأسطر غير المتساوية"), + ("Drop Caps and Nested Styles…", "الأحرف الاستهلالية والأنماط المتداخلة…"), + ("GREP Styles…", "أنماط GREP…"), + ("Restart/Continue Numbering", "إعادة/متابعة الترقيم"), + ("Convert Bullets and Numbering to Text", "تحويل التعداد النقطي والترقيم إلى نص"), + ("Hyphenation…", "الواصلة…"), + ("Hyphenation Settings", "إعدادات الواصلة"), + ("hyphens", "واصلات"), + ("Hyphenation Zone:", "منطقة الواصلة:"), + ("Better Spacing", "تباعد أفضل"), + ("Fewer Hyphens", "واصلات أقل"), + ("Hyphenate Capitalized Words", "وصل الكلمات ذات الأحرف الكبيرة"), + ("Hyphenate Last Word", "وصل الكلمة الأخيرة"), + ("Hyphenate Across Column", "الوصل عبر الأعمدة"), + ("Character Formatting Controls", "عناصر تحكم تنسيق الأحرف"), + ("Paragraph Formatting Controls", "عناصر تحكم تنسيق الفقرات"), + ("Font Size", "حجم الخط"), + ("Vertical Scale", "المقياس الرأسي"), + ("Horizontal Scale", "المقياس الأفقي"), + ("Baseline Shift", "إزاحة خط الأساس"), + ("Skew (False Italic)", "إمالة (مائل زائف)"), + ("Left Indent", "مسافة بادئة يسرى"), + ("Right Indent", "مسافة بادئة يمنى"), + ("First Line Left Indent", "مسافة بادئة يسرى للسطر الأول"), + ("Last Line Right Indent", "مسافة بادئة يمنى للسطر الأخير"), + ("Drop Cap Number of Lines", "عدد أسطر الحرف الاستهلالي"), + ("Drop Cap One or More Characters", "حرف استهلالي واحد أو أكثر"), + ("Align to Baseline Grid", "محاذاة إلى شبكة خط الأساس"), + ("Apply Bullets", "تطبيق التعداد النقطي"), + ("Apply Numbers", "تطبيق الترقيم"), ("Paste Vector Art", "لصق رسم متجه"), ("Baseline Options", "خيارات خط الأساس"), ("Fixed Number", "عدد ثابت"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 4d41c30dc..6f47fdaff 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,43 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("Restart Numbering", "Začít číslování znovu"), + ("Continue Numbering", "Pokračovat v číslování"), + ("Keep Options…", "Volby zachování…"), + ("Space Between Paragraphs Using Same Style", "Mezera mezi odstavci se stejným stylem"), + ("Ignore", "Ignorovat"), + ("Do Not Align to Baseline Grid", "Nezarovnávat na mřížku účaří"), + ("Only Align First Line to Grid", "Zarovnat na mřížku jen první řádek"), + ("Balance Ragged Lines", "Vyrovnat nezarovnané řádky"), + ("Drop Caps and Nested Styles…", "Iniciály a vnořené styly…"), + ("GREP Styles…", "Styly GREP…"), + ("Restart/Continue Numbering", "Začít znovu/pokračovat v číslování"), + ("Convert Bullets and Numbering to Text", "Převést odrážky a číslování na text"), + ("Hyphenation…", "Dělení slov…"), + ("Hyphenation Settings", "Nastavení dělení slov"), + ("hyphens", "spojovníky"), + ("Hyphenation Zone:", "Zóna dělení:"), + ("Better Spacing", "Lepší mezery"), + ("Fewer Hyphens", "Méně spojovníků"), + ("Hyphenate Capitalized Words", "Dělit slova s velkým počátečním písmenem"), + ("Hyphenate Last Word", "Dělit poslední slovo"), + ("Hyphenate Across Column", "Dělit přes sloupce"), + ("Character Formatting Controls", "Ovládací prvky formátování znaků"), + ("Paragraph Formatting Controls", "Ovládací prvky formátování odstavců"), + ("Font Size", "Velikost písma"), + ("Vertical Scale", "Svislá změna velikosti"), + ("Horizontal Scale", "Vodorovná změna velikosti"), + ("Baseline Shift", "Posun účaří"), + ("Skew (False Italic)", "Zkosení (nepravá kurzíva)"), + ("Left Indent", "Odsazení zleva"), + ("Right Indent", "Odsazení zprava"), + ("First Line Left Indent", "Odsazení prvního řádku zleva"), + ("Last Line Right Indent", "Odsazení posledního řádku zprava"), + ("Drop Cap Number of Lines", "Počet řádků iniciály"), + ("Drop Cap One or More Characters", "Iniciála: jeden nebo více znaků"), + ("Align to Baseline Grid", "Zarovnat na mřížku účaří"), + ("Apply Bullets", "Použít odrážky"), + ("Apply Numbers", "Použít číslování"), ("Reverse Path", "Obrátit cestu"), ("Set Anchor", "Nastavit kotvu"), ("Clear", "Vymazat"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 4f4177cbd..97b48316b 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -31,6 +31,29 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Ausschneiden"), ("Copy", "Kopieren"), ("Paste", "Einfügen"), + ("Restart Numbering", "Nummerierung neu beginnen"), + ("Continue Numbering", "Nummerierung fortführen"), + ("Keep Options…", "Umbruchoptionen …"), + ("Space Between Paragraphs Using Same Style", "Abstand zwischen Absätzen mit gleichem Format"), + ("Ignore", "Ignorieren"), + ("Do Not Align to Baseline Grid", "Nicht am Grundlinienraster ausrichten"), + ("Only Align First Line to Grid", "Nur erste Zeile am Raster ausrichten"), + ("Balance Ragged Lines", "Unausgeglichene Zeilen ausgleichen"), + ("Drop Caps and Nested Styles…", "Initialen und verschachtelte Formate …"), + ("GREP Styles…", "GREP-Stile …"), + ("Restart/Continue Numbering", "Nummerierung neu beginnen/fortführen"), + ("Convert Bullets and Numbering to Text", "Aufzählungszeichen und Nummerierung in Text konvertieren"), + ("Hyphenation…", "Silbentrennung …"), + ("Hyphenation Settings", "Silbentrennungseinstellungen"), + ("hyphens", "Trennstriche"), + ("Hyphenation Zone:", "Silbentrennungsbereich:"), + ("Better Spacing", "Bessere Abstände"), + ("Fewer Hyphens", "Weniger Trennstriche"), + ("Hyphenate Capitalized Words", "Großgeschriebene Wörter trennen"), + ("Hyphenate Last Word", "Letztes Wort trennen"), + ("Hyphenate Across Column", "Über Spalten trennen"), + ("Apply Bullets", "Aufzählungszeichen anwenden"), + ("Apply Numbers", "Nummerierung anwenden"), ("Inches Decimal", "Zoll dezimal"), ("Back", "Zurück"), ("Warning", "Warnung"), @@ -2075,55 +2098,3 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Update Data Source", "Datenquelle aktualisieren"), ("Validate from Root Element", "Ab Stammelement prüfen"), ]; - -#[cfg(test)] -mod tests { - use std::collections::HashSet; - - use super::TABLE; - use crate::i18n::tr; - - fn placeholders(text: &str) -> Vec<&str> { - let mut found = Vec::new(); - let mut rest = text; - while let Some(start) = rest.find('{') { - let Some(len) = rest[start..].find('}') else { break }; - found.push(&rest[start..start + len + 1]); - rest = &rest[start + len + 1..]; - } - found.sort_unstable(); - found - } - - #[test] - fn rows_are_unique_and_complete() { - let mut seen = HashSet::new(); - for (en, de) in TABLE { - assert!(seen.insert(*en), "duplicate {en}"); - assert!(!de.is_empty(), "{en}"); - } - } - - #[test] - fn rows_keep_placeholders_padding_and_endings() { - let ellipsis = |text: &str| text.trim_end().ends_with('…') || text.trim_end().ends_with("..."); - for (en, de) in TABLE { - assert_eq!(placeholders(en), placeholders(de), "placeholders of {en}"); - assert_eq!(en.len() - en.trim_start().len(), de.len() - de.trim_start().len(), "leading space of {en}"); - assert_eq!(en.len() - en.trim_end().len(), de.len() - de.trim_end().len(), "trailing space of {en}"); - assert_eq!(ellipsis(en), ellipsis(de), "ellipsis of {en}"); - // The numbering samples (`a, b, c, d...`) stay as they are; everything else uses `…`. - assert!(!de.contains("...") || de == en, "ASCII ellipsis in {en}"); - assert_eq!(en.trim_end().ends_with(':'), de.trim_end().ends_with(':'), "colon of {en}"); - } - } - - #[test] - fn lookup_uses_this_table_and_leaves_unknown_text_alone() { - assert_eq!(tr("de", "File"), "Datei"); - assert_eq!(tr("de", "Cancel"), "Abbrechen"); - assert_eq!(tr("de", "{count} glyphs — click to insert at the text cursor").matches("{count}").count(), 1); - let unknown = String::from("A user-defined untranslated label"); - assert!(std::ptr::eq(tr("de", &unknown), unknown.as_str())); - } -} diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 681230d84..d19a5b56e 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -337,8 +337,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Sixth Space", "Sixième d'espace"), ("Smart Guide Options", "Options des repères commentés"), ("Smooth Tool", "Outil Lissage"), - ("Space After", "Espace après"), - ("Space Before", "Espace avant"), ("Start Page #", "N° de la première page"), ("Step and Repeat", "Exécuter et répéter"), ("Stop Preview", "Arrêter l'aperçu"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 755272fe5..4a3656699 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,43 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Restart Numbering", "Riavvia numerazione"), + ("Continue Numbering", "Continua numerazione"), + ("Keep Options…", "Opzioni di separazione…"), + ("Space Between Paragraphs Using Same Style", "Spazio tra paragrafi con lo stesso stile"), + ("Ignore", "Ignora"), + ("Do Not Align to Baseline Grid", "Non allineare alla griglia linee di base"), + ("Only Align First Line to Grid", "Allinea solo la prima riga alla griglia"), + ("Balance Ragged Lines", "Bilancia righe irregolari"), + ("Drop Caps and Nested Styles…", "Capilettera e stili nidificati…"), + ("GREP Styles…", "Stili GREP…"), + ("Restart/Continue Numbering", "Riavvia/continua numerazione"), + ("Convert Bullets and Numbering to Text", "Converti elenchi puntati e numerati in testo"), + ("Hyphenation…", "Sillabazione…"), + ("Hyphenation Settings", "Impostazioni sillabazione"), + ("hyphens", "trattini"), + ("Hyphenation Zone:", "Area di sillabazione:"), + ("Better Spacing", "Spaziatura migliore"), + ("Fewer Hyphens", "Meno trattini"), + ("Hyphenate Capitalized Words", "Sillaba parole con iniziale maiuscola"), + ("Hyphenate Last Word", "Sillaba ultima parola"), + ("Hyphenate Across Column", "Sillaba tra colonne"), + ("Character Formatting Controls", "Controlli di formattazione carattere"), + ("Paragraph Formatting Controls", "Controlli di formattazione paragrafo"), + ("Font Size", "Dimensione font"), + ("Vertical Scale", "Scala verticale"), + ("Horizontal Scale", "Scala orizzontale"), + ("Baseline Shift", "Spostamento linea di base"), + ("Skew (False Italic)", "Inclinazione (falso corsivo)"), + ("Left Indent", "Rientro sinistro"), + ("Right Indent", "Rientro destro"), + ("First Line Left Indent", "Rientro sinistro prima riga"), + ("Last Line Right Indent", "Rientro destro ultima riga"), + ("Drop Cap Number of Lines", "Capolettera: numero di righe"), + ("Drop Cap One or More Characters", "Capolettera: uno o più caratteri"), + ("Align to Baseline Grid", "Allinea alla griglia di base"), + ("Apply Bullets", "Applica punti elenco"), + ("Apply Numbers", "Applica numeri"), ("Paste Vector Art", "Incolla grafica vettoriale"), ("Baseline Options", "Opzioni linea di base"), ("Fixed Number", "Numero fisso"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index e23ef15c7..6180858af 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,29 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("Restart Numbering", "番号の振り直し"), + ("Continue Numbering", "番号の継続"), + ("Keep Options…", "段落分離禁止オプション…"), + ("Space Between Paragraphs Using Same Style", "同じスタイルの段落間のスペース"), + ("Ignore", "無視"), + ("Do Not Align to Baseline Grid", "ベースライングリッドに揃えない"), + ("Only Align First Line to Grid", "最初の行のみグリッドに揃える"), + ("Balance Ragged Lines", "行末を調整"), + ("Drop Caps and Nested Styles…", "ドロップキャップと先頭文字スタイル…"), + ("GREP Styles…", "正規表現スタイル…"), + ("Restart/Continue Numbering", "番号の振り直し/継続"), + ("Convert Bullets and Numbering to Text", "箇条書きと段落番号をテキストに変換"), + ("Hyphenation…", "ハイフネーション…"), + ("Hyphenation Settings", "ハイフネーション設定"), + ("hyphens", "ハイフン"), + ("Hyphenation Zone:", "ハイフネーション領域 :"), + ("Better Spacing", "スペースを優先"), + ("Fewer Hyphens", "ハイフンを減らす"), + ("Hyphenate Capitalized Words", "大文字で始まる単語をハイフネーション"), + ("Hyphenate Last Word", "最後の単語をハイフネーション"), + ("Hyphenate Across Column", "段をまたいでハイフネーション"), + ("Apply Bullets", "箇条書きを適用"), + ("Apply Numbers", "段落番号を適用"), ("Paste Vector Art", "ベクトルアートをペースト"), ("Baseline Options", "ベースラインオプション"), ("Column Rules", "段落境界線"), @@ -2124,55 +2147,3 @@ pub(super) const TABLE: &[(&str, &str)] = &[ /// Captions whose Japanese depends on where they appear: (English, context, Japanese). pub(super) const CONTEXT: &[(&str, &str, &str)] = &[("Span Columns", "paragraphLayout", "段抜き"), ("Columns:", "grid", "列:")]; - -#[cfg(test)] -mod tests { - use std::collections::HashSet; - - use super::TABLE; - use crate::i18n::tr; - - fn placeholders(text: &str) -> Vec<&str> { - let mut found = Vec::new(); - let mut rest = text; - while let Some(start) = rest.find('{') { - let Some(len) = rest[start..].find('}') else { break }; - found.push(&rest[start..start + len + 1]); - rest = &rest[start + len + 1..]; - } - found.sort_unstable(); - found - } - - #[test] - fn rows_are_unique_and_complete() { - let mut seen = HashSet::new(); - for (en, ja) in TABLE { - assert!(seen.insert(*en), "duplicate {en}"); - assert!(!ja.is_empty(), "{en}"); - } - } - - #[test] - fn rows_keep_placeholders_padding_and_endings() { - let ellipsis = |text: &str| text.trim_end().ends_with('…') || text.trim_end().ends_with("..."); - for (en, ja) in TABLE { - assert_eq!(placeholders(en), placeholders(ja), "placeholders of {en}"); - assert_eq!(en.len() - en.trim_start().len(), ja.len() - ja.trim_start().len(), "leading space of {en}"); - assert_eq!(en.len() - en.trim_end().len(), ja.len() - ja.trim_end().len(), "trailing space of {en}"); - assert_eq!(ellipsis(en), ellipsis(ja), "ellipsis of {en}"); - // The numbering samples (`a, b, c, d...`) stay as they are; everything else uses `…`. - assert!(!ja.contains("...") || ja == en, "ASCII ellipsis in {en}"); - assert_eq!(en.trim_end().ends_with(':'), ja.trim_end().ends_with(':'), "colon of {en}"); - } - } - - #[test] - fn lookup_uses_this_table_and_leaves_unknown_text_alone() { - assert_eq!(tr("ja", "File"), "ファイル"); - assert_eq!(tr("ja", "Cancel"), "キャンセル"); - assert_eq!(tr("ja", "{count} glyphs — click to insert at the text cursor").matches("{count}").count(), 1); - let unknown = String::from("A user-defined untranslated label"); - assert!(std::ptr::eq(tr("ja", &unknown), unknown.as_str())); - } -} diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 0300b62d7..4767b6c30 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,43 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Restart Numbering", "ნუმერაციის თავიდან დაწყება"), + ("Continue Numbering", "ნუმერაციის გაგრძელება"), + ("Keep Options…", "შენარჩუნების პარამეტრები…"), + ("Space Between Paragraphs Using Same Style", "დაშორება ერთი სტილის აბზაცებს შორის"), + ("Ignore", "უგულებელყოფა"), + ("Do Not Align to Baseline Grid", "საბაზისო ბადეზე არ გასწორდეს"), + ("Only Align First Line to Grid", "ბადეზე მხოლოდ პირველი ხაზის გასწორება"), + ("Balance Ragged Lines", "უსწორმასწორო ხაზების დაბალანსება"), + ("Drop Caps and Nested Styles…", "საწყისი ასოები და ჩადგმული სტილები…"), + ("GREP Styles…", "GREP სტილები…"), + ("Restart/Continue Numbering", "ნუმერაციის თავიდან დაწყება/გაგრძელება"), + ("Convert Bullets and Numbering to Text", "მარკერებისა და ნუმერაციის ტექსტად გარდაქმნა"), + ("Hyphenation…", "დამარცვლა…"), + ("Hyphenation Settings", "დამარცვლის პარამეტრები"), + ("hyphens", "დეფისი"), + ("Hyphenation Zone:", "დამარცვლის ზონა:"), + ("Better Spacing", "უკეთესი დაშორება"), + ("Fewer Hyphens", "ნაკლები დეფისი"), + ("Hyphenate Capitalized Words", "მთავრული სიტყვების დამარცვლა"), + ("Hyphenate Last Word", "ბოლო სიტყვის დამარცვლა"), + ("Hyphenate Across Column", "სვეტებს შორის დამარცვლა"), + ("Character Formatting Controls", "სიმბოლოების ფორმატირების მართვა"), + ("Paragraph Formatting Controls", "აბზაცების ფორმატირების მართვა"), + ("Font Size", "შრიფტის ზომა"), + ("Vertical Scale", "ვერტიკალური მასშტაბი"), + ("Horizontal Scale", "ჰორიზონტალური მასშტაბი"), + ("Baseline Shift", "საბაზო ხაზის წანაცვლება"), + ("Skew (False Italic)", "დახრა (ცრუ კურსივი)"), + ("Left Indent", "მარცხენა შეწევა"), + ("Right Indent", "მარჯვენა შეწევა"), + ("First Line Left Indent", "პირველი სტრიქონის მარცხენა შეწევა"), + ("Last Line Right Indent", "ბოლო სტრიქონის მარჯვენა შეწევა"), + ("Drop Cap Number of Lines", "ინიციალის სტრიქონების რაოდენობა"), + ("Drop Cap One or More Characters", "ინიციალი: ერთი ან მეტი სიმბოლო"), + ("Align to Baseline Grid", "საბაზო ბადეზე სწორება"), + ("Apply Bullets", "მარკერების გამოყენება"), + ("Apply Numbers", "ნუმერაციის გამოყენება"), ("Inches Decimal", "ინჩები (ათობითი)"), ("Back", "უკან"), ("Warning", "გაფრთხილება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 7d1d12c67..bf0c7600b 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,43 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("Restart Numbering", "Zacznij numerowanie od nowa"), + ("Continue Numbering", "Kontynuuj numerowanie"), + ("Keep Options…", "Opcje utrzymywania…"), + ("Space Between Paragraphs Using Same Style", "Odstęp między akapitami o tym samym stylu"), + ("Ignore", "Ignoruj"), + ("Do Not Align to Baseline Grid", "Nie wyrównuj do siatki linii bazowych"), + ("Only Align First Line to Grid", "Wyrównaj do siatki tylko pierwszy wiersz"), + ("Balance Ragged Lines", "Wyrównaj nierówne wiersze"), + ("Drop Caps and Nested Styles…", "Inicjały i style zagnieżdżone…"), + ("GREP Styles…", "Style GREP…"), + ("Restart/Continue Numbering", "Zacznij od nowa/kontynuuj numerowanie"), + ("Convert Bullets and Numbering to Text", "Konwertuj wypunktowanie i numerowanie na tekst"), + ("Hyphenation…", "Dzielenie wyrazów…"), + ("Hyphenation Settings", "Ustawienia dzielenia wyrazów"), + ("hyphens", "dywizy"), + ("Hyphenation Zone:", "Strefa dzielenia:"), + ("Better Spacing", "Lepsze odstępy"), + ("Fewer Hyphens", "Mniej dywizów"), + ("Hyphenate Capitalized Words", "Dziel wyrazy pisane wielką literą"), + ("Hyphenate Last Word", "Dziel ostatni wyraz"), + ("Hyphenate Across Column", "Dziel między łamami"), + ("Character Formatting Controls", "Elementy formatowania znaków"), + ("Paragraph Formatting Controls", "Elementy formatowania akapitów"), + ("Font Size", "Rozmiar czcionki"), + ("Vertical Scale", "Skala pionowa"), + ("Horizontal Scale", "Skala pozioma"), + ("Baseline Shift", "Przesunięcie linii bazowej"), + ("Skew (False Italic)", "Pochylenie (fałszywa kursywa)"), + ("Left Indent", "Wcięcie z lewej"), + ("Right Indent", "Wcięcie z prawej"), + ("First Line Left Indent", "Wcięcie pierwszego wiersza z lewej"), + ("Last Line Right Indent", "Wcięcie ostatniego wiersza z prawej"), + ("Drop Cap Number of Lines", "Inicjał: liczba wierszy"), + ("Drop Cap One or More Characters", "Inicjał: jeden lub więcej znaków"), + ("Align to Baseline Grid", "Wyrównaj do siatki linii bazowych"), + ("Apply Bullets", "Zastosuj wypunktowanie"), + ("Apply Numbers", "Zastosuj numerowanie"), ("Clear", "Wyczyść"), ("Duplicate", "Powiel"), ("Select All", "Zaznacz wszystko"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index f552e9a63..59c4f2e23 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,43 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Restart Numbering", "Reiniciar numeração"), + ("Continue Numbering", "Continuar numeração"), + ("Keep Options…", "Opções de manutenção…"), + ("Space Between Paragraphs Using Same Style", "Espaço entre parágrafos com o mesmo estilo"), + ("Ignore", "Ignorar"), + ("Do Not Align to Baseline Grid", "Não alinhar à grade de linha de base"), + ("Only Align First Line to Grid", "Alinhar somente a primeira linha à grade"), + ("Balance Ragged Lines", "Equilibrar linhas irregulares"), + ("Drop Caps and Nested Styles…", "Capitulares e estilos aninhados…"), + ("GREP Styles…", "Estilos GREP…"), + ("Restart/Continue Numbering", "Reiniciar/continuar numeração"), + ("Convert Bullets and Numbering to Text", "Converter marcadores e numeração em texto"), + ("Hyphenation…", "Hifenização…"), + ("Hyphenation Settings", "Configurações de hifenização"), + ("hyphens", "hifens"), + ("Hyphenation Zone:", "Zona de hifenização:"), + ("Better Spacing", "Melhor espaçamento"), + ("Fewer Hyphens", "Menos hifens"), + ("Hyphenate Capitalized Words", "Hifenizar palavras iniciadas por maiúscula"), + ("Hyphenate Last Word", "Hifenizar última palavra"), + ("Hyphenate Across Column", "Hifenizar entre colunas"), + ("Character Formatting Controls", "Controles de formatação de caracteres"), + ("Paragraph Formatting Controls", "Controles de formatação de parágrafos"), + ("Font Size", "Tamanho da fonte"), + ("Vertical Scale", "Escala vertical"), + ("Horizontal Scale", "Escala horizontal"), + ("Baseline Shift", "Deslocamento da linha de base"), + ("Skew (False Italic)", "Inclinação (falso itálico)"), + ("Left Indent", "Recuo à esquerda"), + ("Right Indent", "Recuo à direita"), + ("First Line Left Indent", "Recuo à esquerda da primeira linha"), + ("Last Line Right Indent", "Recuo à direita da última linha"), + ("Drop Cap Number of Lines", "Capitular: número de linhas"), + ("Drop Cap One or More Characters", "Capitular: um ou mais caracteres"), + ("Align to Baseline Grid", "Alinhar à grade de linha de base"), + ("Apply Bullets", "Aplicar marcadores"), + ("Apply Numbers", "Aplicar números"), ("Paste Vector Art", "Colar arte vetorial"), ("Baseline Options", "Opções de linha de base"), ("Fixed Number", "Número fixo"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 3f6ef1be0..412faea7e 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,43 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("Restart Numbering", "Начать нумерацию заново"), + ("Continue Numbering", "Продолжить нумерацию"), + ("Keep Options…", "Параметры удержания…"), + ("Space Between Paragraphs Using Same Style", "Интервал между абзацами одного стиля"), + ("Ignore", "Игнорировать"), + ("Do Not Align to Baseline Grid", "Не выравнивать по базовой сетке"), + ("Only Align First Line to Grid", "Выравнивать по сетке только первую строку"), + ("Balance Ragged Lines", "Сбалансировать рваные строки"), + ("Drop Caps and Nested Styles…", "Буквицы и вложенные стили…"), + ("GREP Styles…", "Стили GREP…"), + ("Restart/Continue Numbering", "Начать заново/продолжить нумерацию"), + ("Convert Bullets and Numbering to Text", "Преобразовать маркеры и нумерацию в текст"), + ("Hyphenation…", "Расстановка переносов…"), + ("Hyphenation Settings", "Параметры расстановки переносов"), + ("hyphens", "дефисов"), + ("Hyphenation Zone:", "Зона переносов:"), + ("Better Spacing", "Лучшие интервалы"), + ("Fewer Hyphens", "Меньше переносов"), + ("Hyphenate Capitalized Words", "Переносить слова с прописной буквы"), + ("Hyphenate Last Word", "Переносить последнее слово"), + ("Hyphenate Across Column", "Переносить между колонками"), + ("Character Formatting Controls", "Элементы управления форматированием символов"), + ("Paragraph Formatting Controls", "Элементы управления форматированием абзацев"), + ("Font Size", "Кегль"), + ("Vertical Scale", "Масштаб по вертикали"), + ("Horizontal Scale", "Масштаб по горизонтали"), + ("Baseline Shift", "Смещение базовой линии"), + ("Skew (False Italic)", "Наклон (ложный курсив)"), + ("Left Indent", "Отступ слева"), + ("Right Indent", "Отступ справа"), + ("First Line Left Indent", "Отступ первой строки слева"), + ("Last Line Right Indent", "Отступ последней строки справа"), + ("Drop Cap Number of Lines", "Буквица: число строк"), + ("Drop Cap One or More Characters", "Буквица: один или несколько символов"), + ("Align to Baseline Grid", "Выровнять по сетке базовых линий"), + ("Apply Bullets", "Применить маркеры"), + ("Apply Numbers", "Применить нумерацию"), ("Clear", "Очистить"), ("Duplicate", "Дублировать"), ("Select All", "Выделить все"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 9c7cc855a..62c594fd1 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,43 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Restart Numbering", "Почати нумерацію знову"), + ("Continue Numbering", "Продовжити нумерацію"), + ("Keep Options…", "Параметри утримання…"), + ("Space Between Paragraphs Using Same Style", "Інтервал між абзацами одного стилю"), + ("Ignore", "Ігнорувати"), + ("Do Not Align to Baseline Grid", "Не вирівнювати за базовою сіткою"), + ("Only Align First Line to Grid", "Вирівнювати за сіткою лише перший рядок"), + ("Balance Ragged Lines", "Збалансувати нерівні рядки"), + ("Drop Caps and Nested Styles…", "Буквиці та вкладені стилі…"), + ("GREP Styles…", "Стилі GREP…"), + ("Restart/Continue Numbering", "Почати знову/продовжити нумерацію"), + ("Convert Bullets and Numbering to Text", "Перетворити маркери й нумерацію на текст"), + ("Hyphenation…", "Перенесення…"), + ("Hyphenation Settings", "Параметри перенесення"), + ("hyphens", "дефісів"), + ("Hyphenation Zone:", "Зона перенесення:"), + ("Better Spacing", "Кращі інтервали"), + ("Fewer Hyphens", "Менше перенесень"), + ("Hyphenate Capitalized Words", "Переносити слова з великої літери"), + ("Hyphenate Last Word", "Переносити останнє слово"), + ("Hyphenate Across Column", "Переносити між колонками"), + ("Character Formatting Controls", "Елементи керування форматуванням символів"), + ("Paragraph Formatting Controls", "Елементи керування форматуванням абзаців"), + ("Font Size", "Кегль"), + ("Vertical Scale", "Масштаб по вертикалі"), + ("Horizontal Scale", "Масштаб по горизонталі"), + ("Baseline Shift", "Зсув базової лінії"), + ("Skew (False Italic)", "Нахил (несправжній курсив)"), + ("Left Indent", "Відступ ліворуч"), + ("Right Indent", "Відступ праворуч"), + ("First Line Left Indent", "Відступ першого рядка ліворуч"), + ("Last Line Right Indent", "Відступ останнього рядка праворуч"), + ("Drop Cap Number of Lines", "Буквиця: кількість рядків"), + ("Drop Cap One or More Characters", "Буквиця: один або кілька символів"), + ("Align to Baseline Grid", "Вирівняти за сіткою базових ліній"), + ("Apply Bullets", "Застосувати маркери"), + ("Apply Numbers", "Застосувати нумерацію"), ("Paste Vector Art", "Вставити векторну графіку"), ("Baseline Options", "Параметри базової лінії"), ("Fixed Number", "Фіксована кількість"), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index a90fe989e..72c37fc83 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -798,6 +798,12 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { pen.line(&[(3.0, v), (5.0, v)]); } } + // Do Not Align to Baseline Grid: ragged lines of text, free of any grid. + "grid-none" => { + for (y, x1) in [(5.0, 17.0), (8.5, 12.0), (12.0, 17.0), (15.5, 14.0)] { + pen.line(&[(3.0, y), (x1, y)]); + } + } "grid-baseline" => { pen.rect(3.0, 3.0, 17.0, 17.0); for y in [6.5, 10.0, 13.5] { @@ -938,6 +944,86 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { pen.line(&[(15.0, 13.5), (17.0, 15.5), (15.0, 17.5)]); } } + "vscale" => { + pen.text(13.0, 10.5, "T", 15.0); + pen.line(&[(4.0, 3.0), (4.0, 17.0)]); + pen.line(&[(2.5, 5.0), (4.0, 3.0), (5.5, 5.0)]); + pen.line(&[(2.5, 15.0), (4.0, 17.0), (5.5, 15.0)]); + } + "hscale" => { + pen.text(10.0, 8.0, "T", 13.0); + pen.line(&[(3.0, 16.5), (17.0, 16.5)]); + pen.line(&[(5.0, 14.5), (3.0, 16.5), (5.0, 18.5)]); + pen.line(&[(15.0, 14.5), (17.0, 16.5), (15.0, 18.5)]); + } + "baseline-shift" => { + pen.text(7.0, 11.0, "A", 13.0); + pen.text(15.5, 5.5, "a", 8.0); + pen.line(&[(15.5, 17.5), (15.5, 10.0)]); + pen.line(&[(13.5, 12.0), (15.5, 10.0), (17.5, 12.0)]); + } + "skew" => { + pen.w = 1.8; + pen.line(&[(7.0, 4.0), (17.0, 4.0)]); + pen.line(&[(12.0, 4.0), (8.0, 17.0)]); + } + "indent-left" | "indent-right" | "indent-first" | "indent-last" => { + // Text lines, and an arrow at the side (or line) indented. + let (x0, x1) = match name { + "indent-left" => (9.0, 18.0), + "indent-right" => (2.0, 11.0), + _ => (2.0, 18.0), + }; + for (k, y) in [4.0, 8.0, 12.0, 16.0].into_iter().enumerate() { + let (a, z) = match (name, k) { + ("indent-first", 0) => (9.0, x1), + ("indent-last", 3) => (x0, 11.0), + _ => (x0, x1), + }; + pen.line(&[(a, y), (z, y)]); + } + let (y, from, to) = match name { + "indent-left" => (10.0, 2.0, 7.0), + "indent-right" => (10.0, 18.0, 13.0), + "indent-first" => (4.0, 2.0, 7.0), + _ => (16.0, 18.0, 13.0), + }; + pen.line(&[(from, y), (to, y)]); + let d = if to > from { -2.0 } else { 2.0 }; + pen.line(&[(to + d, y - 2.0), (to, y), (to + d, y + 2.0)]); + } + "space-before" | "space-after" => { + let before = name == "space-before"; + let (bar, lines) = if before { (3.0, [11.0, 14.5, 18.0]) } else { (17.0, [2.0, 5.5, 9.0]) }; + pen.line(&[(2.0, bar), (18.0, bar)]); + for y in lines { + pen.line(&[(8.0, y), (18.0, y)]); + } + let (a, z) = if before { (5.0, 10.0) } else { (10.0, 15.0) }; + pen.line(&[(4.0, a), (4.0, z)]); + pen.line(&[(2.5, a + 1.5), (4.0, a), (5.5, a + 1.5)]); + pen.line(&[(2.5, z - 1.5), (4.0, z), (5.5, z - 1.5)]); + } + // Space Between Paragraphs Using Same Style: two paragraphs and the space between them. + "space-same" => { + for y in [2.0, 5.0, 15.0, 18.0] { + pen.line(&[(8.0, y), (18.0, y)]); + } + pen.line(&[(4.0, 6.5), (4.0, 13.5)]); + pen.line(&[(2.5, 8.0), (4.0, 6.5), (5.5, 8.0)]); + pen.line(&[(2.5, 12.0), (4.0, 13.5), (5.5, 12.0)]); + } + "drop-lines" | "drop-chars" => { + pen.text(5.5, 8.0, "A", 14.0); + if name == "drop-chars" { + pen.text(13.5, 4.5, "a", 8.0); + } + for (y, x) in [(if name == "drop-chars" { 8.5 } else { 3.5 }, 11.0), (8.5, 11.0), (13.5, 11.0), (17.5, 2.0)] { + if name == "drop-lines" || y > 5.0 { + pen.line(&[(x, y), (18.0, y)]); + } + } + } "corner" => { pen.w = 1.0; for (x, y) in [(3.0, 3.0), (14.0, 3.0), (3.0, 14.0), (14.0, 14.0)] { @@ -975,6 +1061,20 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { pen.line(&[(7.5, *y), (if k == 1 { 14.0 } else { 17.0 }, *y)]); } } + // Paragraph shading: a square with a checked fill (the Control panel's Shading). + "para-shading" => { + pen.w = 1.0; + pen.rect(4.0, 4.0, 16.0, 16.0); + pen.frect(4.0, 4.0, 10.0, 10.0); + pen.frect(10.0, 10.0, 16.0, 16.0); + } + // Paragraph border: a box with its corner drawn heavy (the Control panel's Border). + "para-border" => { + pen.w = 1.0; + pen.rect(4.0, 4.0, 16.0, 16.0); + pen.w = 2.5; + pen.line(&[(5.0, 12.0), (5.0, 5.0), (12.0, 5.0)]); + } "pilcrow-menu" => { pen.text(9.0, 10.0, "¶", 14.0); pen.fill(&[(14.0, 15.0), (17.0, 15.0), (15.5, 17.0)]); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 535c87629..6a60da26e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -71,7 +71,8 @@ pub type Inbox = std::sync::Arc>>; pub struct Services { /// Open-file dialog for a purpose (`open`, `place`) → path. pub pick_open: Option, - /// Save dialog with a suggested name → path. + /// Save dialog with a suggested name → path. The name may start with the folder the dialog + /// opens in (see [`DesignApp::pick_save`]). pub pick_save: Option, pub read: Option, pub write: Option, @@ -223,6 +224,9 @@ pub struct UiState { pub pages_panel: panels::pages::PanelOptions, /// Edit › Color Settings (re-applied at start). pub color_settings: Option, + /// ICC files loaded with Edit › Color Settings › Load Profile… (loaded again at start, before + /// `color_settings`, so a loaded working space stays chosen). + pub color_profiles: Vec, /// View › Proof Colors and View › Proof Setup. pub proof_colors: bool, pub proof_setup: designcraft_color::cms::ProofSetup, @@ -236,6 +240,8 @@ pub struct UiState { pub flattener: String, /// File › Export PDF… options (persisted; see `PdfExportSettings`). pub pdf_export: PdfExportSettings, + /// The Preview checkbox of Paragraph/Character Style Options, remembered between openings. + pub style_preview: bool, /// View › Separations Preview: a process plate (0–3) and/or an ink limit (total, 0–4). #[serde(skip)] pub separation: Option, @@ -285,6 +291,10 @@ pub struct UiState { pub custom_workspaces: Vec, /// File › New › Document › Recent: the settings of the last documents made, newest first. pub recent_new_documents: Vec, + /// The folder of the last file saved or exported: where the save dialog starts for a + /// document never saved. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_save_folder: Option, /// The font menus' sample text (a key of `panels::FONT_SAMPLES`) and size (0–2). pub font_sample: String, pub font_preview_size: u8, @@ -383,12 +393,14 @@ impl Default for UiState { pages_parents_height: None, pages_panel: Default::default(), color_settings: None, + color_profiles: Vec::new(), proof_colors: false, proof_setup: Default::default(), dynamic_spelling: false, language: String::new(), flattener: String::new(), pdf_export: PdfExportSettings::default(), + style_preview: false, separation: None, ink_limit: None, display_quality: designcraft_render::DisplayQuality::High, @@ -412,6 +424,7 @@ impl Default for UiState { workspace: "Essentials".into(), custom_workspaces: Vec::new(), recent_new_documents: Vec::new(), + last_save_folder: None, font_sample: "selected".into(), font_preview_size: 1, new_document_preview: false, @@ -751,6 +764,32 @@ impl DesignApp { self.ui.status = s.into(); } + /// The host's save dialog for `name`, started beside the active document once it has been + /// saved, else in the folder of the last file saved or exported (#1653). The answer's folder + /// is remembered for the next one. + pub(crate) fn pick_save(&mut self, name: &str) -> Option { + use std::path::{Path, PathBuf}; + let folder_of = |path: &str| Path::new(path).parent().filter(|f| !f.as_os_str().is_empty()).map(Path::to_path_buf); + let folder = self + .session + .active() + .and_then(|d| d.path.as_deref()) + .and_then(folder_of) + .or_else(|| self.ui.last_save_folder.as_ref().map(PathBuf::from)); + // Only the last part of a name goes after the folder: a title may hold a slash, and an + // empty name would make the folder itself the suggested file. + let file = Path::new(name).file_name(); + let suggested = match (folder, file) { + (Some(f), Some(file)) => f.join(file).to_string_lossy().to_string(), + _ => name.to_string(), + }; + let picked = self.services.pick_save.as_mut().and_then(|f| f(&suggested))?; + if let Some(folder) = folder_of(&picked) { + self.ui.last_save_folder = Some(folder.to_string_lossy().to_string()); + } + Some(picked) + } + /// Did an IME composition end outside the IME this frame (a click, a tool switch)? egui's /// `should_interrupt_composition` toggles winit's IME, which doesn't reach the macOS input /// context: it keeps the marked text and types it again into the next composition, so the @@ -1032,7 +1071,7 @@ impl DesignApp { } } let Some(inbox) = self.services.inbox.clone() else { return }; - let files = std::mem::take(&mut *inbox.lock().unwrap_or_else(|e| e.into_inner())); + let files = std::mem::take(&mut *inbox.lock().unwrap_or_else(std::sync::PoisonError::into_inner)); for file in files { if file.request.purpose == "shortcuts" { shortcut_import::import_delivered(self, &file.bytes); @@ -1106,8 +1145,22 @@ impl DesignApp { self.window_maximized = ctx.input(|i| i.viewport().maximized.unwrap_or(false)); if !self.color_applied { self.color_applied = true; + for path in self.ui.color_profiles.clone() { + if let Err(e) = self.session.execute("color.loadProfile", &json!({"path": path})) { + log::warn!("Color Settings: {e}"); + self.status_error("Color Settings", e); + } + } if let Some(cs) = self.ui.color_settings.clone() { - let _ = designcraft_color::cms::set_active(&cs.with_known_profiles()); + let known = cs.with_known_profiles(); + for (saved, used) in [(&cs.rgb, &known.rgb), (&cs.cmyk, &known.cmyk)] { + if saved != used { + let e = format!("saved profile {saved} is not available; using {used}"); + log::warn!("Color Settings: {e}"); + self.status_error("Color Settings", e); + } + } + let _ = designcraft_color::cms::set_active(&known); } } let scale = self.ui.ui_scale.clamp(0.5, 3.0); @@ -1834,6 +1887,70 @@ mod tests { assert_eq!(app.view_info().unit, Unit::Inches); } + /// Save and Export PDF dialogs started in the home folder (#1653): they start beside a saved + /// document, and for one never saved in the folder of the last save or export. + #[test] + fn save_dialogs_start_beside_the_document_or_in_the_last_folder() { + use std::{cell::RefCell, rc::Rc}; + let home = tempfile::tempdir().unwrap(); + let elsewhere = tempfile::tempdir().unwrap(); + let asked: Rc>> = Rc::default(); + let answer: Rc>> = Rc::default(); + let mut app = DesignApp::new(designcraft_engine::Session::new(), Services::default()); + let (seen, reply) = (asked.clone(), answer.clone()); + app.services.pick_save = Some(Box::new(move |name: &str| { + seen.borrow_mut().push(name.to_string()); + reply.borrow().clone() + })); + app.services.write = Some(Box::new(|_: &str, _: &[u8]| Ok(()))); + let last = |asked: &Rc>>| asked.borrow().last().cloned().unwrap_or_default(); + let export_pdf = |app: &mut DesignApp| { + app.ui.dialog = Some(dialogs::Dialog::new("pdfExport", json!({}))); + app.ui.dialog.as_mut().unwrap().fields.insert("title".into(), json!("Report")); + dialogs::confirm(app).unwrap(); + }; + + app.run("file.new", json!({})).unwrap(); + let untitled = format!("{}.designcraft", app.session.active().unwrap().doc.title); + app.run("app.saveDialog", json!({})).unwrap(); + assert_eq!(last(&asked), untitled, "never saved, nothing remembered: the system's own folder"); + + let saved = home.path().join("Brochure.designcraft"); + *answer.borrow_mut() = Some(saved.to_string_lossy().to_string()); + app.run("app.saveDialog", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().path.as_deref(), Some(&*saved.to_string_lossy())); + // An export into another folder becomes the last folder used. + *answer.borrow_mut() = Some(elsewhere.path().join("Proof.pdf").to_string_lossy().to_string()); + export_pdf(&mut app); + *answer.borrow_mut() = None; + export_pdf(&mut app); + assert_eq!(last(&asked), home.path().join("Report.pdf").to_string_lossy(), "a saved document exports beside itself"); + + app.run("file.new", json!({})).unwrap(); + let untitled = format!("{}.designcraft", app.session.active().unwrap().doc.title); + app.run("app.saveDialog", json!({})).unwrap(); + assert_eq!(last(&asked), elsewhere.path().join(untitled).to_string_lossy(), "a new document starts in the last folder used"); + } + + /// No name (Save with no document) suggested the remembered folder itself as the file, and a + /// title with a slash moved the suggestion out of the document's folder. + #[test] + fn save_dialog_suggestions_keep_the_folder_and_only_a_file_name() { + use std::{cell::RefCell, rc::Rc}; + let folder = tempfile::tempdir().unwrap(); + let asked: Rc>> = Rc::default(); + let mut app = DesignApp::new(designcraft_engine::Session::new(), Services::default()); + let seen = asked.clone(); + app.services.pick_save = Some(Box::new(move |name: &str| { + seen.borrow_mut().push(name.to_string()); + None + })); + app.ui.last_save_folder = Some(folder.path().join("Docs").to_string_lossy().to_string()); + app.pick_save(""); + app.pick_save("Q1/Q2.pdf"); + assert_eq!(*asked.borrow(), ["".to_string(), folder.path().join("Docs").join("Q2.pdf").to_string_lossy().to_string()]); + } + /// An app with one document that has an unsaved edit. fn app_with_unsaved_document() -> DesignApp { let mut app = DesignApp::new(designcraft_engine::Session::new(), Services::default()); @@ -2929,6 +3046,19 @@ mod browser_import_tests { assert_eq!(pixels.data().iter().filter(|p| p.r > 240 && p.g < 10 && p.b < 10).count(), 0); } + #[test] + fn pdf_options_refuse_a_page_range_instead_of_placing_page_one() { + for spec in ["4-5", "2,5", "2-3", "2.5", "-3", "0", "nan", "inf"] { + let mut app = app(); + let request = app.import_request("place"); + deliver(&mut app, request, "three.pdf", pdf()); + app.ui.dialog.as_mut().unwrap().fields.insert("page".into(), json!(spec)); + assert!(dialogs::confirm(&mut app).is_err(), "{spec}"); + assert!(app.session.doc().unwrap().doc.assets.is_empty(), "{spec}"); + assert_eq!(app.ui.dialog.as_ref().unwrap().id, "pdfImport", "{spec}"); + } + } + #[test] fn place_loads_the_cursor_unless_a_frame_can_take_the_graphic() { let mut app = app(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 87537abd7..75053ef1b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -114,7 +114,21 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ "{tab?: border|shading} — border and shading of the selected paragraphs (type.para)", ), ("app.spanColumnsDialog", "Span Columns…", None, "{} — span or split columns of the selected paragraphs (type.para)"), + ("app.keepOptionsDialog", "Keep Options…", None, "{} — Keep with Next and Keep Lines Together of the selected paragraphs (type.para)"), + ( + "app.nestedStylesDialog", + "Drop Caps and Nested Styles…", + None, + "{} — drop cap, nested styles and nested line styles of the selected paragraphs (type.para)", + ), + ("app.grepStylesDialog", "GREP Styles…", None, "{} — GREP styles of the selected paragraphs (type.para)"), ("app.bulletsNumberingDialog", "Bullets and Numbering…", None, "{} — bullets or numbers of the selected paragraphs (type.para)"), + ( + "app.hyphenationDialog", + "Hyphenation…", + None, + "{} — Hyphenation Settings of the selected paragraphs (type.para): limits, zone, Better Spacing ↔ Fewer Hyphens, Preview", + ), ( "app.justificationDialog", "Justification…", @@ -510,6 +524,9 @@ pub const MENUS: &[(&str, &[&str])] = &[ "<", "-", ">Bulleted & Numbered Lists", + "cmd:list.bullets", + "cmd:list.numbers", + "-", "cmd:list.define", "<", "-", @@ -1071,7 +1088,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { let name = app.session.active().map(|d| format!("{}.designcraft", d.doc.title)).unwrap_or_default(); - if let Some(path) = app.services.pick_save.as_mut().and_then(|f| f(&name)) { + if let Some(path) = app.pick_save(&name) { return Some(app.run("file.saveAs", json!({"path": path}))); } Ok(Value::Null) @@ -1082,7 +1099,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { let name = app.session.active().map(|d| format!("{} copy.designcraft", d.doc.title)).unwrap_or_default(); - if let Some(path) = app.services.pick_save.as_mut().and_then(|f| f(&name)) { + if let Some(path) = app.pick_save(&name) { return Some(app.run("file.saveACopy", json!({"path": path}))); } Ok(Value::Null) @@ -1156,7 +1173,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option export_bytes(app, p, "rtf", "file.exportText"), "app.packageDialog" => { let name = app.session.active().map(|d| format!("{} Folder", d.doc.title)).unwrap_or_default(); - match app.services.pick_save.as_mut().and_then(|f| f(&name)) { + match app.pick_save(&name) { Some(dir) => { let r = app.run("file.package", json!({"dir": dir})); if let Ok(v) = &r { @@ -1217,7 +1234,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { let path = match p.get("path").and_then(Value::as_str) { Some(s) => Some(s.to_string()), - None => app.services.pick_save.as_mut().and_then(|f| f("Swatches.ase")), + None => app.pick_save("Swatches.ase"), }; let Some(path) = path else { return Some(Ok(Value::Null)) }; let r = match app.run("swatch.save", json!({})) { @@ -1280,11 +1297,19 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { + "app.paragraphBordersDialog" + | "app.spanColumnsDialog" + | "app.bulletsNumberingDialog" + | "app.keepOptionsDialog" + | "app.nestedStylesDialog" + | "app.grepStylesDialog" => { let Some(a) = crate::panels::text_attrs(app) else { return Some(Err("select text or a text frame".into())) }; let section = match id { "app.spanColumnsDialog" => "span", "app.bulletsNumberingDialog" => "bullets", + "app.keepOptionsDialog" => "keep", + "app.nestedStylesDialog" => "nested", + "app.grepStylesDialog" => "grep", _ if p.get("tab").and_then(Value::as_str) == Some("shading") => "shading", _ => "border", }; @@ -1297,6 +1322,18 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { + let Some(a) = app.session.execute("type.selectionAttrs", &json!({"mixed": true})).ok().filter(|a| !a.is_null()) else { + return Some(Err("select text or a text frame".into())); + }; + let mixed: Vec<&str> = a["paraMixed"].as_array().map(|m| m.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); + let current: serde_json::Map = crate::dialogs::HYPH_ATTRS + .iter() + .map(|k| (k.to_string(), if mixed.contains(k) { Value::Null } else { a["para"][*k].clone() })) + .collect(); + app.ui.dialog = Some(crate::dialogs::Dialog::new("hyphenation", json!({"current": current}))); + Ok(Value::Null) + } "app.justificationDialog" => { let Some(a) = app.session.execute("type.selectionAttrs", &json!({"mixed": true})).ok().filter(|a| !a.is_null()) else { return Some(Err("select text or a text frame".into())); @@ -1933,7 +1970,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option Some(TYPOGRAPHY_GROUPS), _ => None, }; - app.ui.icon_groups = groups.map(|groups| groups.iter().map(|g| g.iter().map(|p| p.to_string()).collect()).collect()); + app.ui.icon_groups = groups.map(|groups| groups.iter().map(|g| g.iter().map(ToString::to_string).collect()).collect()); app.ui.dock_expanded = groups.is_none(); let dock_tab = match name { "Interactive for PDF" | "Digital Publishing" => Some("pages"), @@ -2011,7 +2048,7 @@ fn export_png(app: &mut DesignApp, p: &Value) -> Result { Some(s) => Some(s.to_string()), None => { let name = format!("{}-p{}.png", st.doc.title, page + 1); - app.services.pick_save.as_mut().and_then(|f| f(&name)) + app.pick_save(&name) } }; let Some(path) = path else { return Ok(Value::Null) }; @@ -2029,7 +2066,7 @@ fn export_idml(app: &mut DesignApp, p: &Value) -> Result { Some(s) => Some(s.to_string()), None => { let name = format!("{}.idml", st.doc.title); - app.services.pick_save.as_mut().and_then(|f| f(&name)) + app.pick_save(&name) } }; let Some(path) = path else { return Ok(Value::Null) }; @@ -2049,7 +2086,7 @@ fn export_bytes(app: &mut DesignApp, p: &Value, ext: &str, cmd: &str) -> Result< Some(s) => Some(s.to_string()), None => { let name = format!("{}.{ext}", st.doc.title); - app.services.pick_save.as_mut().and_then(|f| f(&name)) + app.pick_save(&name) } }; let Some(path) = path else { return Ok(Value::Null) }; @@ -2077,7 +2114,7 @@ pub(crate) fn export_pdf(app: &mut DesignApp, p: &Value) -> Result Some(s.to_string()), None => { let name = format!("{}.pdf", st.doc.title); - app.services.pick_save.as_mut().and_then(|f| f(&name)) + app.pick_save(&name) } }; let Some(path) = path else { return Ok(Value::Null) }; @@ -2399,6 +2436,11 @@ pub fn checked(app: &DesignApp, id: &str, params: &Value) -> Option { "view.hiddenCharacters" => app.ui.hidden_characters, "view.taggedFrames" => app.ui.tagged_frames, "changes.track" => app.session.active().is_some_and(|d| d.doc.settings.track_changes), + // Pressed, as the Bulleted and Numbered List buttons: every paragraph the command applies + // to (selected text, cells or text frames) has it. + "list.bullets" | "list.numbers" => { + app.session.selection_has_list(if id == "list.bullets" { designcraft_doc::ListType::Bullets } else { designcraft_doc::ListType::Numbers }) + } "window.controlBar" => app.ui.control_bar, "window.split" => app.split, "window.newWindow" => app.second_window, @@ -2455,7 +2497,7 @@ pub fn menu_enabled(app: &DesignApp, id: &str) -> bool { if is_window_control(id) { return window_control_available(app, id); } - if id == "app.justificationDialog" { + if matches!(id, "app.justificationDialog" | "app.hyphenationDialog") { return enabled(app, "type.para"); } if id.starts_with("fit.") { @@ -5642,4 +5684,111 @@ mod tests { assert!(texts.iter().any(|t| t == "Recent files"), "{texts:?}"); assert!(texts.iter().any(|t| t == "gone"), "the card names it without `.designcraft`: {texts:?}"); } + + /// On a short window (1366 x 768 laptops) the start screen is taller than the window; the part + /// below the fold has to be reachable by scrolling instead of being cut off. + #[test] + fn start_screen_scrolls_when_the_window_is_too_short() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, ""); + let size = egui::vec2(1366.0, 300.0); + let mut tile_y = |events: Vec| { + let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), events, ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + out.textures_delta.clear(); + out.shapes.iter().find_map(|s| match &s.shape { + // The preset tiles paint their names even when scrolled out of view (labels skip that). + egui::Shape::Text(t) if t.galley.text() == "Tabloid" => Some(t.pos.y), + _ => None, + }) + }; + let at = egui::pos2(600.0, 150.0); + let mut before = None; + for _ in 0..3 { + before = tile_y(vec![egui::Event::PointerMoved(at)]); + } + let before = before.expect("the Tabloid tile is laid out"); + let wheel = egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -400.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }; + let mut after = tile_y(vec![egui::Event::PointerMoved(at), wheel]); + for _ in 0..30 { + after = tile_y(Vec::new()); + } + let after = after.expect("the Tabloid tile is laid out"); + assert!(after < before - 50.0, "the start screen should scroll: {before} -> {after}"); + } + + // Regression test for #329: on the start screen (no document) the Help menu's links were greyed out. + #[test] + fn help_links_are_enabled_without_a_document() { + let app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + assert!(app.session.active().is_none()); + let ids: Vec<&str> = UI_COMMANDS.iter().map(|c| c.0).filter(|id| id.starts_with("help.")).collect(); + assert!(ids.contains(&"help.discord") && ids.contains(&"help.github"), "{ids:?}"); + for id in ids { + assert!(menu_enabled(&app, id), "{id} must work from the start screen"); + } + assert!(!menu_enabled(&app, "view.rulers"), "other UI commands still need a document"); + } + + // Regression test for #248: the start screen offered no A6 sheet (it listed only the first ten presets). + #[test] + fn start_screen_offers_every_print_preset_including_a6() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let texts = start_screen_texts(&mut app); + for p in designcraft_doc::build::PRESETS.iter().filter(|p| p.intent == designcraft_doc::Intent::Print) { + assert!(texts.iter().any(|t| t == p.name), "start screen lacks the {} card: {texts:?}", p.name); + } + assert!(texts.iter().any(|t| t == "A6"), "{texts:?}"); + let a6 = designcraft_doc::build::PRESETS.iter().find(|p| p.name == "A6").unwrap(); + assert!((a6.width - 105.0 * 72.0 / 25.4).abs() < 0.01 && (a6.height - 148.0 * 72.0 / 25.4).abs() < 0.01, "{a6:?}"); + let page = crate::panels::properties::PAGE_PRESETS.iter().find(|p| p.0 == "A6").expect("Properties page-size list has A6"); + assert!((page.1 - a6.width).abs() < 0.01 && (page.2 - a6.height).abs() < 0.01, "{page:?} vs {a6:?}"); + } + + // Regression test for #249: a menu row only reacted over its text, not across the whole row. + #[test] + fn menu_rows_are_selectable_across_the_full_width() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 400.0)); + let entries = vec![cmd_item("app.preferences", Value::Null)]; + let mut tick = 0; + let mut frame = |app: &mut DesignApp, events: Vec| { + tick += 1; + let mut row = egui::Rect::NOTHING; + let mut output = + ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(tick as f64 / 60.0), events, ..Default::default() }, |ui| { + egui::Area::new(egui::Id::new("menu_rows")).fixed_pos(egui::pos2(50.0, 50.0)).show(ui.ctx(), |ui| { + let before = ui.cursor().min; + menu_column(app, ui, &[("Test", entries.clone())]); + row = egui::Rect::from_min_max(before, ui.min_rect().max); + }); + }); + output.textures_delta.clear(); + row + }; + frame(&mut app, vec![]); + let row = frame(&mut app, vec![]); + assert!(row.width() >= 239.0, "the column is at least 240 wide: {row:?}"); + let far = egui::pos2(row.right() - 6.0, row.top() + 8.0); + for pressed in [true, false] { + frame( + &mut app, + vec![ + egui::Event::PointerMoved(far), + egui::Event::PointerButton { pos: far, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }, + ], + ); + } + assert!(app.ui.dialog.is_some(), "clicking the empty end of the Preferences… row must choose it"); + } } diff --git a/crates/ui-egui/src/panels/conditions.rs b/crates/ui-egui/src/panels/conditions.rs index 45eddec20..a855b4820 100644 --- a/crates/ui-egui/src/panels/conditions.rs +++ b/crates/ui-egui/src/panels/conditions.rs @@ -7,6 +7,18 @@ use serde_json::json; use crate::DesignApp; use crate::theme::Tokens; +/// Click on a condition name: toggle it on the selected text, or tell the user to select some. +fn apply_condition(app: &mut DesignApp, name: &str, has_text: bool, applied: &[String]) { + if has_text { + let on = !applied.iter().any(|a| a == name); + if let Err(e) = app.run("condition.apply", json!({"name": name, "on": on})) { + app.status_error("Conditional Text", e); + } + } else { + app.status(crate::i18n::tr(&app.ui.language, "Select text to apply a condition")); + } +} + pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { return }; @@ -55,14 +67,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let chip = egui::Rect::from_min_size(egui::pos2(row.max.x - 20.0, row.min.y + 5.0), vec2(14.0, 12.0)); ui.painter().rect_filled(chip, 2.0, egui::Color32::from_rgb(c.color[0], c.color[1], c.color[2])); if nr.clicked() { - if has_text { - let on = !applied.contains(&c.name); - if let Err(e) = app.run("condition.apply", json!({"name": c.name, "on": on})) { - app.status_error("Conditional Text", e); - } - } else { - app.status(crate::i18n::tr(&app.ui.language, "Select text to apply a condition")); - } + apply_condition(app, &c.name, has_text, &applied); } nr.context_menu(|ui| { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Delete Condition"))).clicked() { diff --git a/crates/ui-egui/src/panels/library.rs b/crates/ui-egui/src/panels/library.rs index 56d6c8cae..2199e571e 100644 --- a/crates/ui-egui/src/panels/library.rs +++ b/crates/ui-egui/src/panels/library.rs @@ -33,7 +33,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { ui.separator(); ui.horizontal(|ui| { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Library…"))).clicked() { - let path = app.services.pick_save.as_mut().and_then(|f| f("Library.dclib")); + let path = app.pick_save("Library.dclib"); let _ = app.run("library.new", json!({"path": path})); } if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Open…"))).clicked() @@ -84,13 +84,10 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } } -/// Book panel: the open book's documents with their page ranges; numbering, synchronising and -/// exporting the whole book. -pub fn book(app: &mut DesignApp, ui: &mut egui::Ui) { - let t = Tokens::get(ui.ctx()); +fn book_open_buttons(app: &mut DesignApp, ui: &mut egui::Ui) { ui.horizontal(|ui| { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Book…"))).clicked() - && let Some(path) = app.services.pick_save.as_mut().and_then(|f| f("Book.dcbook")) + && let Some(path) = app.pick_save("Book.dcbook") && let Err(e) = app.run("book.new", json!({"path": path})) { app.status_error("Book", e); @@ -102,6 +99,36 @@ pub fn book(app: &mut DesignApp, ui: &mut egui::Ui) { app.status_error("Book", e); } }); +} + +fn book_action_buttons(app: &mut DesignApp, ui: &mut egui::Ui) { + ui.horizontal_wrapped(|ui| { + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Add Document…"))).clicked() + && let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("open")) + && let Err(e) = app.run("book.add", json!({"path": path})) + { + app.status_error("Book", e); + } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Update Numbering"))).clicked() { + let _ = app.run("book.paginate", json!({})); + } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Synchronize"))).clicked() { + let _ = app.run("book.syncStyles", json!({})); + } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Export PDF…"))).clicked() + && let Some(path) = app.pick_save("Book.pdf") + && let Err(e) = app.run("book.exportPdf", json!({"path": path})) + { + app.status_error("Book", e); + } + }); +} + +/// Book panel: the open book's documents with their page ranges; numbering, synchronising and +/// exporting the whole book. +pub fn book(app: &mut DesignApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + book_open_buttons(app, ui); let Ok(info) = app.session.execute("book.list", &json!({})) else { crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "No book open.")).size(11.0).color(t.text_dim)); return; @@ -140,26 +167,7 @@ pub fn book(app: &mut DesignApp, ui: &mut egui::Ui) { }); } ui.separator(); - ui.horizontal_wrapped(|ui| { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Add Document…"))).clicked() - && let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("open")) - && let Err(e) = app.run("book.add", json!({"path": path})) - { - app.status_error("Book", e); - } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Update Numbering"))).clicked() { - let _ = app.run("book.paginate", json!({})); - } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Synchronize"))).clicked() { - let _ = app.run("book.syncStyles", json!({})); - } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Export PDF…"))).clicked() - && let Some(path) = app.services.pick_save.as_mut().and_then(|f| f("Book.pdf")) - && let Err(e) = app.run("book.exportPdf", json!({"path": path})) - { - app.status_error("Book", e); - } - }); + book_action_buttons(app, ui); } /// Scripts panel: saved command scripts — run (one undo step), edit, add, delete, load. diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index f2229b604..85058d7a8 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -264,7 +264,7 @@ pub fn preflight_errors(app: &DesignApp) -> usize { static MEMO: std::sync::Mutex> = std::sync::Mutex::new(None); let Some(st) = app.session.active() else { return 0 }; let key = std::sync::Arc::as_ptr(&st.doc) as usize; - let mut g = MEMO.lock().unwrap_or_else(|e| e.into_inner()); + let mut g = MEMO.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if let Some((k, n)) = *g && k == key { diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index 4b2360f8f..534eb4aa1 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -28,7 +28,7 @@ const MAX_THUMB_W: f64 = 1024.0; fn thumb(app: &mut DesignApp, ctx: &egui::Context, of: ThumbOf, h: f32) -> Option { let st = app.session.active()?; let key = (st.uid, st.revision, of, h.round() as u32); - let mut g = THUMBS.lock().unwrap_or_else(|e| e.into_inner()); + let mut g = THUMBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let map = g.get_or_insert_with(HashMap::new); if let Some(t) = map.get(&key) { return Some(t.clone()); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index fd0094f9b..6deace3fc 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -982,7 +982,9 @@ fn align_row(app: &mut DesignApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 8.0; - let to = app.ui.align_to.clone(); + // A key object switches Align To to Key Object; choosing another target drops the key. + let key = app.session.active().and_then(|st| st.selection.key.filter(|_| st.selection.items.len() > 1)); + let to = if key.is_some() { "keyObject".to_string() } else { app.ui.align_to.clone() }; let to_label = match to.as_str() { "selection" => "Selection", "keyObject" => "Key Object", @@ -1001,7 +1003,10 @@ fn align_row(app: &mut DesignApp, ui: &mut Ui) { ("Align to Page", "page"), ("Align to Spread", "spread"), ] { - if ui.selectable_label(app.ui.align_to == id, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { + if ui.selectable_label(to == id, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { + if let Some(k) = key.filter(|_| id != "keyObject") { + let _ = app.run("selection.key", json!({"id": k.0})); + } app.ui.align_to = id.into(); ui.close(); } @@ -1343,21 +1348,141 @@ fn character_section(app: &mut DesignApp, ui: &mut Ui) { }) { let _ = app.run("type.char", json!({"attrs": {"tracking": v}})); } - // Language: hyphenation, spelling and typographer's quotes follow it. - ui.add_space(1.0); + // ••• shows the rest in place, as InDesign's Properties panel does. + let more = temp_bool(ui, "props_char_more", false); + if more { + character_more(app, ui, &c, fw); + } + if more_options(ui, &app.ui.language).clicked() { + set_temp_bool(ui, "props_char_more", !more); + } +} + +/// A percentage or other number field in one half of a two-field row, with its icon. +#[allow(clippy::too_many_arguments)] +fn half_field(ui: &mut Ui, b: Rect, right: bool, y: f32, half: f32, icon: &str, tip: &str, field: NumField) -> Option { + half_field_or_clear(ui, b, right, y, half, icon, tip, field).flatten() +} + +/// [`half_field`] for a value that may be unset: emptying the field commits `Some(None)`. +#[allow(clippy::too_many_arguments)] +fn half_field_or_clear(ui: &mut Ui, b: Rect, right: bool, y: f32, half: f32, icon: &str, tip: &str, field: NumField) -> Option> { + let t = Tokens::get(ui.ctx()); + let x = if right { half + 10.0 } else { 0.0 }; + let ir = sub(b, x, y + 1.0, 19.0, 19.0); + icons::paint(ui.painter(), ir, icon, t.icon); + let resp = ui.interact(ir, ui.id().with(("half_icon", icon)), Sense::hover()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, tip)); + let label = resp.id; + resp.on_hover_text(tip); + place(ui, sub(b, x + 24.0, y, half - 24.0, FIELD_H), |ui| field.width(half - 24.0).labelled_by(label).show_or_clear(ui)) +} + +/// Properties › Character after •••: vertical and horizontal scale, baseline shift and skew, +/// language and OpenType, then All Caps, Small Caps, Superscript, Subscript, Underline and +/// Strikethrough, as InDesign lays them out. +fn character_more(app: &mut DesignApp, ui: &mut Ui, c: &Value, fw: f32) { + let lang_ui = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang_ui, s).to_string(); + let half = (fw - 10.0) / 2.0; + let b = block(ui, 2.0 * FIELD_H + 5.0); + const SCALES: &[f64] = &[25.0, 50.0, 75.0, 80.0, 90.0, 100.0, 110.0, 120.0, 125.0, 150.0, 200.0]; + let pct = |v: &Value| v.as_f64().map(|s| (s * 1000.0).round() / 10.0); + let field = |id, v, suffix, decimals| NumField::number(id, v, suffix, decimals).spinner(); + if let Some(v) = half_field( + ui, + b, + false, + 0.0, + half, + "vscale", + &tr("Vertical Scale"), + field("cvscale", pct(&c["vScale"]), "%", 1).presets(SCALES).range(1.0, 1000.0), + ) { + let _ = app.run("type.char", json!({"attrs": {"vScale": v / 100.0}})); + } + if let Some(v) = half_field( + ui, + b, + true, + 0.0, + half, + "hscale", + &tr("Horizontal Scale"), + field("chscale", pct(&c["hScale"]), "%", 1).presets(SCALES).range(1.0, 1000.0), + ) { + let _ = app.run("type.char", json!({"attrs": {"hScale": v / 100.0}})); + } + if let Some(v) = half_field( + ui, + b, + false, + 26.0, + half, + "baseline-shift", + &tr("Baseline Shift"), + field("cbshift", c["baselineShift"].as_f64(), " pt", 2).range(-5000.0, 5000.0), + ) { + let _ = app.run("type.char", json!({"attrs": {"baselineShift": v}})); + } + if let Some(v) = + half_field(ui, b, true, 26.0, half, "skew", &tr("Skew (False Italic)"), field("cskew", c["skew"].as_f64(), "°", 1).range(-85.0, 85.0)) + { + let _ = app.run("type.char", json!({"attrs": {"skew": v}})); + } + // Language (hyphenation, spelling and typographer's quotes follow it) and OpenType. + ui.add_space(4.0); let lang = c["language"].as_str().unwrap_or("English: USA").to_string(); - let label = crate::i18n::tr(&app.ui.language, "Language"); - let resp = widgets::dropdown(ui, crate::i18n::tr(&app.ui.language, &lang), fw).on_hover_ui(|ui| { - crate::rtl::label(ui, label); + let label = tr("Language"); + let row = block(ui, FIELD_H); + let resp = place(ui, sub(row, 0.0, 0.0, half, FIELD_H), |ui| widgets::dropdown(ui, crate::i18n::tr(&lang_ui, &lang), half)).on_hover_ui(|ui| { + crate::rtl::label(ui, label.clone()); }); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, &label)); egui::Popup::menu(&resp).show(|ui| { - ui.set_min_width(fw); + ui.set_min_width(half); egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| language_menu(app, ui, &lang)); }); - if more_options(ui, &app.ui.language).clicked() { - show_panel(app, "character"); + if place(ui, sub(row, half + 10.0, 0.0, half, FIELD_H), |ui| widgets::outline_button(ui, &tr("OpenType"), half)).clicked() { + show_typography_panel(app); } + // The case and position buttons. + ui.add_space(8.0); + let caps = c["capitalization"].as_str().unwrap_or("normal").to_string(); + let pos = c["position"].as_str().unwrap_or("normal").to_string(); + let (ul, st) = (c["underline"].as_bool().unwrap_or(false), c["strikethrough"].as_bool().unwrap_or(false)); + let buttons: [(&str, &str, bool, Value); 6] = [ + ("TT", "All Caps", caps == "allCaps", json!({"capitalization": if caps == "allCaps" { "normal" } else { "allCaps" }})), + ("Tᴛ", "Small Caps", caps == "smallCaps", json!({"capitalization": if caps == "smallCaps" { "normal" } else { "smallCaps" }})), + ("T¹", "Superscript", pos == "superscript", json!({"position": if pos == "superscript" { "normal" } else { "superscript" }})), + ("T₁", "Subscript", pos == "subscript", json!({"position": if pos == "subscript" { "normal" } else { "subscript" }})), + ("T̲", "Underline", ul, json!({"underline": !ul})), + ("T", "Strikethrough", st, json!({"strikethrough": !st})), + ]; + let pitch = fw / 6.0; + let row = block(ui, 22.0); + for (i, (text, tip, on, attrs)) in buttons.into_iter().enumerate() { + let r = Rect::from_center_size(row.min + vec2(pitch * (i as f32 + 0.5), 11.0), vec2(26.0, 22.0)); + let resp = ui.interact(r, ui.id().with(("case", i)), Sense::click()); + let t = Tokens::get(ui.ctx()); + if on { + ui.painter().rect_filled(r, 2.0, t.tool_active); + } else if resp.hovered() { + ui.painter().rect_filled(r, 2.0, t.hover); + } + let col = if on { t.text_strong } else { t.icon }; + ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, text, egui::FontId::proportional(13.0), col); + // Strikethrough: the T struck through (a combining stroke doesn't draw in every font). + if i == 5 { + ui.painter().line_segment([r.center() + vec2(-6.0, 0.5), r.center() + vec2(6.0, 0.5)], Stroke::new(1.2, col)); + } + let tip = tr(tip); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, &tip)); + if resp.on_hover_text(&tip).clicked() { + let _ = app.run("type.char", json!({"attrs": attrs})); + } + } + ui.add_space(4.0); } /// Paragraph section: the 9 alignment buttons and •••. @@ -1387,20 +1512,81 @@ fn paragraph_section(app: &mut DesignApp, ui: &mut Ui) { } } }); + let more = temp_bool(ui, "props_para_more", false); + if more { + paragraph_more(app, ui, &a["para"], fw); + } if more_options(ui, &app.ui.language).clicked() { - show_panel(app, "paragraph"); + set_temp_bool(ui, "props_para_more", !more); } } +/// Properties › Paragraph after •••: left and right indent, first line and last line indent, +/// space before and after, drop cap lines and characters, then Hyphenate and Align to Baseline +/// Grid, as InDesign lays them out. +fn paragraph_more(app: &mut DesignApp, ui: &mut Ui, p: &Value, fw: f32) { + let lang_ui = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang_ui, s).to_string(); + let u = units(app); + let half = (fw - 10.0) / 2.0; + ui.add_space(4.0); + let b = block(ui, 4.0 * FIELD_H + 15.0); + let rows = [ + (("indent-left", "Left Indent", "leftIndent"), ("indent-right", "Right Indent", "rightIndent")), + (("indent-first", "First Line Left Indent", "firstLineIndent"), ("indent-last", "Last Line Right Indent", "lastLineIndent")), + (("space-before", "Space Before", "spaceBefore"), ("space-after", "Space After", "spaceAfter")), + ]; + for (i, (l, r)) in rows.into_iter().enumerate() { + let y = i as f32 * (FIELD_H + 5.0); + for (right, (icon, tip, key)) in [(false, l), (true, r)] { + let min = para_field_min(key); + let field = NumField::measure(key, p[key].as_f64(), u).spinner().range(min, 10_000.0); + if let Some(v) = half_field(ui, b, right, y, half, icon, &tr(tip), field) { + let _ = app.run("type.para", json!({"attrs": {key: v}})); + } + } + } + let y = 3.0 * (FIELD_H + 5.0); + for (right, icon, tip, key, max) in [ + (false, "drop-lines", "Drop Cap Number of Lines", "dropCapLines", designcraft_compose::MAX_DROP_CAP_LINES), + (true, "drop-chars", "Drop Cap One or More Characters", "dropCapChars", designcraft_compose::MAX_DROP_CAP_CHARS), + ] { + let field = NumField::number(key, p[key].as_f64(), "", 0).spinner().range(0.0, max as f64); + if let Some(v) = half_field(ui, b, right, y, half, icon, &tr(tip), field) { + let _ = app.run("type.para", json!({"attrs": {key: (v.max(0.0) as u64).min(max as u64)}})); + } + } + ui.add_space(4.0); + ui.horizontal(|ui| { + // First Line Only is aligned too: the button shows it pressed, and a click turns alignment off. + let grid = matches!(p["gridAlign"].as_str(), Some("allLines" | "firstLineOnly")); + if widgets::icon_toggle(ui, "grid-baseline", grid, &tr("Align to Baseline Grid")).clicked() { + let _ = app.run("type.para", json!({"attrs": {"gridAlign": if grid { "none" } else { "allLines" }}})); + } + ui.add_space(12.0); + let mut h = p["hyphenate"].as_bool().unwrap_or(true); + if widgets::checkbox(ui, &mut h, &tr("Hyphenate")).changed() { + let _ = app.run("type.para", json!({"attrs": {"hyphenate": h}})); + } + }); + ui.add_space(4.0); +} + fn bullets_section(app: &mut DesignApp, ui: &mut Ui) { - let Some(a) = text_attrs(app) else { return }; - let lt = a["para"]["listType"].as_str().unwrap_or("none").to_string(); + if text_attrs(app).is_none() { + return; + } ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 10.0; - for (icon, kind, tip) in [("bullets", "bullets", "Bulleted List"), ("numbering", "numbers", "Numbered List")] { - let on = lt == kind; + // As InDesign's: a list with the default hanging indent, or (pressed: every selected + // paragraph has it, as Type › Apply Bullets shows) no list. + use designcraft_doc::ListType; + for (icon, kind, cmd, tip) in + [("bullets", ListType::Bullets, "list.bullets", "Bulleted List"), ("numbering", ListType::Numbers, "list.numbers", "Numbered List")] + { + let on = app.session.selection_has_list(kind); if icon_toggle(ui, icon, on, crate::i18n::tr(&app.ui.language, tip)).clicked() { - let _ = app.run("type.para", json!({"attrs": {"listType": if on { "none" } else { kind }}})); + let _ = app.run(cmd, json!({"on": !on})); } } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -1500,7 +1686,7 @@ pub(crate) const LANGUAGES: &[&str] = &[ /// The Language list (Character panel, Properties ▸ Character): picking one sets the language of /// the selected text. -fn language_menu(app: &mut DesignApp, ui: &mut Ui, cur: &str) { +pub(crate) fn language_menu(app: &mut DesignApp, ui: &mut Ui, cur: &str) { for l in LANGUAGES { if ui.selectable_label(*l == cur, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, l))).clicked() { let _ = app.run("type.char", json!({"attrs": {"language": l}})); @@ -1512,7 +1698,7 @@ fn language_menu(app: &mut DesignApp, ui: &mut Ui, cur: &str) { /// Kerning for the text selection, or at a caret for the pair at the caret: the label shows /// Metrics, Optical, 0 or the manual value; its menu sets one of the first three or a manual value /// in 1/1000 em (typed or stepped). -fn kerning_control(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, k: &Value, width: f32) { +pub(crate) fn kerning_control(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, k: &Value, width: f32) { let kind = k["kind"].as_str().unwrap_or("metrics"); let manual = k["value"].as_f64().filter(|v| kind == "manual" && v.is_finite()); let label = match (kind, manual) { @@ -1732,161 +1918,235 @@ pub fn show_typography_panel(app: &mut DesignApp) { let _ = app.run("window.panel", json!({"panel": "typography"})); } +/// The paragraph alignments in InDesign's order, with their icons. +const PARA_ALIGNS: [(Align, &str); 9] = [ + (Align::Left, "palign-left"), + (Align::Center, "palign-center"), + (Align::Right, "palign-right"), + (Align::LeftJustified, "palign-justify-left"), + (Align::CenterJustified, "palign-justify-center"), + (Align::RightJustified, "palign-justify-right"), + (Align::FullyJustified, "palign-justify-all"), + (Align::TowardsSpine, "palign-spine-towards"), + (Align::AwayFromSpine, "palign-spine-away"), +]; + +/// Space Between Paragraphs Using Same Style's presets (pt), after Ignore. +const SAME_STYLE_SPACING: &[f64] = &[0.0, 3.0, 6.0, 9.0, 12.0, 18.0, 24.0]; + +/// The Paragraph panel, laid out as InDesign's: the alignment buttons in groups of 3, 4 and 2; +/// indents, spacing and drop cap fields by their icons in two columns; Shading and Border with +/// their colours; Hyphenate and the baseline grid buttons. The rest (composer, grid and ragged +/// lines options, the paragraph dialogs) is in the panel menu. pub fn paragraph_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let Some(a) = text_attrs(app) else { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Select text or a text frame.")); return; }; + let lang_ui = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang_ui, s).to_string(); let u = units(app); let p = a["para"].clone(); - ui.horizontal(|ui| { - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Paragraph")).strong()); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - crate::menus::menu_button(ui, "☰", |ui| { - for (label, id) in [ - ("Paragraph Rules…", "app.paragraphRulesDialog"), - ("Justification…", "app.justificationDialog"), - ("Paragraph Borders and Shading…", "app.paragraphBordersDialog"), - ("Span Columns…", "app.spanColumnsDialog"), - ("Bullets and Numbering…", "app.bulletsNumberingDialog"), - ] { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { - let _ = app.run(id, json!({})); - ui.close(); - } - } - }); - }); - }); + let fw = full_width(ui); + let half = (fw - 10.0) / 2.0; + // Alignment, and the panel menu at the right. let cur: Align = serde_json::from_value(p["align"].clone()).unwrap_or_default(); - ui.horizontal(|ui| { - for (al, icon) in [ - (Align::Left, "align-left"), - (Align::Center, "align-center"), - (Align::Right, "align-right"), - (Align::LeftJustified, "align-justify"), - (Align::FullyJustified, "align-justify-all"), - ] { - if icons::button(ui, icon, 24.0, cur == al, al.label()).clicked() { - let _ = app.run("type.para", json!({"attrs": {"align": al}})); - } + const GAP: f32 = 8.0; + let pitch = ((fw - 24.0 - 2.0 * GAP) / 9.0).clamp(16.0, 24.0); + let b = block(ui, 21.0); + let mut x = 0.0; + for (i, (al, icon)) in PARA_ALIGNS.into_iter().enumerate() { + if i == 3 || i == 7 { + x += GAP; } - }); - // Paragraph Direction (World-Ready): switching also mirrors left/right alignment. - let rtl = p["direction"] == "rightToLeft"; - ui.horizontal(|ui| { - caption(ui, crate::i18n::tr(&app.ui.language, "Direction")); - for (on, label, dir) in [(!rtl, "Left to Right", "leftToRight"), (rtl, "Right to Left", "rightToLeft")] { - if ui.selectable_label(on, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() && !on { - let align = match cur { - Align::Left => Align::Right, - Align::Right => Align::Left, - a => a, - }; - let _ = app.run("type.para", json!({"attrs": {"direction": dir, "align": align}})); - } - } - }); - egui::Grid::new("paragrid").num_columns(4).spacing(vec2(6.0, 4.0)).show(ui, |ui| { - for (row, ((la, ka), (lb, kb))) in [ - (("Left", "leftIndent"), ("Right", "rightIndent")), - (("First", "firstLineIndent"), ("Last", "lastLineIndent")), - (("Before", "spaceBefore"), ("After", "spaceAfter")), - ] - .into_iter() - .enumerate() + if place(ui, sub(b, x, 0.0, pitch, 21.0), |ui| widgets::icon_toggle_sized(ui, icon, cur == al, &tr(al.label()), vec2(pitch, 21.0))).clicked() { - caption(ui, crate::i18n::tr(&app.ui.language, la)); - if let Some(v) = measure(ui, &format!("pp{row}a"), p[ka].as_f64(), u, 64.0) { - let _ = app.run("type.para", json!({"attrs": {ka: v}})); - } - caption(ui, crate::i18n::tr(&app.ui.language, lb)); - if let Some(v) = measure(ui, &format!("pp{row}b"), p[kb].as_f64(), u, 64.0) { - let _ = app.run("type.para", json!({"attrs": {kb: v}})); - } - ui.end_row(); + let _ = app.run("type.para", json!({"attrs": {"align": al}})); } - // Drop cap: number of lines and characters. - for (label, key, max, id) in [ - ("Drop Lines", "dropCapLines", designcraft_compose::MAX_DROP_CAP_LINES, "ppdl"), - ("Drop Chars", "dropCapChars", designcraft_compose::MAX_DROP_CAP_CHARS, "ppdc"), - ] { - caption(ui, crate::i18n::tr(&app.ui.language, label)); - if let Some(v) = number(ui, id, p[key].as_f64(), "", 64.0, 0) { - let _ = app.run("type.para", json!({"attrs": {key: (v.max(0.0) as u64).min(max as u64)}})); - } - } - ui.end_row(); - }); - ui.horizontal(|ui| { - let mut h = p["hyphenate"].as_bool().unwrap_or(true); - if ui.checkbox(&mut h, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Hyphenate"))).changed() { - let _ = app.run("type.para", json!({"attrs": {"hyphenate": h}})); - } - let mut grid = p["gridAlign"].as_str() == Some("allLines"); - if ui.checkbox(&mut grid, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Align to baseline grid"))).changed() { - let _ = app.run("type.para", json!({"attrs": {"gridAlign": if grid { "allLines" } else { "none" }}})); - } - }); - let mut optical = p["opticalMargin"].as_bool().unwrap_or(false); - if ui.checkbox(&mut optical, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Optical Margin Alignment"))).changed() { - let _ = app.run("type.para", json!({"attrs": {"opticalMargin": optical}})); + x += pitch; } - ui.horizontal(|ui| { - caption(ui, crate::i18n::tr(&app.ui.language, "Composer")); - let (single, [paragraph, single_line]) = composer_choice(p["composer"].as_str()); - if ui.selectable_label(!single, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Paragraph"))).clicked() { - let _ = app.run("type.para", json!({"attrs": {"composer": paragraph}})); + place(ui, sub(b, fw - 20.0, 0.0, 20.0, 21.0), |ui| crate::menus::menu_button(ui, "☰", |ui| paragraph_panel_menu(app, ui, &p))); + ui.add_space(6.0); + // Indents, spacing and drop cap, two to a row as in InDesign. + let row = FIELD_H + 5.0; + let b = block(ui, 5.0 * row - 5.0); + let rows = [ + (("indent-left", "Left Indent", "leftIndent"), ("indent-right", "Right Indent", "rightIndent")), + (("indent-first", "First Line Left Indent", "firstLineIndent"), ("indent-last", "Last Line Right Indent", "lastLineIndent")), + (("space-before", "Space Before", "spaceBefore"), ("space-after", "Space After", "spaceAfter")), + ]; + for (i, (l, r)) in rows.into_iter().enumerate() { + for (right, (icon, tip, key)) in [(false, l), (true, r)] { + // The first and last line indents may be negative (a hanging line), as InDesign's. + let min = if matches!(key, "firstLineIndent" | "lastLineIndent") { -10_000.0 } else { 0.0 }; + let field = NumField::measure(key, p[key].as_f64(), u).spinner().range(min, 10_000.0); + if let Some(v) = half_field(ui, b, right, i as f32 * row, half, icon, &tr(tip), field) { + let _ = app.run("type.para", json!({"attrs": {key: v}})); + } } - if ui.selectable_label(single, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Single-line"))).clicked() { - let _ = app.run("type.para", json!({"attrs": {"composer": single_line}})); + } + // Space Between Paragraphs Using Same Style: Ignore (unset) or a space that replaces the + // space after and before between paragraphs of the same style. + let ignore = tr("Ignore"); + let field = NumField::measure("sameParaStyleSpacing", p["sameParaStyleSpacing"].as_f64(), u) + .spinner() + .presets(SAME_STYLE_SPACING) + .empty_text(&ignore) + .range(0.0, 10_000.0); + if let Some(v) = half_field_or_clear(ui, b, false, 3.0 * row, half, "space-same", &tr("Space Between Paragraphs Using Same Style"), field) { + let _ = app.run("type.para", json!({"attrs": {"sameParaStyleSpacing": v}})); + } + for (right, icon, tip, key, max) in [ + (false, "drop-lines", "Drop Cap Number of Lines", "dropCapLines", designcraft_compose::MAX_DROP_CAP_LINES), + (true, "drop-chars", "Drop Cap One or More Characters", "dropCapChars", designcraft_compose::MAX_DROP_CAP_CHARS), + ] { + let field = NumField::number(key, p[key].as_f64(), "", 0).spinner().range(0.0, max as f64); + if let Some(v) = half_field(ui, b, right, 4.0 * row, half, icon, &tr(tip), field) { + let _ = app.run("type.para", json!({"attrs": {key: (v.max(0.0) as u64).min(max as u64)}})); } - }); - // Paragraph Border and Shading. - let swatches: Vec = - app.session.active().map(|d| d.doc.swatches.iter().filter(|w| !w.hidden).map(|w| w.name.clone()).collect()).unwrap_or_default(); - for (on_key, color_key, label) in [("shadingOn", "shadingColor", "Shading"), ("borderOn", "borderColor", "Border")] { - ui.horizontal(|ui| { - let mut on = p[on_key].as_bool().unwrap_or(false); - if ui.checkbox(&mut on, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).changed() { - let _ = app.run("type.para", json!({ on_key: on })); - } - if on { - let cur = p[color_key].as_str().unwrap_or("[Black]").to_string(); - egui::ComboBox::from_id_salt(("pbs", label)).selected_text(&cur).width(110.0).show_ui(ui, |ui| { - for w in &swatches { - if ui.selectable_label(*w == cur, w).clicked() { - let _ = app.run("type.para", json!({ color_key: w })); - } - } - }); - if label == "Shading" { - if let Some(v) = number(ui, "pshtint", p["shadingTint"].as_f64().map(|t| t * 100.0), "%", 46.0, 0) { - let _ = app.run("type.para", json!({"shadingTint": (v / 100.0).clamp(0.0, 1.0)})); - } - } else if let Some(v) = number(ui, "pbw", p["borderWeights"][0].as_f64(), " pt", 50.0, 2) { - let w = [v.max(0.0); 4]; - let _ = app.run("type.para", json!({ "borderWeights": w })); - } - } - }); } - if p["shadingOn"].as_bool().unwrap_or(false) || p["borderOn"].as_bool().unwrap_or(false) { - ui.horizontal(|ui| { - caption(ui, crate::i18n::tr(&app.ui.language, "Offsets")); - let o = p["borderOffsets"][0].as_f64().or(p["shadingOffsets"][0].as_f64()); - if let Some(v) = number(ui, "pbo", o, " pt", 50.0, 2) { - let o = [v; 4]; - let _ = app.run("type.para", json!({ "borderOffsets": o, "shadingOffsets": o })); + // Shading and Border: on or off at the left; at the right the Paragraph Borders and Shading + // dialog on its tab and the colour. + ui.add_space(10.0); + let doc = app.session.active().map(|d| d.doc.clone()); + let b = block(ui, 2.0 * row - 5.0); + for (i, (on_key, color_key, label, icon, tab, name)) in [ + ("shadingOn", "shadingColor", "Shading", "para-shading", "shading", "Paragraph Shading"), + ("borderOn", "borderColor", "Border", "para-border", "border", "Paragraph Border"), + ] + .into_iter() + .enumerate() + { + let y = i as f32 * row; + let mut on = p[on_key].as_bool().unwrap_or(false); + if place(ui, sub(b, 0.0, y, half, FIELD_H), |ui| widgets::checkbox(ui, &mut on, &tr(label))).changed() { + let _ = app.run("type.para", json!({"attrs": {on_key: on}})); + } + if place(ui, sub(b, half + 10.0, y, 23.0, FIELD_H), |ui| widgets::icon_toggle(ui, icon, false, &tr("Paragraph Borders and Shading…"))) + .clicked() + { + let _ = app.run("app.paragraphBordersDialog", json!({"tab": tab})); + } + if let Some(doc) = &doc { + let color = p[color_key].as_str().unwrap_or("[Black]").to_string(); + let w = half - 24.0; + if let Some(sw) = place(ui, sub(b, half + 34.0, y, w, FIELD_H), |ui| crate::chrome::swatch_field(ui, doc, &color, w, &tr(name))) { + let _ = app.run("type.para", json!({"attrs": {color_key: sw}})); } - }); + } + } + // Hyphenate, and Do Not Align / Align to Baseline Grid. + ui.add_space(10.0); + let b = block(ui, FIELD_H); + let mut h = p["hyphenate"].as_bool().unwrap_or(true); + if place(ui, sub(b, 0.0, 0.0, half, FIELD_H), |ui| widgets::checkbox(ui, &mut h, &tr("Hyphenate"))).changed() { + let _ = app.run("type.para", json!({"attrs": {"hyphenate": h}})); + } + let grid = matches!(p["gridAlign"].as_str(), Some("allLines" | "firstLineOnly")); + for (i, (icon, tip, on, v)) in + [("grid-none", "Do Not Align to Baseline Grid", !grid, "none"), ("grid-baseline", "Align to Baseline Grid", grid, "allLines")] + .into_iter() + .enumerate() + { + if place(ui, sub(b, fw - 48.0 + i as f32 * 25.0, 0.0, 23.0, FIELD_H), |ui| widgets::icon_toggle(ui, icon, on, &tr(tip))).clicked() && !on { + let _ = app.run("type.para", json!({"attrs": {"gridAlign": v}})); + } + } + ui.add_space(4.0); +} + +/// The Paragraph panel menu, in the order of InDesign's. +fn paragraph_panel_menu(app: &mut DesignApp, ui: &mut Ui, p: &Value) { + let lang = app.ui.language.clone(); + let item = |ui: &mut Ui, label: &str, check: Option, enabled: bool| -> bool { + let label = crate::i18n::tr(&lang, label); + let text = match check { + Some(true) => format!("✓ {label}"), + Some(false) => format!(" {label}"), + None => label.to_string(), + }; + let clicked = ui.add_enabled(enabled, egui::Button::new(crate::rtl::widget(ui, text))).clicked(); + if clicked { + ui.close(); + } + clicked + }; + let mut attrs = None; + let mut command = None; + let (single, [paragraph, single_line]) = composer_choice(p["composer"].as_str()); + if item(ui, "Paragraph Composer", Some(!single), true) { + attrs = Some(json!({"composer": paragraph})); + } + if item(ui, "Single-line Composer", Some(single), true) { + attrs = Some(json!({"composer": single_line})); + } + ui.separator(); + let grid = p["gridAlign"].as_str().unwrap_or("none"); + if item(ui, "Only Align First Line to Grid", Some(grid == "firstLineOnly"), grid != "none") { + attrs = Some(json!({"gridAlign": if grid == "firstLineOnly" { "allLines" } else { "firstLineOnly" }})); + } + let balance = p["balanceRagged"].as_bool().unwrap_or(false); + if item(ui, "Balance Ragged Lines", Some(balance), true) { + attrs = Some(json!({"balanceRagged": !balance})); + } + let optical = p["opticalMargin"].as_bool().unwrap_or(false); + if item(ui, "Optical Margin Alignment", Some(optical), true) { + attrs = Some(json!({"opticalMargin": !optical})); + } + ui.separator(); + for (label, id) in [ + ("Justification…", "app.justificationDialog"), + ("Keep Options…", "app.keepOptionsDialog"), + ("Span Columns…", "app.spanColumnsDialog"), + ("Hyphenation…", "app.hyphenationDialog"), + ("Drop Caps and Nested Styles…", "app.nestedStylesDialog"), + ("GREP Styles…", "app.grepStylesDialog"), + ("Paragraph Rules…", "app.paragraphRulesDialog"), + ("Paragraph Borders and Shading…", "app.paragraphBordersDialog"), + ] { + if item(ui, label, None, true) { + command = Some(id); + } + } + ui.separator(); + if item(ui, "Bullets and Numbering…", None, true) { + command = Some("app.bulletsNumberingDialog"); + } + // Restart Numbering at 1, or Continue Numbering from the paragraph before, for a numbered list. + let numbered = p["listType"].as_str() == Some("numbers"); + let restarts = p["startAt"].is_u64(); + let label = match (numbered, restarts) { + (false, _) => "Restart/Continue Numbering", + (true, false) => "Restart Numbering", + (true, true) => "Continue Numbering", + }; + if item(ui, label, None, numbered) { + attrs = Some(json!({"startAt": if restarts { Value::Null } else { json!(1) }})); + } + item(ui, "Convert Bullets and Numbering to Text", None, false); + if item(ui, "Define Lists…", None, true) { + command = Some("list.define"); + } + // Paragraph Direction (World-Ready): switching also mirrors left and right alignment. + ui.separator(); + let rtl = p["direction"] == "rightToLeft"; + for (on, label, dir) in [(!rtl, "Left to Right", "leftToRight"), (rtl, "Right to Left", "rightToLeft")] { + if item(ui, label, Some(on), true) && !on { + let align = match serde_json::from_value(p["align"].clone()).unwrap_or_default() { + Align::Left => Align::Right, + Align::Right => Align::Left, + a => a, + }; + attrs = Some(json!({"direction": dir, "align": align})); + } + } + if let Some(attrs) = attrs { + let _ = app.run("type.para", json!({"attrs": attrs})); + } + // As a menu choice: Define Lists… and the other commands open their dialogs. + if let Some(id) = command { + crate::menus::activate(app, id, &Value::Null); } - let ps = a["paragraphStyle"].as_str().unwrap_or("").to_string(); - let ov = a["paraOverrides"].as_u64().unwrap_or(0) + a["charOverrides"].as_u64().unwrap_or(0); - ui.horizontal(|ui| { - caption(ui, crate::i18n::tr(&app.ui.language, "Style")); - super::para_style_picker(app, ui, &ps, ov > 0, 190.0); - }); } /// Stroke panel: weight, cap, miter limit, join, alignment, type, arrowheads, gap colour. @@ -2900,9 +3160,18 @@ pub fn digit_label(lang: &str, o: (&str, &str, &str)) -> String { if o.2.is_empty() { name.to_string() } else { format!("{name} {}", o.2) } } +/// The least value a paragraph field takes: the first and last line indents may be negative (a +/// hanging line, a last line past the right indent), as in InDesign; the others not. +pub(crate) fn para_field_min(key: &str) -> f64 { + if matches!(key, "firstLineIndent" | "lastLineIndent") { -10_000.0 } else { 0.0 } +} + #[cfg(test)] mod tests { - use egui_kittest::{Harness, kittest::Queryable}; + use egui_kittest::{ + Harness, + kittest::{NodeT, Queryable}, + }; use super::*; @@ -2974,6 +3243,18 @@ mod tests { } /// With the caret in text, the Stroke panel offers the text's stroke options, not an empty panel. + /// The first and last line indents take negative values, as InDesign's (and the Paragraph + /// panel's) do; the left and right indents and the spacing don't. + #[test] + fn last_line_indent_may_be_negative() { + for key in ["firstLineIndent", "lastLineIndent"] { + assert!(super::para_field_min(key) < 0.0, "{key}"); + } + for key in ["leftIndent", "rightIndent", "spaceBefore", "spaceAfter"] { + assert_eq!(super::para_field_min(key), 0.0, "{key}"); + } + } + #[test] fn stroke_panel_shows_the_text_stroke_options_while_text_is_selected() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); @@ -3089,6 +3370,9 @@ mod tests { app, ); h.run_steps(3); + assert!(h.query_by_label("Language").is_none(), "under •••, as in InDesign"); + h.get_all_by_label("More Options").next().unwrap().click(); + h.run_steps(3); h.get_by_label("Language").click(); h.run_steps(2); h.get_by_label("French").click(); @@ -3096,6 +3380,166 @@ mod tests { assert_eq!(language(h.state_mut()), "French"); } + /// Properties › Character and Paragraph •••, as in InDesign: the sections expand in place, + /// with the scales, baseline shift, skew, language, OpenType and the case buttons, and the + /// indents, spacing and drop caps. + /// Properties › Paragraph ••• on a First Line Only paragraph: Align to Baseline Grid shows + /// pressed, and a click turns alignment off instead of making it All Lines. + #[test] + fn more_options_grid_button_keeps_first_line_only() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "If fine plants", "caret": true})).unwrap(); + app.run("edit.selectAll", json!({})).unwrap(); + app.run("type.para", json!({"attrs": {"gridAlign": "firstLineOnly"}})).unwrap(); + let mut h = panel_harness(app, show); + h.run_steps(3); + h.get_all_by_label("More Options").nth(1).unwrap().click(); + h.run_steps(3); + assert_eq!(h.get_by_label("Align to Baseline Grid").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True)); + assert_eq!(text_attrs(h.state_mut()).unwrap()["para"]["gridAlign"], "firstLineOnly"); + h.get_by_label("Align to Baseline Grid").click(); + h.run_steps(3); + assert_eq!(text_attrs(h.state_mut()).unwrap()["para"]["gridAlign"], "none"); + } + + #[test] + fn more_options_expand_character_and_paragraph_in_place() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "If fine plants", "caret": true})).unwrap(); + app.run("edit.selectAll", json!({})).unwrap(); + let mut h = panel_harness(app, show); + h.run_steps(3); + for l in ["Vertical Scale", "Small Caps", "Drop Cap Number of Lines", "Space Before"] { + assert!(h.query_by_label(l).is_none(), "{l} hidden until •••"); + } + h.get_all_by_label("More Options").next().unwrap().click(); + h.run_steps(3); + h.get_by_label("Small Caps").click(); + h.run_steps(3); + let _ = h.get_by_label("OpenType"); + let _ = h.get_by_label("Language"); + h.get_all_by_label("More Options").nth(1).unwrap().click(); + h.run_steps(3); + let _ = h.get_by_label("Hyphenate"); + h.get_by_label("Align to Baseline Grid").click(); + h.run_steps(3); + let a = text_attrs(h.state_mut()).unwrap(); + assert_eq!(a["chars"]["capitalization"], "smallCaps"); + assert_eq!(a["para"]["gridAlign"], "allLines"); + // ••• again folds the section. + h.get_all_by_label("More Options").next().unwrap().click(); + h.run_steps(3); + assert!(h.query_by_label("Small Caps").is_none()); + } + + /// The Paragraph panel on a First Line Only paragraph: Align to Baseline Grid shows pressed + /// and keeps First Line Only, and the panel menu checks Only Align First Line to Grid. + #[test] + fn paragraph_panel_keeps_first_line_only() { + let mut app = text_app(); + app.run("type.para", json!({"attrs": {"gridAlign": "firstLineOnly"}})).unwrap(); + let mut h = panel_harness(app, paragraph_panel); + h.run_steps(3); + assert_eq!(h.get_by_label("Align to Baseline Grid").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True)); + h.get_by_label("Align to Baseline Grid").click(); + h.run_steps(3); + assert_eq!(text_attrs(h.state_mut()).unwrap()["para"]["gridAlign"], "firstLineOnly"); + h.get_by_label("☰").click(); + h.run_steps(3); + let _ = h.get_by_label("✓ Only Align First Line to Grid"); + } + + #[test] + fn paragraph_panel_menu_define_lists_opens_its_dialog() { + let mut h = panel_harness(text_app(), paragraph_panel); + h.run_steps(3); + h.get_by_label("☰").click(); + h.run_steps(3); + h.get_by_label("Define Lists…").click(); + h.run_steps(3); + assert!(h.state_mut().ui.dialog.is_some(), "Define Lists"); + } + + #[test] + fn paragraph_panel_is_laid_out_as_indesigns_with_its_panel_menu() { + let mut h = panel_harness(text_app(), paragraph_panel); + h.run_steps(3); + for l in [ + "Left Indent", + "Right Indent", + "First Line Left Indent", + "Last Line Right Indent", + "Space Before", + "Space After", + "Space Between Paragraphs Using Same Style", + "Drop Cap Number of Lines", + "Drop Cap One or More Characters", + "Paragraph Shading", + "Paragraph Border", + "Hyphenate", + "Do Not Align to Baseline Grid", + ] { + let _ = h.get_by_label(l); + } + let para = |h: &mut Harness<'static, DesignApp>| text_attrs(h.state_mut()).unwrap()["para"].clone(); + // Space Between Paragraphs Using Same Style starts at Ignore; a value is set, and emptying + // the field ignores it again. + assert!(para(&mut h)["sameParaStyleSpacing"].is_null()); + let field = h.get_all_by_role(egui::accesskit::Role::TextInput).nth(6).unwrap(); + field.focus(); + field.type_text("6 pt"); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(para(&mut h)["sameParaStyleSpacing"], 6.0); + h.get_all_by_role(egui::accesskit::Role::TextInput).nth(6).unwrap().focus(); + h.run_steps(1); + for _ in 0..8 { + h.key_press(egui::Key::Backspace); + } + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert!(para(&mut h)["sameParaStyleSpacing"].is_null(), "Ignore"); + // Shading on, then the grid buttons and Only Align First Line to Grid from the menu. + h.get_by_label("Shading").click(); + h.run_steps(3); + assert_eq!(para(&mut h)["shadingOn"], true); + h.get_by_label("Align to Baseline Grid").click(); + h.run_steps(3); + assert_eq!(para(&mut h)["gridAlign"], "allLines"); + h.get_by_label("☰").click(); + h.run_steps(3); + for l in [ + " Balance Ragged Lines", + "Keep Options…", + "Drop Caps and Nested Styles…", + "GREP Styles…", + "Define Lists…", + "Restart/Continue Numbering", + ] { + let _ = h.get_by_label(l); + } + h.get_by_label(" Only Align First Line to Grid").click(); + h.run_steps(3); + assert_eq!(para(&mut h)["gridAlign"], "firstLineOnly"); + h.get_by_label("Do Not Align to Baseline Grid").click(); + h.run_steps(3); + assert_eq!(para(&mut h)["gridAlign"], "none"); + // Keep Options… opens the selected paragraphs' Keep Options, and OK applies them. + h.get_by_label("☰").click(); + h.run_steps(3); + h.get_by_label("Keep Options…").click(); + h.run_steps(3); + let app = h.state_mut(); + let d = app.ui.dialog.as_mut().expect("Keep Options"); + assert_eq!((d.id.as_str(), d.s("section").as_str()), ("paragraphLocal", "keep")); + d.fields.insert("p.keepWithNext".into(), json!(2)); + crate::dialogs::confirm(app).unwrap(); + assert_eq!(para(&mut h)["keepWithNext"], 2); + } + fn kerning(app: &mut DesignApp) -> Value { text_attrs(app).unwrap()["chars"]["kerning"].clone() } @@ -3359,6 +3803,35 @@ mod tests { h } + /// The course's chapter 21: Properties' Bulleted List button gives the selected paragraphs + /// bullets with InDesign's hanging indent; pressed again, both go. + #[test] + fn the_bulleted_list_button_applies_a_hanging_indent() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app + .run( + "frame.create", + json!({"rect": [72, 72, 300, 200], "content": "text", "text": "4 eggs +1 cup milk"}), + ) + .unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 15})).unwrap(); + let mut h = panel(app); + h.get_by_label("Bulleted List").click(); + h.run_steps(3); + let a = h.state_mut().run("type.selectionAttrs", json!({})).unwrap(); + assert_eq!( + (a["para"]["listType"].as_str(), a["para"]["leftIndent"].as_f64(), a["para"]["firstLineIndent"].as_f64()), + (Some("bullets"), Some(18.0), Some(-18.0)) + ); + assert_eq!(crate::menus::checked(h.state(), "list.bullets", &json!({})), Some(true), "Type › Apply Bullets shows it"); + h.get_by_label("Bulleted List").click(); + h.run_steps(3); + let a = h.state_mut().run("type.selectionAttrs", json!({})).unwrap(); + assert_eq!((a["para"]["listType"].as_str(), a["para"]["leftIndent"].as_f64()), (Some("none"), Some(0.0))); + } + #[test] fn several_shapes_show_pathfinder_and_intersect_combines_them() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 3e7030e87..878904a5f 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -18,53 +18,63 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { // Unnamed colours (mixed in the Color panel) aren't listed until Add to Swatches; swatches in // colour groups are listed under their folder. let grouped: Vec<&str> = doc.color_groups.iter().flat_map(|g| g.swatches.iter().map(String::as_str)).collect(); - for sw in doc.swatches.iter().filter(|w| !w.hidden && !grouped.contains(&w.name.as_str())) { - swatch_row(app, ui, &doc, sw, 0.0, fill_cur.as_deref()); - } - for g in &doc.color_groups { - let open_id = egui::Id::new(("color_group_open", &g.name)); - let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true); - let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &g.name)); - if resp.hovered() { - ui.painter().rect_filled(row, 0.0, t.hover); + // A gradient dragged from the Gradient panel's thumbnail is dropped on the list, colour + // groups included. + let (_, dropped) = ui.dnd_drop_zone::(egui::Frame::NONE, |ui| { + for sw in doc.swatches.iter().filter(|w| !w.hidden && !grouped.contains(&w.name.as_str())) { + swatch_row(app, ui, &doc, sw, 0.0, fill_cur.as_deref()); } - icons::paint( - ui.painter(), - egui::Rect::from_min_size(row.min + vec2(2.0, 3.0), vec2(16.0, 16.0)), - if open { "chevron-down" } else { "chevron-right" }, - t.text_dim, - ); - // A folder. - let f = egui::Rect::from_min_size(row.min + vec2(20.0, 5.0), vec2(16.0, 12.0)); - ui.painter().rect_stroke(f, 1.5, egui::Stroke::new(1.2, t.text_dim), egui::StrokeKind::Inside); - ui.painter().line_segment([f.left_top() + vec2(1.0, 3.0), f.right_top() + vec2(-1.0, 3.0)], egui::Stroke::new(1.0, t.text_dim)); - ui.painter().text(row.min + vec2(42.0, 11.0), egui::Align2::LEFT_CENTER, &g.name, egui::FontId::proportional(12.5), t.text); - // A double click's first click folded the group: it stays as it was. - if resp.clicked() { - open = !open; - ui.data_mut(|d| d.insert_temp(open_id, open)); - } - if resp.double_clicked() { - let _ = app.run("app.colorGroupOptionsDialog", json!({"name": g.name})); - } - resp.context_menu(|ui| { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Color Group Options…"))).clicked() { + for g in &doc.color_groups { + let open_id = egui::Id::new(("color_group_open", &g.name)); + let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true); + let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &g.name)); + if resp.hovered() { + ui.painter().rect_filled(row, 0.0, t.hover); + } + icons::paint( + ui.painter(), + egui::Rect::from_min_size(row.min + vec2(2.0, 3.0), vec2(16.0, 16.0)), + if open { "chevron-down" } else { "chevron-right" }, + t.text_dim, + ); + // A folder. + let f = egui::Rect::from_min_size(row.min + vec2(20.0, 5.0), vec2(16.0, 12.0)); + ui.painter().rect_stroke(f, 1.5, egui::Stroke::new(1.2, t.text_dim), egui::StrokeKind::Inside); + ui.painter().line_segment([f.left_top() + vec2(1.0, 3.0), f.right_top() + vec2(-1.0, 3.0)], egui::Stroke::new(1.0, t.text_dim)); + ui.painter().text(row.min + vec2(42.0, 11.0), egui::Align2::LEFT_CENTER, &g.name, egui::FontId::proportional(12.5), t.text); + // A double click's first click folded the group: it stays as it was. + if resp.clicked() { + open = !open; + ui.data_mut(|d| d.insert_temp(open_id, open)); + } + if resp.double_clicked() { let _ = app.run("app.colorGroupOptionsDialog", json!({"name": g.name})); - ui.close(); } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Ungroup Color Group"))).clicked() { - let _ = app.run("swatch.ungroupColorGroup", json!({"name": g.name})); - ui.close(); - } - }); - if open { - for n in &g.swatches { - if let Some(sw) = doc.swatches.iter().find(|w| w.name == *n) { - swatch_row(app, ui, &doc, sw, 16.0, fill_cur.as_deref()); + resp.context_menu(|ui| { + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Color Group Options…"))).clicked() { + let _ = app.run("app.colorGroupOptionsDialog", json!({"name": g.name})); + ui.close(); + } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Ungroup Color Group"))).clicked() { + let _ = app.run("swatch.ungroupColorGroup", json!({"name": g.name})); + ui.close(); + } + }); + if open { + for n in &g.swatches { + if let Some(sw) = doc.swatches.iter().find(|w| w.name == *n) { + swatch_row(app, ui, &doc, sw, 16.0, fill_cur.as_deref()); + } } } } + }); + // An unnamed gradient becomes "New Gradient Swatch"; one that is a swatch already stays one. + if let Some(g) = dropped + && doc.swatch(&g.0).is_some_and(|w| w.hidden) + { + let _ = app.run("swatch.new", json!({"swatch": g.0})); } ui.add_space(6.0); // InDesign's bottom row: New Color Group, New Swatch, Delete Swatch (the one last clicked). @@ -575,6 +585,11 @@ fn spectrum(app: &mut DesignApp, ui: &mut egui::Ui, target: &str) { #[derive(Clone, Debug)] pub struct SwatchDrag(pub String); +/// The selection's gradient dragged from the Gradient panel's thumbnail (its swatch name): the +/// Swatches panel takes it as a new gradient swatch. +#[derive(Clone, Debug)] +pub struct GradientDrag(pub String); + /// A colour in `object.gradient` form, keeping its model (CMYK stays CMYK). fn color_json(c: &designcraft_color::Color) -> serde_json::Value { use designcraft_color::Color; @@ -680,8 +695,18 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let full = ui.available_width().max(60.0); ui.add_enabled_ui(has_item, |ui| { ui.horizontal(|ui| { - let (r, resp) = ui.allocate_exact_size(vec2(34.0, 34.0), Sense::click()); + let (r, resp) = ui.allocate_exact_size(vec2(34.0, 34.0), Sense::click_and_drag()); thumbnail(ui.painter(), r.shrink(1.0), &g, t.field_border); + // Drag the gradient onto the Swatches panel to keep it as a swatch. + if is_gradient { + resp.dnd_set_drag_payload(GradientDrag(fill.swatch.clone())); + if resp.dragged() + && let Some(p) = ui.ctx().pointer_interact_pos() + { + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("gradient_drag"))); + thumbnail(&painter, egui::Rect::from_min_size(p + vec2(8.0, 8.0), vec2(18.0, 18.0)), &g, t.field_border); + } + } // Clicking the thumbnail applies the gradient shown (InDesign's way to start one). if resp.clicked() && !is_gradient { send = Some(json!({})); @@ -1025,6 +1050,64 @@ mod gradient_panel_tests { assert_eq!(g.stops[1].color, Color::cmyk(1.0, 0.0, 0.0, 0.0)); assert!((g.stops[1].offset - 0.5).abs() < 0.05, "{}", g.stops[1].offset); } + + /// The Gradient panel's thumbnail drags the selection's gradient onto the Swatches panel, + /// which keeps it as a gradient swatch. + #[test] + fn a_gradient_dragged_onto_the_swatches_becomes_a_swatch() { + let mut app = app_with_frame(); + app.run("object.gradient", json!({})).unwrap(); + app.run("window.panel.float", json!({"panel": "gradient", "x": 300, "y": 120})).unwrap(); + app.run("window.panel.float", json!({"panel": "swatches", "x": 620, "y": 120})).unwrap(); + let named = + |app: &DesignApp| app.session.doc().unwrap().doc.swatches.iter().filter(|w| !w.hidden).map(|w| w.name.clone()).collect::>(); + let before = named(&app); + let mut h = crate::test_window::open(app, vec2(1400.0, 900.0)); + h.run_steps(6); + drag_gradient_to(&mut h, "[Black]"); + let after = named(&h.state().app); + assert_eq!(after.len(), before.len() + 1); + assert_eq!(after.last().map(String::as_str), Some("New Gradient Swatch")); + let d = &h.state().app.session.doc().unwrap().doc; + assert!(matches!(d.swatch("New Gradient Swatch").map(|w| &w.value), Some(SwatchValue::Gradient { .. }))); + let id = h.state().app.session.doc().unwrap().selection.items[0]; + assert_eq!(d.item(id).unwrap().fill.swatch, "New Gradient Swatch", "the object keeps it, now by name"); + } + + /// Drag the Gradient panel's thumbnail onto the Swatches panel row labelled `label`. + fn drag_gradient_to(h: &mut egui_kittest::Harness<'static, crate::test_window::Window>, label: &str) { + use egui::{Event, Modifiers, PointerButton}; + // The thumbnail sits left of the Type row. + let ty = h.get_by_label("Type:").rect(); + let from = egui::pos2(ty.min.x - 21.0, ty.min.y + 12.0); + let to = h.get_by_label(label).rect().center(); + h.event(Event::PointerMoved(from)); + h.step(); + h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for i in 1..=10 { + h.event(Event::PointerMoved(from + (to - from) * (i as f32 / 10.0))); + h.step(); + } + h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(4); + } + + /// A colour group's row is part of the list too: a gradient dropped on it becomes a swatch. + #[test] + fn a_gradient_dropped_on_a_colour_group_becomes_a_swatch() { + let mut app = app_with_frame(); + app.run("object.gradient", json!({})).unwrap(); + let group = app.run("swatch.newColorGroup", json!({})).unwrap()["name"].as_str().unwrap().to_string(); + app.run("window.panel.float", json!({"panel": "gradient", "x": 300, "y": 120})).unwrap(); + // Tall enough to show the group's row below the swatches. + app.run("window.panel.float", json!({"panel": "swatches", "x": 620, "y": 120, "height": 700})).unwrap(); + let mut h = crate::test_window::open(app, vec2(1400.0, 900.0)); + h.run_steps(6); + drag_gradient_to(&mut h, &group); + let d = &h.state().app.session.doc().unwrap().doc; + assert!(matches!(d.swatch("New Gradient Swatch").map(|w| &w.value), Some(SwatchValue::Gradient { .. })), "dropped on the group's row"); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 7b2364ffe..666394e09 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -446,9 +446,9 @@ pub(crate) fn add_system_fallbacks(fonts: &mut FontDefinitions, system: &[System /// The UI fonts: the app's own, then the craft-fonts faces from `craft` (empty without /// `CRAFT_FONTS_DIR`) as fallbacks at the end of every family: Japanese (BIZ UDPGothic first) and -/// Simplified Chinese, in the interface language's order, then Arabic. The `arabic` families +/// Simplified Chinese, in the interface language's order, then Arabic and Georgian. The `arabic` families /// put the Arabic face first for right-to-left runs. egui has no system-font discovery, so -/// without craft-fonts CJK and Arabic UI text has no glyphs. +/// without craft-fonts CJK, Arabic and Georgian UI text has no glyphs. pub(crate) fn font_definitions(craft: &'static [designcraft_fonts::CraftFont], lang: &str) -> FontDefinitions { let mut fonts = FontDefinitions::default(); let add = |fonts: &mut FontDefinitions, name: &str, data: &'static [u8]| { @@ -466,12 +466,13 @@ pub(crate) fn font_definitions(craft: &'static [designcraft_fonts::CraftFont], l japanese.sort_by_key(|f| (f.family != "BIZ UDPGothic", f.style != "Regular")); let chinese = pick("Hans"); let arabic = pick("Arab"); + let georgian = pick("Geor"); let name = |f: &designcraft_fonts::CraftFont| format!("craft-{}-{}", f.family, f.style); for f in japanese.iter().chain(&chinese) { // CJK faces sit on the Latin baseline (see `baseline_tweak`). fonts.font_data.insert(name(f), Arc::new(FontData { tweak: baseline_tweak(f.bytes, 0), ..FontData::from_static(f.bytes) })); } - for f in &arabic { + for f in arabic.iter().chain(&georgian) { add(&mut fonts, &name(f), f.bytes); } // Han characters take the forms of the interface language: Chinese faces first for Chinese. @@ -483,12 +484,16 @@ pub(crate) fn font_definitions(craft: &'static [designcraft_fonts::CraftFont], l ja.sort_by_key(|f| (f.family != "BIZ UDPGothic", f.style != "Bold")); } let (first, second) = if zh { (&chinese, &ja) } else { (&ja, &chinese) }; - stack.extend(first.iter().chain(second).chain(&arabic).map(|f| name(f))); + stack.extend(first.iter().chain(second).chain(&arabic).chain(&georgian).map(|f| name(f))); } // Arabic runs (rtl.rs): the Arabic face first, so letters and spaces shape as one run. for family in ["arabic", "arabic-semibold"] { let ui = if family == "arabic" { "ui" } else { "ui-semibold" }; - let stack = arabic.iter().map(|f| name(f)).chain([ui.to_string(), "ui".to_string()]).chain(japanese.iter().chain(&chinese).map(|f| name(f))); + let stack = arabic + .iter() + .map(|f| name(f)) + .chain([ui.to_string(), "ui".to_string()]) + .chain(japanese.iter().chain(&chinese).chain(&georgian).map(|f| name(f))); let mut seen = std::collections::HashSet::new(); fonts.families.insert(FontFamily::Name(family.into()), stack.filter(|n| seen.insert(n.clone())).collect()); } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index fb9876aa2..e820e21a8 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -80,6 +80,10 @@ pub struct NumField<'a> { range: (f64, f64), enabled: bool, parens: bool, + /// The widget naming the field (an icon or caption beside it), for accessibility. + label: Option, + /// What an unset value shows (InDesign's "Ignore"), and the first of the presets. + empty: Option<&'a str>, } impl<'a> NumField<'a> { @@ -90,7 +94,20 @@ impl<'a> NumField<'a> { Self::new(id, value, NumKind::Number { suffix, decimals }) } fn new(id: &'a str, value: Option, kind: NumKind<'a>) -> Self { - NumField { id, value, kind, width: 72.0, spinner: false, presets: &[], step: None, range: (f64::MIN, f64::MAX), enabled: true, parens: false } + NumField { + id, + value, + kind, + width: 72.0, + spinner: false, + presets: &[], + step: None, + range: (f64::MIN, f64::MAX), + enabled: true, + parens: false, + label: None, + empty: None, + } } /// Total width (spinner and preset segments included). pub fn width(mut self, w: f32) -> Self { @@ -130,6 +147,19 @@ impl<'a> NumField<'a> { self.enabled = on; self } + /// The editor is labelled by widget `id` (an icon or caption that names the field), so + /// assistive technology reads the field's name with its value. + pub fn labelled_by(mut self, id: egui::Id) -> Self { + self.label = Some(id); + self + } + + /// An unset value shows `text` (dimmed), and the preset menu offers it first: picking it, or + /// emptying the field, commits `Some(None)` from [`NumField::show_or_clear`]. + pub fn empty_text(mut self, text: &'a str) -> Self { + self.empty = Some(text); + self + } /// Draw the field; returns the new value when the user commits an edit (Enter, focus loss, /// a spinner click, ↑/↓ while focused (Shift = ×10) or a preset). @@ -194,13 +224,16 @@ impl<'a> NumField<'a> { // Value. ui.painter().rect_filled(text_rect, 0.0, bg); let mut buf: String = ui.data(|d| d.get_temp::(key)).unwrap_or_else(|| self.value.map(|v| self.fmt(v)).unwrap_or_default()); - let te = egui::TextEdit::singleline(&mut buf) + let mut te = egui::TextEdit::singleline(&mut buf) .id(focused_id) .frame(egui::Frame::NONE) .font(label_font()) .text_color(fg) .margin(egui::Margin::ZERO) .vertical_align(egui::Align::Center); + if let Some(empty) = self.empty { + te = te.hint_text(egui::RichText::new(empty).font(label_font()).color(t.text_dim)); + } // The field already allocated its slot. A scoped allocation here would consume another // grid cell and enlarge toolbar rows; the value editor must stay inside that same slot. let value_rect = Rect::from_min_max(text_rect.min + vec2(5.0, 1.0), text_rect.max - vec2(1.0, 1.0)); @@ -210,6 +243,10 @@ impl<'a> NumField<'a> { editor_ui.disable(); } let r = editor_ui.put(value_rect, te); + let r = match self.label { + Some(id) => r.labelled_by(id), + None => r, + }; if r.has_focus() { // ↑/↓ step the value while typing. let (up, down, shift) = ui.input_mut(|i| { @@ -227,8 +264,14 @@ impl<'a> NumField<'a> { ui.data_mut(|d| d.insert_temp(key, buf.clone())); } if r.lost_focus() { - if let Some(v) = self.parse(&buf).map(clamp) - && self.value.is_none_or(|old| (old - v).abs() > 1e-9) + // An automatic value (in parentheses) typed over commits even when it's the same + // number: typing "14.4 pt" over "(14.4 pt)" makes it a fixed value. Escape reverts + // whatever was typed, as in InDesign. + let escaped = ui.input(|i| i.key_pressed(egui::Key::Escape)); + let typed_over_auto = self.parens && !buf.trim_start().starts_with('('); + if escaped { + } else if let Some(v) = self.parse(&buf).map(clamp) + && (typed_over_auto || self.value.is_none_or(|old| (old - v).abs() > 1e-9)) { out = Some(v); } else if buf.trim().is_empty() && self.value.is_some() { @@ -253,6 +296,12 @@ impl<'a> NumField<'a> { if let Some(resp) = preset_resp { egui::Popup::menu(&resp).show(|ui| { ui.set_min_width(self.width); + if let Some(empty) = self.empty + && ui.selectable_label(self.value.is_none(), empty).clicked() + { + cleared = self.value.is_some(); + ui.close(); + } for v in self.presets { if ui.selectable_label(self.value.is_some_and(|c| (c - v).abs() < 1e-9), self.kind.format(*v)).clicked() { out = Some(*v); @@ -382,6 +431,8 @@ pub fn more_options(ui: &mut Ui, language: &str) -> Response { let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 14.0), Sense::hover()); let r = Rect::from_min_max(pos2(row.max.x - 26.0, row.min.y), pos2(row.max.x - 4.0, row.max.y)); let resp = ui.interact(r, ui.id().with(("more", row.min.y as i32)), Sense::click()); + let label = crate::i18n::tr(language, "More Options"); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); for i in 0..3 { ui.painter().circle_filled(pos2(r.min.x + 4.0 + i as f32 * 7.0, r.center().y), 1.9, if resp.hovered() { t.text_strong } else { t.icon }); } @@ -567,6 +618,8 @@ pub fn checkbox(ui: &mut Ui, on: &mut bool, label: &str) -> Response { *on = !*on; resp.mark_changed(); } + let checked = *on; + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, checked, label)); resp } diff --git a/crates/ui-egui/tests/it/color_profiles_restart.rs b/crates/ui-egui/tests/it/color_profiles_restart.rs new file mode 100644 index 000000000..ad7ebcc75 --- /dev/null +++ b/crates/ui-egui/tests/it/color_profiles_restart.rs @@ -0,0 +1,43 @@ +//! A working space loaded with Edit › Color Settings › Load Profile… is active again after a +//! restart (#1474). Colour settings are process-wide, so this lives in the integration tests. + +use designcraft_color::cms::{self, ColorSettings}; +use designcraft_engine::Session; +use designcraft_ui_egui::{DesignApp, Services, UiState}; +use serde_json::json; + +/// The app's first frame, where saved settings are applied. +fn start(app: &mut DesignApp) { + let mut out = egui::Context::default().run_ui(egui::RawInput::default(), |ui| app.logic(&ui.ctx().clone())); + out.textures_delta.clear(); +} + +#[test] +fn loaded_working_space_survives_restart() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("restart-test.icc"); + let mut profile = cms::icc_out::generic_cmyk_profile().unwrap().clone(); + profile.description = "Restart Test CMYK".into(); + std::fs::write(&path, profile.with_encoded_bytes().to_bytes().as_slice()).unwrap(); + + // What ui.json holds after Load Profile… and choosing that profile in a previous session. + let chosen = ColorSettings { cmyk: "Restart Test CMYK".into(), ..Default::default() }; + let saved = json!({"colorProfiles": [path.to_string_lossy()], "colorSettings": chosen}); + + // This process has never loaded the profile: only the built-in ones are known. + assert!(!cms::profiles().iter().any(|p| p.name == chosen.cmyk)); + let mut app = DesignApp::new(Session::new(), Services::default()); + app.ui = serde_json::from_value::(saved).unwrap(); + start(&mut app); + let active = cms::active_settings(); + cms::set_active(&ColorSettings::default()).unwrap(); + assert_eq!(active, chosen, "the loaded working space is active again"); + + // A saved profile that can't be loaded any more is reported, not silently replaced. + let gone = json!({"colorProfiles": [dir.path().join("gone.icc").to_string_lossy()], "colorSettings": ColorSettings { cmyk: "Gone CMYK".into(), ..Default::default() }}); + let mut app = DesignApp::new(Session::new(), Services::default()); + app.ui = serde_json::from_value::(gone).unwrap(); + start(&mut app); + assert_eq!(cms::active_settings(), ColorSettings::default()); + assert!(app.ui.status.contains("Gone CMYK"), "{}", app.ui.status); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 2d07c9629..6950fc776 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -1,2 +1,4 @@ #[cfg(not(target_arch = "wasm32"))] +mod color_profiles_restart; +#[cfg(not(target_arch = "wasm32"))] mod native_job_lifetime_control; diff --git a/docs/agents.md b/docs/agents.md index 2b8d6e51e..566357fbe 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -63,6 +63,16 @@ The image must contain at most 16 million pixels. PDF/SVG graphics are unsupport not Adobe's palette algorithm. In the UI, click an image with the Color Theme Tool (Shift+I), or choose Color Theme… in the Swatches panel menu with one image selected. Cancel keeps the document unchanged. +## PDF/X-4 validation + +`designcraft-cli validate-pdf FILE.pdf` prints JSON with `valid` and `issues`, and exits non-zero +when a built-in check fails. Checks cover PDF version, output intent and profile, Info and XMP +identification, image interpolation, encryption, trim boxes, Type 3 fonts, device RGB under a CMYK +output intent, and unembedded fonts drawing visible text (including placed PDF forms). Render +mode 3 (invisible OCR text) is exempt from the font-embedding check. PDF/X-4 export reports these +issues as warnings; it does not embed or outline placed fonts. These checks are not a complete +external PDF/X certification. + ## Data merge `data.source.select {path, sheet?}` links a CSV/TSV/`.xlsx` file to the document (`data.source.update` re-reads it, diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 381498fb6..2a05973af 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (IDML liquid guide types, #872; .indd and .indt open through indd-utils; XLSX cached values preserve numeric precision and text; shared print spooler; Windows CI passes; content-indexed asset deduplication, #727) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (PDF/X-4 visible unembedded font detection, #909; IDML liquid guide types, #872; .indd and .indt open through indd-utils; XLSX cached values preserve numeric precision and text; shared print spooler; Windows CI passes; content-indexed asset deduplication, #727) · **Target:** Adobe InDesign 2026 (21.6) Every format InDesign 2026 reads or writes, with DesignCraft's support, fidelity and tests. The InDesign list comes from its bundle's `Info.plist` (`CFBundleDocumentTypes`, @@ -55,6 +55,14 @@ layer survive interchange; literal XML and round-trip tests are in | Print / PostScript | | ✓ printers, PPDs, separations | ~ PDF to CUPS/Windows (craft-print; no PostScript) | | 3% | | Package | folder | ✓ fonts, links, IDML, PDF, report | ✓ (#340: IDML inside links to absolute paths) | | 2% | +PDF/X-4 export warns about unembedded fonts that draw visible text, including inside placed PDF +forms; `designcraft-cli validate-pdf FILE.pdf` reports the same issues, with font names and pages +(#909). Invisible text (render mode 3) is exempt from this font-embedding check. Detection does not +automatically embed or outline fonts; embedding, substitution or outlining of placed-PDF fonts +remains missing. A document-wide operation limit and a form-nesting depth limit report incomplete +font checks rather than traversing pathological nesting. These built-in checks are not complete +external PDF/X certification. + ## Placed and imported content | Format | Ext | InDesign | DesignCraft | Notes | Weight | @@ -114,6 +122,7 @@ up to 30 points. | Date | Change | Summary | |---|---|---| +| 2026-10-19 | minor | PDF/X-4 export and `validate-pdf` report visible unembedded fonts by name and page, including placed forms; invisible text is exempt, operation- or depth-limited checks report incompleteness, and automatic font repair remains missing (#909) | | 2026-10-15 | minor | IDML retains liquid versus ruler guide types on document and parent pages, with literal XML and mixed-guide round-trip coverage; object liquid constraints remain unsupported (#872) | | 2026-10-13 | minor | Asset loading and compaction use content fingerprints with exact equality checks; tests cover equal-sized distinct buffers, shared copies, independent links and missing data (#727) | | 2026-10-13 | minor | `.indd` and `.indt` read through storytold/indd-utils; INDD remaining work is a real-file corpus (10–30 h); score unchanged until it is measured | diff --git a/docs/gaps.md b/docs/gaps.md index 5738d14c4..6723fe1e7 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DesignCraft falls short of InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 3: Package links are relative, #340; gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (gap 28 added: placed-PDF font repair remains after visible unembedded font detection, #909; gap 26 shrunk: autoflow fills the following pages before it adds pages at the end; gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 3: Package links are relative, #340; gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6) Every known shortfall against InDesign 2026, one entry each, ranked by how much it stops an InDesign professional from doing real work. This is the work list: agents pick from the top. @@ -123,6 +123,18 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · spot, marks and bleed, the options dialog with remembered settings (#196). - **Estimate:** 40–70 h. **Doc:** [file-format-parity.md](file-format-parity.md) +### 28. Placed-PDF font repair for PDF/X-4 (FF) +- **Missing:** embedding, substitution or outlining of unembedded fonts in placed PDFs; export + does not automatically embed or outline them. +- **Have:** PDF/X-4 export warnings and `validate-pdf` report unembedded fonts drawing visible + text, including inside placed PDF forms, with font names and pages (#909). Invisible text + (render mode 3) is exempt from this font-embedding check. A document-wide operation limit and a + form-nesting depth limit report incomplete font checks rather than traversing pathological + nesting. +- **Impact:** detection now ships, but a placed PDF with visible unembedded fonts still needs + font repair before it can meet PDF/X-4 requirements. +- **Doc:** [file-format-parity.md](file-format-parity.md) + ### 10. Printing (H, F) - **Missing:** PostScript, a printer-driver options UI, separations to device, print presets, or trapping. - **Have:** `file.print` exports selected pages/spreads with marks and bleed, then submits through @@ -190,6 +202,9 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · - **Estimate:** 20–35 h. ### 19. Placed graphics depth (FF) +- **Safety:** PDF visibility variants preserve catalog/configuration entries and nonzero-generation + references, redefining the existing catalog rather than choosing an arbitrary object number. + Encrypted, hybrid-reference and over-budget rewrites are rejected; no print-fidelity claim. - **Missing:** EPS is shown through its preview only (no PostScript interpreter); PSD layer comps beyond visibility; the Clipping Path Options dialog (tracing into a clip that keeps the frame, Threshold/Tolerance/Inset Frame preview, picking an alpha channel or a path saved in a @@ -206,13 +221,11 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · parallel story composition. - **Estimate:** 40–80 h. -### 26. Autoflow fills the following pages before it adds pages (F) -- **Missing:** Shift-click autoflow (and autoflow on Place) always adds new pages after the - story's last frame, one margin-sized frame each. InDesign first threads a frame per column on - the existing pages that follow, then adds pages at the end of the document. -- **Impact:** autoflowing into a document that already has pages inserts new pages in the - middle of it instead of using the empty pages. -- **Estimate:** 4–8 h. +### 26. Autoflow flows into the primary text frame of the pages that follow (F) +- **Missing:** autoflow threads a frame per column on the pages that follow, then adds pages at + the end of the document, as InDesign does. On a page whose parent has a primary text frame it + still sets a new frame instead of flowing into the primary one. +- **Estimate:** 2–4 h. ## Lower @@ -249,6 +262,8 @@ Cue. These are excluded from every percentage. | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Gap 26 shrunk: autoflow threads a frame per column on the pages that follow the story's last frame, then adds pages at the end of the document | +| 2026-10-19 | minor | Gap 28 added for remaining placed-PDF font embedding, substitution or outlining; visible unembedded font detection now ships in PDF/X-4 export and `validate-pdf`, with invisible text exempt and operation- or depth-limited checks reported incomplete (#909) | | 2026-10-16 | minor | Gap 27 closed: the loaded text cursor threads into a text frame inside a group (#1075) | | 2026-10-16 | trivial | Gap 13 shrunk: Greek hyphenates, so five languages do (#1077) | | 2026-10-13 | minor | Gap 16: Parent Options dialog and overrides of base items on a parent spread | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index fa75a20d8..bc209c118 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (Polish and Georgian interfaces; catalog counts remain the 2026-10-10 measurement) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (missing translations are reported, never a failure; catalog counts remain the 2026-10-10 measurement) · **Target:** Adobe InDesign 2026 (21.6) Per-language status of the DesignCraft interface and of document typesetting in each script. Script-specific typesetting detail: [cjk.md](cjk.md), [cjk-typography.md](cjk-typography.md), @@ -19,6 +19,10 @@ in the UI crate appear in no catalog (some are test data or proper names), so th somewhat higher and every percentage below is a slight overestimate. Help: DesignCraft has no help content in any language (Help ▸ opens the project's pages). +A missing translation never fails a test, a build or a release. `cargo xtask i18n-report` (also +a step of `cargo xtask ci`, so the list is in every gate log) prints how many interface strings +each language lacks, then the strings. + InDesign 2026 ships 25 interface localizations (`.lproj` folders: cs, da, de, el, en, en_GB, es, fi, fr, hu, it, ja, ko, nb, nl, pl, pt, ro, ru, sq, sv, tr, uk, zh_CN, zh_TW); Arabic and Hebrew come as Middle East editions with an English or French interface and right-to-left features. diff --git a/docs/mcp.md b/docs/mcp.md index 432a31794..581736489 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -189,7 +189,9 @@ and `text.move` return, a story's `length` in `get_story` and `inspect_document` `get_story`. A character outside ASCII takes 2 to 4 bytes (é 2, — and は 3), so the story `はただ商店` has length 15 and its second character starts at 3. An offset inside a character snaps back to the start of that character, except in `story.replaceRange`: there an offset inside a character, an `end` past the end of the story or a `start` after `end` -is an error that gives the story length in bytes, and the story is left unchanged. +is an error that gives the story length in bytes, and the story is left unchanged. A `find.find` or `find.next` match +that has a `cell` (`{table, row, col}`) is in that table cell's text, and its `start` / `end` count from the start of +the cell. ## Text and image fitting diff --git a/docs/pdf-backend.md b/docs/pdf-backend.md index 4cbc985d3..26d73aa77 100644 --- a/docs/pdf-backend.md +++ b/docs/pdf-backend.md @@ -1,7 +1,7 @@ # Shared PDF backend -DesignCraft pins `craft-pdf`, patched Krilla 0.8.2 and the four Hayro reader crates to -craft-libs revision `89dcbb3060377a26c99a6d80caa16084366704c4`. Existing `craft-ui` and `craft-print` revisions +DesignCraft pins `craft-pdf`, patched Krilla 0.8.2 and the five Hayro reader crates (with +`hayro-cmap`) to craft-libs revision `9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef`. Existing `craft-ui` and `craft-print` revisions remain unchanged. The reader migration is reviewed on top of the separate writer change. ## Writing @@ -49,6 +49,12 @@ are recorded separately in ASSETS.md. DesignCraft retains placement, layer-name policy, document state and the application render cache. Render output is premultiplied RGBA8. Page boxes use the same crop, rotation and UserUnit transform as raster output, including fractional placed-PDF dimensions. +Layer choices are validated for every selected PDF before any document/undo state changes; +a selection containing an unsupported or layerless PDF is refused rather than partially edited. +Stale persisted/relinked choices that cannot be honored suppress the graphic on canvas rather +than reveal hidden source content; resetting to file defaults remains available. All-content +cropping keeps the whole page when real optional-content state cannot be rewritten, while +layerless files and empty/null optional-content properties retain measured bounds. Layered-PDF export stops on failed layer rewriting, missing pages or reported incomplete content rather than saving an unchanged or incomplete placed page. Shared diagnostics only report known backend warnings; they do not certify all PDF fidelity. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index bb7c5eb80..6e676c470 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DesignCraft parity with Adobe InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (list buttons with the default hanging indent; custom anchored objects honour Relative to Spine; PDF/X-4 visible unembedded font detection, #909; autoflow fills the following pages first; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature @@ -211,18 +211,25 @@ table is the one summed. | Fill, stroke, colour | 100% | 60% | 20–35 | hex entry (#323); Assign Profiles / Convert to Profile; color-managed proofing depth; Adobe Color Themes OOS | | Effects & transparency | 100% | 50% | 20–30 | effects fidelity vs InDesign unmeasured; Clear Effects / Clear All Transparency; flattener depth | | Placing & links | 93% | 55% | 20–35 | EPS is preview-only (no PostScript interpreter); Edit Original; placed PDF layer visibility; INDD page place | -| Type & text frames | 99% | 45% | 40–60 | Primary Text Frame pages users can't delete (alpha blocker, #165); autoflow doesn't fill existing pages; no threading into grouped frames with the loaded cursor; caret formatting discarded (#289); Notes/Track Changes menus; frame grids | +| Type & text frames | 99% | 45% | 40–60 | Primary Text Frame pages users can't delete (alpha blocker, #165); autoflow sets new frames on following pages instead of flowing into their primary text frame; caret formatting discarded (#289); Notes/Track Changes menus; frame grids | | Typography | 100% | 45% | (typography dimension) | see [typography-parity.md](typography-parity.md) | | Styles | 100% | 60% | 15–25 | Paragraph Styles edit/delete reported failing (#169); style options depth | | Tables | 100% | 55% | 25–40 | anchored objects in cells not drawn (#163); Table ▸ Table Options submenus, Diagonal Lines, Edit Header/Footer | | Long documents | 100% | 45% | 30–50 | Books are shallow (sync, book pagination, book PDF/TOC/index); Table of Contents Styles | | Interactivity & digital | 100% | 40% | 30–50 | HTML export basic (#314); fixed-layout EPUB is page images; no animation, timing, media playback | -| Output & production | 92% | 55% | 40–70 | PDF/X-4 only (no X-1a/X-3), no PDF/print presets or `.joboptions`, print via craft-print (CUPS/Windows), no trapping; Output Preview panel | +| Output & production | 92% | 55% | 40–70 | PDF/X-4 only (no X-1a/X-3), with visible unembedded font detection (below); no PDF/print presets or `.joboptions`, print via craft-print (CUPS/Windows), no trapping; Output Preview panel | | XML & automation | 100% | 35% | (ecosystem dimension) | no ExtendScript/UXP; XML structure depth | | View & navigation | 100% | 65% | 10–20 | Navigator, display badges, Show/Hide extras (content grabber, live corners) | | Undo, history, saving | 100% | 70% | 5–10 | Undo/Redo name the action | | Accessibility | 100% | 50% | 15–25 | tagged PDF exists; PDF/UA validation, reading order depth, screen-reader support in the UI | +PDF/X-4 export warnings and `validate-pdf` report unembedded fonts drawing visible text, including +inside placed PDF forms, with font names and pages (#909). Invisible text (render mode 3) is exempt +from the font-embedding check. Fonts are not embedded or outlined automatically; embedding, +substitution or outlining of placed-PDF fonts remains missing ([gap 28](gaps.md#28-placed-pdf-font-repair-for-pdfx-4-ff)). +A document-wide operation limit and a form-nesting depth limit report incomplete font checks rather +than traversing pathological nesting. This is detection, not complete external PDF/X certification. + ## Remaining effort and calibration **Calibrated against this repo's history.** The first commit was 2026-10-01 09:00; by 2026-10-10 @@ -299,7 +306,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - 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; R/G/B, C/M/Y/K and hex fields; a CMYK colour stays CMYK unless R/G/B values are typed); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`. - Threading: click a selected text frame's in or out port (overset or not) to load the text cursor, then click a frame to thread it (stories merge), click empty space for a column-wide frame down to the bottom margin, or drag a frame; Shift-click autoflows, Alt-click stays loaded; click the linked frame or double-click a port to break the thread; Esc unloads. Link and unthread cursors show where a click threads or unthreads. `text.thread`, `text.unthread`, `text.removeFromThread`. -- 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 one frame per page column and adds pages 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 one frame per page column, on the pages that follow first, 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. @@ -336,7 +343,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - View ▸ Fit Selection in Window (⌥⌘=) and Find/Change ▸ Object (`find.objects`, `find.changeObjects`: by fill, stroke, weight, opacity, kind, layer, label). - Custom workspaces (`window.newWorkspace/deleteWorkspace/resetWorkspace`; workspace switcher shows the current one): bars and panel arrangement saved by name. Essentials Classic shows the Control panel and InDesign's iconic panel groups with names. The Window menu follows InDesign's: Arrange, Workspace, then the panels alphabetically with their Color, Editorial, Interactive, Object & Layout, Output, Styles, Type & Tables and Utilities groups. - Word/RTF Import Options (File ▸ Place with Import Options…, `place.styles`, `file.place {styleMap, styleConflicts}`): map imported styles onto the document's, use existing / redefine / auto-rename on conflicts, or remove formatting. -- Custom anchored objects (`anchored.insert/options {position: custom}`): placed relative to the anchor, frame, column, page margins or page edge with reference points and offsets, no space in the text, keep within column; IDML Anchored position. Their text wrap pushes aside the text of other stories they overlap and the lines of their own story after the anchor's line. +- Custom anchored objects (`anchored.insert/options {position: custom}`): placed relative to the anchor, frame, column, page margins or page edge with reference points and offsets, no space in the text, keep within column, Relative to Spine (mirrored on right-hand pages); IDML Anchored position. Their text wrap pushes aside the text of other stories they overlap and the lines of their own story after the anchor's line. - Variable font axes: Character panel sliders; any axis values (`fontStyle: "Bold {wght:650}"`) make that instance for layout, screen and PDF. - Image Import Options › Crop to for placed PDFs (`file.place {pdfCrop: crop|trim|bleed|art|media}`, Place PDF dialog): the frame shows that page box. - Stroke styles: striped (thick-thin… and custom bands), wavy and straight-hash strokes now draw on screen and in PDF; named custom stripe/dash/dot styles (`strokeStyle.new/delete/list`, Stroke panel Type list; IDML Striped/Dashed/DottedStrokeStyle). @@ -368,14 +375,14 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Object States (`states.create/show/rename/release/list`, Object States panel): multi-state objects show one state on screen and in PDF. - Buttons (`button.set/clear/list`, Buttons and Forms panel): go to page / next / previous / first / last / URL as PDF link annotations. Form fields too (`form.set/clear/list`: text fields, check boxes, combo and list boxes, signature fields; required, multiline, defaults) exported as AcroForm widgets through an incremental update (not in PDF/X or PDF/A). - Power Zoom (Hand tool: press and hold still for half a second, or Alt-press): zoom out to the spread, aim the red view rectangle, release to zoom back in there. -- Graphic cells (`table.placeGraphic`, `table.textCell`, Table › Convert Cell Type, Table panel): images in table cells, fitted or filled, clipped to the cell on screen and in PDF. +- Graphic cells (`table.placeGraphic`, `table.textCell`, Table › Convert Cell Type, Table panel): images in table cells, fitted or filled, clipped to the cell on screen and in PDF; IDML export writes them as an inline graphic in the cell. - Live Distribute (Space while dragging a selection handle; `transform.resize {distribute}`): objects keep their size and spread with the bounds. - Flex Layout (InDesign 2026; `flex.add`, `flex.convert`, `flex.remove`, `flex.set`, `flex.reorder`, Window › Flex Layout and the Properties panel's Flex Layout section): a frame lays out its objects in rows or columns by direction, wrap, justification (Flex Start, Center, Flex End, Space Between, Space Around, Space Evenly), the alignment grid, Fixed/Auto width and height, padding and spacing; aligned objects are detected on Add (direction, Auto, Space Between); resizing a container reflows it; dragging an object in one reorders it. Not yet: item sizing (Fixed/Auto/Fill Space per object), object styles with Flex Layout settings, IDML (a container exports as a frame with pasted-in objects), nested selection cues on the canvas. - Liquid Layout (`liquid.pageRule/object`, `guide.liquid`, `layout.createAlternate`, Liquid Layout panel): scale / re-center / guide-based / object-based page rules applied on page resize and Document Setup; dashed liquid guides; alternate layouts as resized page copies in a named section. Text in an alternate layout stays linked to the original stories (Links panel shows them out of date; `story.updateLink`). - Split Window / New Window (`window.split`, `window.newWindow`, Window ▸ Arrange): two side-by-side views of the document with their own zoom, scroll and render cache. New Window puts the second view in its own OS window (an egui viewport; a floating window on the web). - Transparency flattener (`file.exportPdf {flatten: high|medium|low|ppi}`, Edit ▸ Transparency Flattener Presets): each object involving transparency becomes an opaque image of its area over what's beneath it, so everything else stays vector (spreads with transparent parent items are rasterised whole). - Video and sound (place .mp4/.mov/.webm/.mp3/.wav…, `media.options/get`, Media panel): media frames show a poster or placeholder on screen, in print and in PDF, and export as HTML5 `