Merge remote-tracking branch 'origin/main' into direct-select-delete-image

# Conflicts:
#	crates/engine/src/tests.rs
This commit is contained in:
moppeeer committed 2026-10-23 23:42:19 +02:00
commit 245fb99a8d
140 files changed
+4385 -1271

No files matched your search

+13 -51
View File
@@ -1,10 +1,7 @@
# The CLAUDE.md quality gate (`cargo xtask ci`: fmt, clippy -D warnings, tests, assets, layers,
# wasm) on every pull request and every push to main. Today only path-filtered workflows run on
# pull requests, so most PRs land with no test run at all.
#
# Ported from wordcraft#800. Linux only; standard GitHub-hosted runner, no secrets.
name: CI
# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
# suite's shared gate workflow in craft-common-actions.
on:
push:
branches: [main]
@@ -14,54 +11,19 @@ on:
permissions:
contents: read
# A newer push to a PR branch cancels its older run; every push to main runs to the end.
concurrency:
group: ci-${{ github.ref }}
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
# Same craft-fonts commit as release.yml, so the font tests see what a release build embeds.
CRAFT_FONTS_REF: abb83316d96aa59c1cf64784289e378fe9fa5695
jobs:
gate:
name: cargo xtask ci
runs-on: ubuntu-24.04
timeout-minutes: 90
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: "0"
RUST_BACKTRACE: "1"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Check out craft-fonts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
persist-credentials: false
- name: Install Linux build dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev \
libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
- name: Install Rust (stable + clippy, rustfmt, wasm32)
run: |
rustup toolchain install stable --profile minimal --component clippy,rustfmt \
--target wasm32-unknown-unknown
rustup default stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Quality gate
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
run: cargo xtask ci
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
# Same craft-fonts commit as release.yml.
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
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
+6 -5
View File
@@ -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:
+2 -1
View File
@@ -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:
+5 -7
View File
@@ -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:
+6 -6
View File
@@ -40,12 +40,12 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| `docs/images/page-4.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI |
| `docs/images/page-cover.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI |
| `docs/images/ui-spread.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI |
| `crates/render/tests/golden/drop-shadow-spread.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
| `crates/render/tests/golden/drop-shadow-text.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
| `crates/render/tests/golden/drop-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
| `crates/render/tests/golden/feather.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
| `crates/render/tests/golden/inner-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
| `crates/render/tests/golden/outer-glow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
| `crates/render/tests/golden/drop-shadow-spread.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
| `crates/render/tests/golden/drop-shadow-text.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
| `crates/render/tests/golden/drop-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
| `crates/render/tests/golden/feather.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
| `crates/render/tests/golden/inner-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
| `crates/render/tests/golden/outer-glow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
| `assets/app-icon/LICENSE.txt` | (licence text) | this project | — | Licence for the app icon |
| `assets/app-icon/designcraft-1024.png` | DesignCraft project owner (drawn in ArtCraft) | rendered from `assets/app-icon/designcraft.svg` by `packaging/icons.sh` | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | App icon: calico cat in a polka-dot scarf (see `assets/app-icon/README.md`) |
| `assets/app-icon/designcraft-macos-512.png` | DesignCraft project owner (drawn in ArtCraft) | rendered from `assets/app-icon/designcraft.svg` by `packaging/icons.sh` | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | App icon: calico cat in a polka-dot scarf (see `assets/app-icon/README.md`) |
Generated
+21 -21
View File
@@ -942,7 +942,7 @@ dependencies = [
[[package]]
name = "designcraft"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"arboard",
"designcraft-engine",
@@ -964,7 +964,7 @@ dependencies = [
[[package]]
name = "designcraft-cli"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -980,7 +980,7 @@ dependencies = [
[[package]]
name = "designcraft-color"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"craft-cms",
"kurbo",
@@ -992,7 +992,7 @@ dependencies = [
[[package]]
name = "designcraft-compose"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"designcraft-color",
"designcraft-doc",
@@ -1012,7 +1012,7 @@ dependencies = [
[[package]]
name = "designcraft-doc"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"chrono",
"designcraft-color",
@@ -1025,7 +1025,7 @@ dependencies = [
[[package]]
name = "designcraft-engine"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"caseless",
"craft-atomic",
@@ -1064,7 +1064,7 @@ dependencies = [
[[package]]
name = "designcraft-epub"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"chrono",
"designcraft-color",
@@ -1074,7 +1074,7 @@ dependencies = [
[[package]]
name = "designcraft-fonts"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"core-text",
"harfrust 0.13.3",
@@ -1087,7 +1087,7 @@ dependencies = [
[[package]]
name = "designcraft-format"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"designcraft-doc",
"designcraft-images",
@@ -1099,7 +1099,7 @@ dependencies = [
[[package]]
name = "designcraft-geom"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"flo_curves",
"kurbo",
@@ -1112,7 +1112,7 @@ dependencies = [
[[package]]
name = "designcraft-idml"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1127,7 +1127,7 @@ dependencies = [
[[package]]
name = "designcraft-images"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"craft-cms",
"designcraft-color",
@@ -1142,7 +1142,7 @@ dependencies = [
[[package]]
name = "designcraft-mcp"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"designcraft-engine",
"designcraft-geom",
@@ -1155,7 +1155,7 @@ dependencies = [
[[package]]
name = "designcraft-pdf"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"chrono",
"craft-pdf",
@@ -1178,7 +1178,7 @@ dependencies = [
[[package]]
name = "designcraft-pptx"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"chrono",
"designcraft-color",
@@ -1196,7 +1196,7 @@ dependencies = [
[[package]]
name = "designcraft-render"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"craft-pdf",
"designcraft-color",
@@ -1215,7 +1215,7 @@ dependencies = [
[[package]]
name = "designcraft-textimport"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"designcraft-doc",
"quick-xml",
@@ -1224,7 +1224,7 @@ dependencies = [
[[package]]
name = "designcraft-tools"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1236,7 +1236,7 @@ dependencies = [
[[package]]
name = "designcraft-ui-egui"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"craft-ui",
"designcraft-color",
@@ -1263,7 +1263,7 @@ dependencies = [
[[package]]
name = "designcraft-web"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"designcraft-engine",
"designcraft-format",
@@ -5814,7 +5814,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"serde_json",
]
+6 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"]
[workspace.package]
version = "0.11.0"
version = "0.12.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -103,11 +103,16 @@ todo = "warn"
too_many_arguments = "allow"
type_complexity = "allow"
# Line tables keep file and line in panics and backtraces; full debug info
# for every test binary and dependency would multiply the target dir size.
# The test profile inherits both settings.
[profile.dev.package."*"]
opt-level = 2
debug = false
[profile.dev]
opt-level = 1
debug = "line-tables-only"
[profile.release]
opt-level = 3
+4
View File
@@ -8,6 +8,10 @@
</p>
<p align="center">
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.designcraft.png" width="128" height="128" alt="DesignCraft icon: an engraved cat in a polka-dot scarf on a green tile">
</p>
<h1 align="center">DesignCraft</h1>
<p align="center">
+13 -1
View File
@@ -42,7 +42,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 +301,8 @@ measured); Ready estimated.
## Languages
Interface strings translated, of 1,724 measured. Detail: [docs/localization-parity.md](docs/localization-parity.md).
A string without a translation shows in English and fails no check; `cargo xtask i18n-report`
(also a `cargo xtask ci` step) lists the missing strings per language.
| Language | Code | Status | Translated |
|---|---|---|---|
@@ -348,11 +350,21 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-23** · Save and export dialogs (Save As, Save a Copy, Export PDF and the other exports, Package, libraries and books) start in the active document's folder once it has been saved, and for a document never saved in the folder of the last file saved or exported, remembered across sessions (#1653). A folder deleted since falls back to the system's choice. No readiness percentage change.
- **2026-10-22** · Autoflow (Shift-click with the loaded text cursor, autoflow on Place, `text.thread {autoflow}`) fills the pages that follow before it adds any, as InDesign does: one frame per column on each following page, whatever else the page holds, then new pages at the end of the document. It inserted pages right after the story's last frame, in front of the document's own pages. Frames it set past the end of the text go again; a page the document had stays, and a page autoflow added only for such a frame goes with it. Smart Text Reflow of the primary text frame is unchanged. No readiness percentage change.
- **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change.
- **2026-10-23** · Translations: a missing string shows in English and fails no test; `cargo xtask i18n-report`, run by `cargo xtask ci`, prints the missing strings per language. Tests keep duplicate-key and placeholder checks and the language mechanics (lookup, context captions, plural captions, right-to-left). No readiness percentage change.
- **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change.
- **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change.
- **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change.
- **2026-10-22** · Paragraph rules: Rule Above's offset is measured from the first line's baseline to the rule's bottom edge, as in InDesign. Rules sat one ascent too high and crossed the previous paragraph's text (reported from an imported INDD document). No readiness percentage change.
- **2026-10-21** · Ruler guides are selected like objects (#1282): a click with the Selection tools selects a guide (also where it crosses an object), Shift-click adds or removes one, a new guide from the rulers is selected, a marquee that touches no object selects the guides it crosses, and Edit › Select All Guides (⌥⌘G, `guide.selectAll`) selects every unlocked guide on the spread. Selected guides draw thicker in their layer's color, drag together (all of them shown moving), nudge with the arrow keys and go with Delete, each as one undo step (`guide.select`, `guide.deselect`, `guide.moveBy`, `guide.delete {}`). With one guide selected the Control bar's X (vertical guide) or Y (horizontal guide) field and Properties › Transform show where it is, as the rulers measure, and typing a value moves it there. No readiness percentage change.
- **2026-10-17** · PDF/X-4 export warns about fonts drawing visible text without an embedded
font program, including placed PDF forms; `validate-pdf` reports the font and page (#909).
Invisible OCR text is exempt. Detection only: fonts are not embedded or outlined automatically.
Repeated forms reuse context-aware font findings; a document-wide operation limit and a form-nesting depth limit
report incomplete checks instead of allowing excessive nested-form traversal.
No readiness percentage change.
- **2026-10-16** · Selection: drag a column guide to move its gutter, as in InDesign (#1096). Both guides of the gutter move together and keep its width, stop at the neighbouring guides and the margins, and snap to the document grid, guides and objects, or with Shift to the ruler's tick marks; Escape cancels and the drag is one undo step. Locked column guides (the default) are not picked. A document page takes the result as its own columns; on a parent page it passes to the pages that follow it. `layout.moveColumnGuide` does the same over the control channel and MCP. No readiness percentage change.
- **2026-10-16** · Layout: autoflow, inserted pages and new parents follow dragged column guides (#1096). Autoflow sets one frame per dragged column and counts the pages it adds from them; Insert Pages and New Parent keep the custom positions with the column count and gutter; a page that moves to the other side of the spine (inserted, deleted or moved pages, Duplicate Spread, a new parent's other page) has them mirrored across its margin box, as InDesign does; `document.inspect` reports each page's gutter, column rects and whether its columns are custom. No readiness percentage change.
- **2026-10-16** · Layout: Margins and Columns keeps dragged column guides unless the column count or gutter changes (#1096). The dialog sends every field on OK, so a margin-only change re-divided the columns evenly. Margin changes keep the positions; the columns follow them only while they fit the margin box. No readiness percentage change.
+1 -1
View File
@@ -39,7 +39,7 @@ use designcraft_engine::Session;
use serde_json::{Value, json};
/// The usage block. Shared by `--help` (stdout, success) and an unknown command (stderr, failure).
const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR]\n the two roots confine every file the agent's commands read (open, placed images, data sources, libraries) and\n write (save, export, package) to those folders, links followed. A root left out grants none of its access.\n They run the in-process server, so they conflict with --connect (a running app is not confined by them).\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli links\n designcraft-cli --version";
const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR]\n the two roots confine every file the agent's commands read (open, placed images, data sources, libraries) and\n write (save, export, package) to those folders, links followed. A root left out grants none of its access.\n They run the in-process server, so they conflict with --connect (a running app is not confined by them).\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli validate-pdf FILE.pdf # built-in PDF/X-4 checks (including visible unembedded fonts)\n designcraft-cli links\n designcraft-cli --version";
/// `script --help`: the script syntax, otherwise only in `describe script.run` and docs/agents.md.
const SCRIPT_HELP: &str = r#"usage: designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]
File renamed without changes.
+1
View File
@@ -0,0 +1 @@
mod cli;
+2 -2
View File
@@ -2,12 +2,12 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DesignCraft</title>
<link data-trunk rel="rust" data-bin="designcraft-web" data-wasm-opt="z" data-weak-refs />
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
#designcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
#designcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; }
#designcraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
color: #b8b8b8; font: 14px system-ui, sans-serif; pointer-events: none; }
</style>
+2
View File
@@ -395,6 +395,8 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
let ctx = ctx.clone();
let dialog = if request.purpose == "swatches" {
rfd::AsyncFileDialog::new().add_filter("Swatch Exchange (ASE)", &["ase"])
} else if request.purpose == "shortcuts" {
rfd::AsyncFileDialog::new().add_filter("Shortcut set (text)", &["txt"])
} else if request.purpose == "place" {
rfd::AsyncFileDialog::new().add_filter("Graphics", IMAGE_EXTS)
} else {
+15 -1
View File
@@ -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 })
+28 -1
View File
@@ -187,6 +187,7 @@ fn open_filters(purpose: &str) -> &'static [OpenFilter] {
match purpose {
"swatches" => &[OpenFilter { name: "Swatch Exchange (ASE)", extensions: &["ase"] }],
"script" => &[OpenFilter { name: "Script", extensions: &["dcscript", "txt", "json"] }],
"shortcuts" => &[OpenFilter { name: "Shortcut set (text)", extensions: &["txt"] }],
"icc" => &[OpenFilter { name: "ICC profile", extensions: &["icc", "icm"] }],
"book" => &[OpenFilter { name: "Book", extensions: &["dcbook"] }],
"xml" => &[OpenFilter { name: "XML", extensions: &["xml"] }],
@@ -276,12 +277,27 @@ fn picked_save_path(picked: std::path::PathBuf, ext: Option<&str>) -> std::path:
s.into()
}
/// The folder a save dialog starts in and the name it suggests. A suggestion with a folder in
/// front (the document's, or the last one used) starts there while that folder still exists;
/// otherwise the system picks, as it did before.
fn save_dialog_start(suggested: &str) -> (Option<std::path::PathBuf>, String) {
let path = std::path::Path::new(suggested);
let name = path.file_name().map_or_else(|| suggested.to_string(), |n| n.to_string_lossy().to_string());
let folder = path.parent().filter(|f| !f.as_os_str().is_empty() && f.is_dir()).map(std::path::Path::to_path_buf);
(folder, name)
}
/// The save dialog: the suggested name's extension as its filter, and the extension added to a
/// name typed without it. Adding it names another file than the one the dialog checked, so
/// replacing an existing one asks again.
fn pick_save(name: &str) -> Option<String> {
fn pick_save(suggested: &str) -> Option<String> {
let (folder, name) = save_dialog_start(suggested);
let name = name.as_str();
let ext = suggested_extension(name);
let mut dialog = rfd::FileDialog::new().set_file_name(name);
if let Some(folder) = folder {
dialog = dialog.set_directory(folder);
}
if let Some(ext) = ext {
dialog = dialog.add_filter(ext.to_ascii_uppercase(), &[ext]);
}
@@ -605,6 +621,17 @@ mod tests {
assert_eq!(save("/d/Brochure.txt", "Untitled.rtf"), PathBuf::from("/d/Brochure.txt"), "another export format is kept");
assert_eq!(save("/d/Package", "Untitled 1.2 Folder"), PathBuf::from("/d/Package"), "a folder has no extension");
}
/// #1653: the save dialog opens in the folder the suggestion names, unless it is gone.
#[test]
fn save_dialog_starts_in_the_suggested_folder_while_it_exists() {
let folder = std::env::temp_dir();
let suggested = folder.join("Brochure.pdf");
assert_eq!(super::save_dialog_start(&suggested.to_string_lossy()), (Some(folder.clone()), "Brochure.pdf".to_string()));
let gone = folder.join(format!("designcraft-gone-{}", std::process::id())).join("Brochure.pdf");
assert_eq!(super::save_dialog_start(&gone.to_string_lossy()), (None, "Brochure.pdf".to_string()), "a folder deleted since: the system's own");
assert_eq!(super::save_dialog_start("Untitled 1.2 Folder"), (None, "Untitled 1.2 Folder".to_string()), "a bare name: the system's own");
}
}
#[cfg(all(test, unix))]
+23 -2
View File
@@ -128,8 +128,14 @@ pub fn write(swatches: &[Swatch]) -> Vec<u8> {
continue;
}
let mut b = Vec::new();
let units: Vec<u16> = s.name.encode_utf16().chain(std::iter::once(0)).collect();
b.extend((units.len() as u16).to_be_bytes());
// The length field is a u16 that counts the terminating NUL: truncate longer names.
let mut units: Vec<u16> = s.name.encode_utf16().take(usize::from(u16::MAX) - 1).collect();
// Do not leave half of a surrogate pair at the cut.
if units.last().is_some_and(|u| (0xD800..0xDC00).contains(u)) {
units.pop();
}
units.push(0);
b.extend(u16::try_from(units.len()).unwrap_or(u16::MAX).to_be_bytes());
for u in units {
b.extend(u.to_be_bytes());
}
@@ -233,6 +239,21 @@ mod tests {
assert_eq!(read(&write(&sw[1..])).map(|w| w.len()), Ok(MAX_COLORS), "up to the limit");
}
#[test]
fn over_long_name_is_truncated_to_fit() {
let sw = vec![Swatch {
name: "x".repeat(70_000),
value: SwatchValue::Color { color: Color::Gray { k: 0.5 }, color_type: ColorType::Process },
locked: false,
named: true,
hidden: false,
}];
let back = read(&write(&sw)).unwrap();
assert_eq!(back.len(), 1, "the swatch must survive the round trip");
assert_eq!(back[0].name, "x".repeat(usize::from(u16::MAX) - 1));
assert_eq!(back[0].value, sw[0].value);
}
#[test]
fn out_of_range_components_are_clamped() {
let mut f = Vec::new();
+26 -4
View File
@@ -165,6 +165,20 @@ pub struct PlacedGlyph {
}
impl PlacedGlyph {
/// Whether an underline or a strikethrough runs across this glyph.
///
/// A tab is not drawn, but it is a character with an advance, and the rule runs across its
/// whole width as InDesign draws it: forms rule their fill-in lines with underlined tabs, and
/// without this they lost every writing line (#1466). The spaces a line break trimmed off the
/// end, the break characters themselves and the markers' carriers are not part of the line's
/// text and are not spanned.
pub fn spans_rules(&self) -> bool {
if self.visible {
return true;
}
self.adv > 0.0 && matches!(self.rendered_char, '\t' | designcraft_doc::story::RIGHT_INDENT_TAB)
}
/// In a vertical frame, the turn that sets this glyph upright, applied after drawing it at `x`
/// on `baseline`: it hangs from its vertical origin ([`designcraft_fonts::FontFace::v_origin`],
/// centred across) at `x` on the line's centre, the middle of the font's em box; a tate-chu-yoko
@@ -679,10 +693,12 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> {
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(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(item.bounds().center().x).and_then(|pi| sp.pages.get(pi));
let left = under.is_some_and(|p| p.side == designcraft_doc::PageSide::Left);
(Some(prefix), None, left)
}
_ => (None, None, false),
};
@@ -1674,10 +1690,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;
+55
View File
@@ -7310,3 +7310,58 @@ 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);
}
/// 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]));
}
+1 -1
View File
@@ -109,7 +109,7 @@ fn position_param(p: &Value, base: &AnchorPosition) -> Result<AnchorPosition> {
}
/// Text frames can't be anchored yet (their stories need frames on a spread).
fn check_item(it: &Item) -> Result<()> {
pub(crate) fn check_item(it: &Item) -> Result<()> {
let mut ok = true;
it.walk(&mut |i| ok &= !matches!(i.content, Content::Text(_)));
if ok { Ok(()) } else { Err(bad("anchored.insert", "text frames can't be anchored yet")) }
+120 -6
View File
@@ -43,24 +43,64 @@ fn copy_text(s: &mut Session) -> Result<Option<String>> {
Ok(Some(text))
}
/// Plain text for the system clipboard after copying objects: the copied text frames' stories,
/// or U+FFFC (object replacement character) when none has text. Paste reads it back as "the
/// copied objects", not as text.
pub fn copied_objects_text(s: &Session) -> String {
let Some(clip) = &s.clipboard else { return '\u{FFFC}'.to_string() };
let mut stories = Vec::new();
for it in clip.spreads.first().map(|sp| sp.items.as_slice()).unwrap_or_default() {
it.walk(&mut |i| {
if let Some(tf) = i.text_frame()
&& !stories.contains(&tf.story)
{
stories.push(tf.story);
}
});
}
let text = stories.iter().filter_map(|id| clip.story(*id)).map(plain).filter(|t| !t.is_empty()).collect::<Vec<_>>().join("\n");
if text.is_empty() { '\u{FFFC}'.to_string() } else { text }
}
/// `text` (the system clipboard's) is what the last copy put there: objects were copied last and
/// it is their placeholder, so Paste stands for the copied objects.
fn is_objects_placeholder(s: &Session, text: &str) -> bool {
s.text_clipboard.is_none() && s.clipboard.is_some() && text.replace("\r\n", "\n") == copied_objects_text(s).replace("\r\n", "\n")
}
/// Paste text at the insertion point: the formatted copy when the system clipboard still holds
/// what was copied here (or `formatted` is forced), else plain text in the insertion format.
/// With `formatted`, objects copied last paste as anchored objects when `text` is absent or their
/// placeholder; other text was copied in another app since. Objects that can't be anchored (text
/// frames, into a table cell) paste as their placeholder text.
fn paste_text(s: &mut Session, text: Option<String>, formatted: bool) -> Result<Value> {
let t = s.doc()?.selection.text.ok_or_else(|| super::bad("edit.paste", "no insertion point"))?;
let clip = s.text_clipboard.clone();
// Items copied last (no text copied since) paste into the text as anchored objects.
if formatted
&& clip.is_none()
let own = formatted && clip.is_none() && s.clipboard.is_some() && text.as_deref().is_none_or(|x| is_objects_placeholder(s, x));
if own
&& t.cell.is_none()
&& let Some(items) = s.clipboard.clone()
{
let list: Vec<designcraft_doc::Item> = items.spreads.first().map(|sp| sp.items.iter().map(|i| (**i).clone()).collect()).unwrap_or_default();
if !list.is_empty() && list.iter().all(|i| !i.is_text_frame()) {
if !list.is_empty() && list.iter().all(|i| super::anchored::check_item(i).is_ok()) {
return s.edit(|d, sel| {
let st = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
let r = t.range();
st.delete(r.start.min(st.len())..r.end.min(st.len()));
let end = super::anchored::anchor_items(d, t.story, r.start, list.clone(), &Default::default())?;
// Fresh ids, story and asset copies for every paste, as on the page.
let mut stories = std::collections::HashMap::new();
let fresh: Vec<designcraft_doc::Item> = list
.iter()
.map(|i| {
let mut i = i.clone();
super::object::renumber(d, &items, &mut i, &mut stories);
if d.layer(i.layer).is_none() {
i.layer = d.default_layer();
}
i
})
.collect();
let end = super::anchored::anchor_items(d, t.story, r.start, fresh, &Default::default())?;
sel.text = Some(designcraft_doc::TextSel { anchor: end, focus: end, ..t });
Ok(json!({"pos": end, "anchored": list.len()}))
});
@@ -80,6 +120,8 @@ fn paste_text(s: &mut Session, text: Option<String>, formatted: bool) -> Result<
Ok(json!({"pos": end, "formatted": true}))
});
}
// U+FFFC stands for objects with no text.
let text = if own { Some(copied_objects_text(s)).filter(|m| m != "\u{FFFC}") } else { text };
let text = text.or_else(|| clip.map(|(_, p)| p)).ok_or_else(|| super::bad("edit.paste", "clipboard is empty"))?;
let text = outside_text(&text)?;
s.execute("text.insert", &json!({"text": text, "raw": true}))
@@ -220,12 +262,29 @@ pub fn specs() -> Vec<CommandSpec> {
st.selection.cells = None;
if let Some(i) = st.selection.items.iter().position(|x| *x == id) {
st.selection.items.remove(i);
// A key object needs another object to align to it.
if st.selection.key == Some(id) || st.selection.items.len() < 2 {
st.selection.key = None;
}
} else {
st.selection.items.push(id);
}
st.bump_revision();
ok()
}),
cmd!(noundo "selection.key", "Key Object", [], None, "{id} — make a selected item the key object for Align; the current key object drops the key", has_doc, |s, p| {
let id = super::id_param(p, "id").ok_or_else(|| super::bad("selection.key", "missing id"))?;
let st = s.doc_mut()?;
if !st.selection.items.contains(&id) {
return Err(super::bad("selection.key", "the key object must be selected"));
}
if st.selection.items.len() < 2 {
return Err(super::bad("selection.key", "a key object needs at least two selected objects"));
}
st.selection.key = if st.selection.key == Some(id) { None } else { Some(id) };
st.bump_revision();
ok()
}),
cmd!(noundo "edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{} — all items on the active spreads, or all text in the story", has_doc, |s, _| {
let st = s.doc_mut()?;
if let Some(t) = st.selection.text {
@@ -327,7 +386,8 @@ pub fn specs() -> Vec<CommandSpec> {
if s.doc()?.selection.text.is_some() {
return paste_text(s, text.map(str::to_string), true);
}
if let Some(text) = text {
// The placeholder of an object copy pastes the copied objects.
if let Some(text) = text.filter(|t| !is_objects_placeholder(s, t)) {
// What our own text copy put on the clipboard keeps its formatting.
let slice = s.text_clipboard.clone().filter(|(_, plain)| text.replace("\r\n", "\n") == *plain).map(|(slice, _)| slice);
return paste_text_frame(s, p, text, slice.as_deref());
@@ -538,6 +598,60 @@ mod text_clipboard_tests {
s.doc().unwrap().doc.check().unwrap();
}
/// Copying a rectangle, then text in another app, pasted the rectangle into the text (#212).
#[test]
fn paste_at_a_caret_prefers_other_apps_text_to_copied_objects() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let story = frame(&mut s, "ab");
let rect = s.execute("frame.create", &json!({"rect": [72, 400, 200, 480]})).unwrap()["id"].clone();
s.execute("selection.set", &json!({"ids": [rect]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
let marker = copied_objects_text(&s);
s.execute("text.select", &json!({"story": story.0, "anchor": 2, "focus": 2})).unwrap();
let r = s.execute("edit.paste", &json!({"text": "From elsewhere"})).unwrap();
assert!(r.get("anchored").is_none(), "{r}");
assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "abFrom elsewhere");
// What the object copy put on the system clipboard still pastes the objects.
assert_eq!(s.execute("edit.paste", &json!({"text": marker})).unwrap()["anchored"], 1);
// With no insertion point too: the rectangle, not a text frame holding the marker.
s.execute("edit.deselectAll", &json!({})).unwrap();
let r = s.execute("edit.paste", &json!({"text": marker})).unwrap();
let id = ItemId(r["ids"][0].as_u64().unwrap());
assert!(s.doc().unwrap().doc.item(id).unwrap().text_frame().is_none());
s.doc().unwrap().doc.check().unwrap();
// Paste without Formatting inserts the text given; empty text is no text.
s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 0})).unwrap();
assert!(s.execute("edit.pasteWithoutFormatting", &json!({"text": ""})).is_err());
}
/// Text frames, and groups holding one, can't be anchored: at a caret their copy pastes as text.
#[test]
fn copied_text_frames_paste_at_a_caret_as_their_text() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let target = frame(&mut s, "ab");
let copy_and_paste = |s: &mut Session, ids: Value, text: Value| {
s.execute("selection.set", &json!({"ids": ids})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("text.select", &json!({"story": target.0, "anchor": 0, "focus": 0})).unwrap();
s.execute("edit.paste", &json!({"text": text})).unwrap()
};
let chapter = s.execute("frame.create", &json!({"rect": [72, 400, 300, 480], "content": "text", "text": "Chapter 1"})).unwrap()["id"].clone();
// The same text copied in another app, and the system clipboard read back as absent.
let r = copy_and_paste(&mut s, json!([chapter]), json!("Chapter 1"));
assert!(r.get("anchored").is_none(), "{r}");
s.execute("edit.paste", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.story(target).unwrap().text, "Chapter 1Chapter 1ab");
// A group with a text frame inside, its own placeholder on the system clipboard.
let rect = s.execute("frame.create", &json!({"rect": [72, 500, 200, 560]})).unwrap()["id"].clone();
let group = s.execute("object.group", &json!({"ids": [chapter, rect]})).unwrap()["id"].clone();
let r = copy_and_paste(&mut s, json!([group]), Value::Null);
assert!(r.get("anchored").is_none(), "{r}");
assert_eq!(s.doc().unwrap().doc.story(target).unwrap().text, "Chapter 1Chapter 1Chapter 1ab");
s.doc().unwrap().doc.check().unwrap();
}
#[test]
fn outside_text_never_pastes_markers() {
let mut s = Session::new();
+61 -2
View File
@@ -10,7 +10,7 @@ use crate::{EngineError, Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(noundo "file.exportPdf", "Export PDF…", ["File"], None,
"{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), flatten?: high|medium|low|ppi (Transparency Flattener: spreads with transparency are rasterised), spreads?: bool, fullScreen?: bool, bookmarksPanel?: bool, pageLayout?: single|continuous|twoUp|twoUpCover|twoUpContinuous, view?: fitPage|fitWidth|actual, advanceSeconds?: number (interactive PDF), bleed?: bool (document bleed), marks?: bool | {crop?, bleed?, pageInfo?, weight?, offset?}, standard?: \"none\"|\"x4\"|\"a2b\", compressImages?: bool, tagged?: bool (structure tree: stories as paragraphs, figures with alt text), media?: bool (interactive: embed placed video and sound), title?, author?} → {path, bytes, pages, warnings} (no path: {base64, …})",
"{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), flatten?: high|medium|low|ppi (Transparency Flattener: spreads with transparency are rasterised), spreads?: bool, fullScreen?: bool, bookmarksPanel?: bool, pageLayout?: single|continuous|twoUp|twoUpCover|twoUpContinuous, view?: fitPage|fitWidth|actual, advanceSeconds?: number (interactive PDF), bleed?: bool (document bleed), marks?: bool | {crop?, bleed?, pageInfo?, weight?, offset?}, standard?: \"none\"|\"x4\"|\"a2b\", compressImages?: bool, tagged?: bool (structure tree: stories as paragraphs, figures with alt text), media?: bool (interactive: embed placed video and sound), title?, author?, created?: int (creation date in Unix seconds; default now, for reproducible files)} → {path, bytes, pages, warnings} (no path: {base64, …})",
has_doc, export_pdf),
cmd!(noundo "file.exportEpub", "Export EPUB (Reflowable)…", ["File"], None,
"{path?, title?, author?, language?: \"en\", identifier?: str (dc:identifier; a stable urn:uuid by default), coverImage?: path (a PNG or JPEG as the cover), tocTitle?: str (the table of contents' title; from the language by default), cover?: bool (the first page as the cover image), embedFonts?: bool (package the stylesheet's fonts whose licence allows it, whole files), fixedLayout?: bool (pre-paginated: each page as an image with its text)} → {path, bytes, warnings} (no path: {base64, bytes, warnings}); warnings name images without alt text, exported as decorative, and fonts that could not be embedded",
@@ -331,7 +331,12 @@ fn pages_param(id: &str, p: &Value, page_count: usize) -> Result<Option<Vec<usiz
.and_then(|n| usize::try_from(n).ok())
.filter(|n| *n >= 1 && *n <= page_count)
.ok_or_else(|| bad(id, format!("bad page {x}")))?;
v.push(n - 1);
// A page named twice is one page, as `"2,2"` already is: the list says which
// pages to export, not how many times. `[2, 2]` exported two copies of page 2
// while `"2,2"` exported one.
if !v.contains(&(n - 1)) {
v.push(n - 1);
}
}
Some(v)
}
@@ -371,6 +376,10 @@ pub(crate) fn options(p: &Value, page_count: usize) -> Result<PdfOptions> {
Some(s) => Standard::parse(s).ok_or_else(|| bad(ID, format!("unknown standard `{s}` (none, x4, a2b)")))?,
None => Standard::None,
};
let created = match p.get("created") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_i64().ok_or_else(|| bad(ID, "`created`: Unix seconds, an integer"))?),
};
Ok(PdfOptions {
pages,
spreads,
@@ -382,6 +391,7 @@ pub(crate) fn options(p: &Value, page_count: usize) -> Result<PdfOptions> {
author: str_param(p, "author").map(str::to_string),
tagged: p.get("tagged").and_then(Value::as_bool).unwrap_or(false),
media: p.get("media").and_then(Value::as_bool).unwrap_or(false),
created,
..PdfOptions::default()
})
}
@@ -1326,3 +1336,52 @@ mod pptx_tests {
assert!(x.contains("<p:pic>") && !x.contains("prst=\"ellipse\""), "{x}");
}
}
#[cfg(test)]
mod page_list_tests {
use serde_json::{Value, json};
use crate::Session;
fn doc(pages: u64) -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200, "pages": pages, "facingPages": false, "margins": 0})).unwrap();
s
}
/// The same selection written two ways exported different files: `"2,2"` is one page, because
/// `parse_page_range` keeps the first of a repeat, while `[2, 2]` was two copies of page 2.
/// The list says which pages to export, not how many times.
#[test]
fn a_page_named_twice_is_exported_once_either_way() {
let mut s = doc(4);
for (pages, want) in [
(json!("2,2"), 1),
(json!([2, 2]), 1),
(json!([2, 2, 2]), 1),
(json!("2-2,2"), 1),
(json!([1, 2, 1]), 2),
(json!("1,2,1"), 2),
(json!([4, 1]), 2),
(json!("4,1"), 2),
(json!("all"), 4),
] {
let r = s.execute("file.exportPdf", &json!({"pages": pages.clone()})).unwrap();
assert_eq!(r["pages"].as_u64(), Some(want), "pdf {pages}");
let r = s.execute("file.exportPptx", &json!({"pages": pages.clone()})).unwrap();
assert_eq!(r["slides"].as_u64(), Some(want), "pptx {pages}");
}
// The two forms now agree byte for byte, and the pages come out in the document's order
// whichever order the list names them in (which is how the export already behaved).
let mut file = |pages: Value| {
let r = s.execute("file.exportPdf", &json!({"pages": pages})).unwrap();
r["base64"].as_str().unwrap_or_default().to_string()
};
assert_eq!(file(json!([4, 1])), file(json!("4,1")), "the array and the string agree");
assert_eq!(file(json!([4, 1])), file(json!([1, 4])), "pages 1 and 4, in the document's order");
assert_eq!(file(json!([2, 2])), file(json!("2")), "a repeat is the single page");
// A page that does not exist is still refused, and an empty list still exports nothing.
assert!(s.execute("file.exportPdf", &json!({"pages": [5]})).is_err());
assert!(s.execute("file.exportPdf", &json!({"pages": []})).is_err());
}
}
+5 -1
View File
@@ -679,7 +679,11 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.active()?;
st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| matches!(it.content, Content::Unassigned | Content::Graphic(_))))
});
let spread = super::spread_param(p, "file.place")?;
// `"spread": null` counts as omitted, like the other placement parameters.
let spread = match p.get("spread") {
Some(Value::Null) => designcraft_doc::SpreadRef::Doc(0),
_ => super::spread_param(p, "file.place")?,
};
let lid = s.doc()?.active_layer;
let px = p.get("x").and_then(Value::as_f64);
let py = p.get("y").and_then(Value::as_f64);
+90 -35
View File
@@ -6,7 +6,7 @@ use std::sync::{Arc, Weak};
use designcraft_compose::grep::{Grep, GrepOptions, translate_replacement};
use designcraft_doc::story::floor_char_boundary;
use designcraft_doc::{Document, Selection, StoryId, TextSel};
use designcraft_doc::{CellAddr, Document, Selection, StoryId, TextSel};
use serde_json::{Value, json};
use super::{CommandSpec, bad, bool_or, cmd, has_doc, str_param};
@@ -61,7 +61,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
),
cmd!(query "find.find", "Find", ["Edit", "Find/Change"], None,
"{find, grep?: bool, caseSensitive?: bool, wholeWord?: bool, scope?: document|story|selection, story?, change?|first?|attrs?: ignored (find.change takes them; one request serves all three Find/Change commands)} → matches [{story, start, end, text}]",
"{find, grep?: bool, caseSensitive?: bool, wholeWord?: bool, scope?: document|story|selection, story?, change?|first?|attrs?: ignored (find.change takes them; one request serves all three Find/Change commands)} → matches [{story, start, end, text, cell?: {table, row, col}}] (document scope also searches table cells; start/end then index the cell's text)",
has_doc, find),
cmd!(
"find.change",
@@ -153,6 +153,9 @@ impl Matcher {
}
}
/// A searched text: a story, or one table cell of it, and the byte range inside.
type Target = (StoryId, Option<CellAddr>, Range<usize>);
/// A search owns its original domain, not the selection used to paint the latest hit.
/// Weak identity avoids retaining an old document merely for this transient cache.
#[derive(Clone)]
@@ -163,7 +166,7 @@ pub(crate) struct FindContext {
selection: Selection,
domain: Domain,
matcher: Matcher,
targets: Vec<(StoryId, Range<usize>)>,
targets: Vec<Target>,
order: Vec<usize>,
target: usize,
offset: usize,
@@ -184,21 +187,25 @@ impl FindContext {
self.target = 0;
self.last_match_end = None;
self.order = (0..self.targets.len()).collect();
let caret = st.selection.text.filter(|t| t.cell.is_none() && st.selection.cells.is_none());
if self.domain == Domain::Document
&& let Some(t) = st.selection.text
&& let Some(i) = self.targets.iter().position(|(sid, _)| *sid == t.story)
{
let caret = st.selection.text.filter(|_| st.selection.cells.is_none());
// A caret in a searched cell starts there; one in a footnote starts at its story.
let start = st.selection.text.and_then(|t| {
let at = |want: Option<CellAddr>| self.targets.iter().position(|(sid, cell, _)| *sid == t.story && *cell == want);
at(t.cell).or_else(|| at(None))
});
if let Some(i) = start.filter(|_| self.domain == Domain::Document) {
self.order.rotate_left(i);
}
let first = self.order.first().and_then(|i| self.targets.get(*i));
self.offset = match first {
Some((sid, r)) if self.domain != Domain::Selection => caret.filter(|t| t.story == *sid).map_or(r.start, |t| t.range().end.min(r.end)),
Some((_, r)) => r.start,
Some((sid, cell, r)) if self.domain != Domain::Selection => {
caret.filter(|t| t.story == *sid && t.cell == *cell).map_or(r.start, |t| t.range().end.min(r.end))
}
Some((_, _, r)) => r.start,
None => 0,
};
if let Some((sid, r)) = first {
let text = target_text(&st.doc, *sid, r)?;
if let Some((sid, cell, r)) = first {
let text = target_text(&st.doc, *sid, *cell, r)?;
if text.get(self.offset.saturating_sub(r.start)..).is_none() {
return Err(bad("find", "invalid UTF-8 search cursor"));
}
@@ -209,7 +216,7 @@ impl FindContext {
fn advance_target(&mut self) {
self.target = self.target.saturating_add(1);
self.last_match_end = None;
self.offset = self.order.get(self.target).and_then(|i| self.targets.get(*i)).map_or(0, |(_, r)| r.start);
self.offset = self.order.get(self.target).and_then(|i| self.targets.get(*i)).map_or(0, |(_, _, r)| r.start);
}
fn advance_empty(&mut self, text: &str, relative_end: usize, end: usize) {
@@ -222,8 +229,8 @@ impl FindContext {
}
}
fn target_text<'a>(d: &'a Document, sid: StoryId, r: &Range<usize>) -> Result<&'a str> {
d.story(sid)
fn target_text<'a>(d: &'a Document, sid: StoryId, cell: Option<CellAddr>, r: &Range<usize>) -> Result<&'a str> {
d.text_story(sid, cell)
.ok_or_else(|| bad("find", "no such story"))?
.text
.get(r.clone())
@@ -246,7 +253,7 @@ fn scope(s: &Session, p: &Value) -> Result<FindContext> {
let targets = match domain {
Domain::Explicit(sid) => {
let len = d.story(sid).ok_or_else(|| bad("find", "no such story"))?.len();
vec![(sid, 0..len)]
vec![(sid, None, 0..len)]
}
Domain::Story | Domain::Selection => {
if st.selection.cells.is_some() || st.selection.text.is_some_and(|t| t.cell.is_some()) {
@@ -255,10 +262,21 @@ fn scope(s: &Session, p: &Value) -> Result<FindContext> {
let t = st.selection.text.ok_or_else(|| bad("find", "no text selected"))?;
let story = d.story(t.story).ok_or_else(|| bad("find", "no such story"))?;
let r = if domain == Domain::Selection { t.range() } else { 0..story.len() };
target_text(d, t.story, &r)?;
vec![(t.story, r)]
target_text(d, t.story, None, &r)?;
vec![(t.story, None, r)]
}
Domain::Document => d.stories.values().map(|x| (x.id, 0..x.len())).collect(),
// Table cells hold their own text outside `stories`; search them right after the story that anchors the table.
Domain::Document => d
.stories
.values()
.flat_map(|x| {
let cells = x.tables.iter().flat_map(move |(id, t)| {
let cols = t.ncols().max(1);
t.cells.iter().enumerate().map(move |(i, c)| (x.id, Some(CellAddr { table: *id, row: i / cols, col: i % cols }), 0..c.text.len()))
});
std::iter::once((x.id, None, 0..x.len())).chain(cells)
})
.collect(),
};
let mut context = FindContext {
document: Arc::downgrade(d),
@@ -285,10 +303,14 @@ fn find(s: &mut Session, p: &Value) -> Result<Value> {
let context = scope(s, p)?;
let st = s.doc()?;
let mut out = Vec::new();
for (sid, r) in &context.targets {
let text = target_text(&st.doc, *sid, r)?;
for (sid, cell, r) in &context.targets {
let text = target_text(&st.doc, *sid, *cell, r)?;
for m in re.find_all(text, MAX_MATCHES.saturating_sub(out.len())).map_err(|e| bad("find", e))? {
out.push(json!({"story": sid.0, "start": r.start + m.start, "end": r.start + m.end, "text": text.get(m)}));
let mut hit = json!({"story": sid.0, "start": r.start + m.start, "end": r.start + m.end, "text": text.get(m)});
if let Some(c) = cell {
hit["cell"] = json!({"table": c.table, "row": c.row, "col": c.col});
}
out.push(hit);
}
}
s.find_context = Some(context);
@@ -317,8 +339,8 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> {
let targets = scope(s, p)?.targets;
s.edit(|d, sel| {
let mut count = 0usize;
for (sid, r) in targets {
let Some(story) = d.story(sid) else { continue };
for (sid, cell, r) in targets {
let Some(story) = d.text_story(sid, cell) else { continue };
// Collect matches first (positions refer to the original text), apply back to front.
let text = story.text.get(r.clone()).ok_or_else(|| bad("find", "invalid selection bounds or UTF-8 boundary"))?;
let edits: Vec<(std::ops::Range<usize>, String)> = if grep {
@@ -330,7 +352,7 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> {
.into_iter()
.map(|(m, out)| (r.start + m.start..r.start + m.end, out))
.collect();
let Some(st) = d.story_mut(sid) else { continue };
let Some(st) = d.text_story_mut(sid, cell) else { continue };
for (range, new) in edits.iter().rev() {
st.replace(range.clone(), new);
if !over.is_empty() && !new.is_empty() {
@@ -338,7 +360,7 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> {
}
count += 1;
}
if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid && t.cell.is_none()) {
if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid && t.cell == cell) {
// Repair byte validity once against the final story, without remapping valid offsets.
let range = t.range();
let start = floor_char_boundary(&st.text, range.start);
@@ -365,8 +387,8 @@ fn find_next(s: &mut Session, p: &Value) -> Result<Value> {
let mut context = scope(s, p)?;
let st = s.doc()?;
let mut hit = None;
while let Some((sid, r)) = context.order.get(context.target).and_then(|i| context.targets.get(*i)).cloned() {
let text = target_text(&st.doc, sid, &r)?;
while let Some((sid, cell, r)) = context.order.get(context.target).and_then(|i| context.targets.get(*i)).cloned() {
let text = target_text(&st.doc, sid, cell, &r)?;
let from = context.offset.checked_sub(r.start).ok_or_else(|| bad("find", "invalid search cursor"))?;
if text.get(from..).is_none() {
return Err(bad("find", "invalid UTF-8 search cursor"));
@@ -379,7 +401,7 @@ fn find_next(s: &mut Session, p: &Value) -> Result<Value> {
context.advance_empty(text, m.end, b);
continue;
}
hit = Some((sid, a, b));
hit = Some((sid, cell, a, b));
context.last_match_end = Some(b);
if m.is_empty() {
context.advance_empty(text, m.end, b);
@@ -390,17 +412,22 @@ fn find_next(s: &mut Session, p: &Value) -> Result<Value> {
}
context.advance_target();
}
let Some((sid, a, b)) = hit else {
let Some((sid, cell, a, b)) = hit else {
s.find_context = Some(context);
return Ok(Value::Null);
};
let frame = st.doc.story(sid).and_then(|x| x.frames.first().copied());
let cell_json = cell.map(|c| json!({"table": c.table, "row": c.row, "col": c.col}));
let stm = s.doc_mut()?;
stm.selection = Selection::text(TextSel { story: sid, anchor: a, focus: b, frame, cell: None });
stm.selection = Selection::text(TextSel { story: sid, anchor: a, focus: b, frame, cell });
stm.bump_revision();
context.acknowledge(stm);
s.find_context = Some(context);
Ok(json!({"story": sid.0, "start": a, "end": b}))
let mut out = json!({"story": sid.0, "start": a, "end": b});
if let Some(c) = cell_json {
out["cell"] = c;
}
Ok(out)
}
#[cfg(test)]
@@ -1007,6 +1034,31 @@ mod tests {
assert_eq!(s.doc().unwrap().history.undo.len(), history);
}
#[test]
fn document_scope_searches_and_changes_table_cells() {
let (mut s, sid) = scope_session("Ore 12.15 here");
scope_select(&mut s, sid, 0, 0);
let table = s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap()["table"].as_u64().unwrap();
s.execute("table.setCell", &json!({"row": 0, "col": 1, "text": "Ore 12.15"})).unwrap();
// The caret sits in the first cell, so Find Next goes to the second cell, then the story.
let p = json!({"find": "Ore 12.15", "scope": "document"});
let cell = designcraft_doc::CellAddr { table, row: 0, col: 1 };
assert_eq!(s.execute("find.next", &p).unwrap(), json!({"story": sid.0, "start": 0, "end": 9, "cell": {"table": table, "row": 0, "col": 1}}));
assert_eq!(s.doc().unwrap().selection.text.map(|t| (t.story, t.range(), t.cell)), Some((sid, 0..9, Some(cell))));
let next = s.execute("find.next", &p).unwrap();
assert!(next.get("start").is_some() && next.get("cell").is_none(), "{next}");
let hits = s.execute("find.find", &p).unwrap();
let hits = hits.as_array().unwrap();
assert_eq!(hits.len(), 2);
let in_cell: Vec<_> = hits.iter().filter(|h| h.get("cell").is_some()).collect();
assert_eq!(in_cell.len(), 1);
assert_eq!((in_cell[0]["cell"]["row"].as_u64(), in_cell[0]["cell"]["col"].as_u64()), (Some(0), Some(1)));
let n = s.execute("find.change", &json!({"find": "Ore 12.15", "change": "Ore 13.00", "scope": "document"})).unwrap();
assert_eq!(n["count"], 2);
assert!(s.execute("find.find", &json!({"find": "12.15", "scope": "document"})).unwrap().as_array().unwrap().is_empty());
assert_eq!(s.execute("find.find", &json!({"find": "13.00", "scope": "document"})).unwrap().as_array().unwrap().len(), 2);
}
fn child_scope_session(note: bool, child: &str) -> (Session, designcraft_doc::StoryId, designcraft_doc::TextSel) {
let (mut s, sid) = scope_session("red");
scope_select(&mut s, sid, 3, 3);
@@ -1051,12 +1103,15 @@ mod tests {
p["story"] = json!(sid.0);
p["scope"] = json!("selection");
}
// Document scope also covers table cells (#1624); an explicit story and footnotes stay top-level.
let in_cell = if explicit || note { 0 } else { child.matches("red").count() };
let changed = if in_cell > 0 { child.replace("red", "blue") } else { child.to_string() };
s.doc_mut().unwrap().selection = selection.clone();
assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1);
assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1 + in_cell);
scope_hit(&mut s, &p, sid, 0, 3);
s.doc_mut().unwrap().selection = selection.clone();
assert_eq!(s.execute("find.change", &p).unwrap()["count"], 1);
assert_eq!(s.doc().unwrap().doc.text_story(sid, selected.cell).unwrap().text, child);
assert_eq!(s.execute("find.change", &p).unwrap()["count"], 1 + in_cell);
assert_eq!(s.doc().unwrap().doc.text_story(sid, selected.cell).unwrap().text, changed);
assert_eq!(s.doc().unwrap().selection, selection, "parent replacement does not clamp child offsets");
p["find"] = json!("blue");
p["change"] = json!("red");
+6 -3
View File
@@ -72,7 +72,9 @@ fn add_reference(s: &mut Session, p: &Value) -> Result<Value> {
"currentPage" => IndexRange::CurrentPage,
"toEndOfStory" => IndexRange::ToEndOfStory,
"suppressPageRange" => IndexRange::SuppressPageRange,
"nextParagraphs" => IndexRange::NextParagraphs(p.get("paragraphs").and_then(Value::as_u64).unwrap_or(1) as u32),
"nextParagraphs" => {
IndexRange::NextParagraphs(p.get("paragraphs").and_then(Value::as_u64).map_or(1, |x| u32::try_from(x).unwrap_or(u32::MAX)))
}
"see" => IndexRange::See(target()),
"seeAlso" => IndexRange::SeeAlso(target()),
r => return Err(bad("index.addReference", format!("unknown range `{r}`"))),
@@ -103,8 +105,9 @@ fn resolve(s: &Session, d: &Document, skip: Option<StoryId>) -> Vec<(StoryId, us
IndexRange::ToEndOfStory => page(st.len().saturating_sub(1)),
IndexRange::NextParagraphs(n) => {
let ranges = st.para_ranges();
let pi = (st.para_at(pos) + *n as usize).min(ranges.len() - 1);
page(ranges[pi].end.saturating_sub(1).max(ranges[pi].start))
let n = usize::try_from(*n).unwrap_or(usize::MAX);
let pi = st.para_at(pos).saturating_add(n).min(ranges.len().saturating_sub(1));
ranges.get(pi).and_then(|r| page(r.end.saturating_sub(1).max(r.start)))
}
_ => start,
};
+45 -3
View File
@@ -161,14 +161,25 @@ pub fn specs() -> Vec<CommandSpec> {
"Insert Pages…",
["Layout", "Pages"],
Some("Cmd+Shift+P"),
"{count?: 1, after?: page index (default: last), parent?: id|\"A-Parent\"|\"A\"|null}",
"{count?: 1 (1 to 9999), after?: 0-based page index (default: the last page), parent?: id|\"A-Parent\"|\"A\"|null}",
has_doc,
|s, p| {
let count = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 9999) as usize;
const ID: &str = "layout.pages.insert";
// A count that is given but is not a number of pages used to insert one page:
// `0`, `-1` and `1.5` all came out as 1, and 10000 was quietly capped.
let count = match p.get("count").filter(|v| !v.is_null()) {
None => 1,
Some(v) => match v.as_u64().filter(|n| (1..=9999).contains(n)).map(|n| n as usize) {
Some(n) => n,
None => return Err(bad(ID, format!("count {v}: 1 to 9999 pages"))),
},
};
let st = s.doc()?;
let n = st.doc.page_count();
// An `after` past the last page appends, which `insert_after_u64_max_appends_at_the_end`
// pins deliberately.
let after = p.get("after").and_then(Value::as_u64).map(|v| usize::try_from(v).unwrap_or(usize::MAX)).unwrap_or(n.saturating_sub(1));
let parent = parent_ref("layout.pages.insert", &st.doc, p.get("parent"))?.unwrap_or_else(|| st.doc.parents.first().map(|x| x.id));
let parent = parent_ref(ID, &st.doc, p.get("parent"))?.unwrap_or_else(|| st.doc.parents.first().map(|x| x.id));
s.edit(|d, _| {
let ids = d.insert_pages(Some(after), count, parent)?;
Ok(json!({"pages": ids.len(), "total": d.page_count()}))
@@ -2497,3 +2508,34 @@ mod parent_base_tests {
#[cfg(test)]
#[path = "parent_delete_tests.rs"]
mod parent_delete_tests;
#[cfg(test)]
mod insert_count_tests {
use serde_json::json;
use crate::Session;
/// A count that is given but is not a number of pages used to insert one page anyway:
/// `{"count": 0}` added a page, `-1` and `1.5` added one each, and `10000` was quietly capped
/// to 9999. A position past the last page appends, which is deliberate.
#[test]
fn a_page_count_insert_pages_cannot_mean_is_refused() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap();
let total = |s: &mut Session| s.execute("layout.pages.insert", &json!({"count": 1})).unwrap()["total"].as_u64().unwrap();
assert_eq!(total(&mut s), 3, "one page added");
for params in [json!({"count": 0}), json!({"count": -1}), json!({"count": 1.5}), json!({"count": 10000}), json!({"count": "two"})] {
let e = s.execute("layout.pages.insert", &params).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)));
}
}
+1
View File
@@ -17,6 +17,7 @@ mod endnotes;
mod export;
mod file;
pub(crate) mod find;
pub use edit::copied_objects_text;
pub use find::same_find_domain;
pub(crate) mod flex;
mod fonts;
+48 -7
View File
@@ -686,7 +686,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Align",
["Window", "Object & Layout", "Align"],
None,
"{edge: left|hcenter|right|top|vcenter|bottom, to?: selection|keyObject|margins|page|spread, ids?}",
"{edge: left|hcenter|right|top|vcenter|bottom, to?: selection|keyObject|margins|page|spread (default: keyObject while a key object is set), ids?}",
has_selection,
align
),
@@ -708,7 +708,7 @@ pub fn specs() -> Vec<CommandSpec> {
super::always,
|s, p| {
if let Some(n) = p.get("sides").and_then(Value::as_u64) {
s.prefs.polygon_sides = (n as u32).clamp(3, 100);
s.prefs.polygon_sides = n.clamp(3, 100) as u32;
}
if let Some(v) = p.get("starInset").and_then(Value::as_f64) {
s.prefs.star_inset = (v / 100.0).clamp(0.0, 1.0);
@@ -972,7 +972,7 @@ fn frame_create(s: &mut Session, p: &Value) -> Result<Value> {
let text = super::text_param(p, "text");
let caret = bool_or(p, "caret", content == "text");
let vertical = bool_or(p, "vertical", false);
let sides = p.get("sides").and_then(Value::as_u64).map_or(s.prefs.polygon_sides, |v| v as u32).clamp(3, 100);
let sides = p.get("sides").and_then(Value::as_u64).unwrap_or(u64::from(s.prefs.polygon_sides)).clamp(3, 100) as u32;
let inset = p.get("starInset").and_then(Value::as_f64).map_or(s.prefs.star_inset, |v| v / 100.0).clamp(0.0, 1.0);
let rect = Rect::new(rect.x0, rect.y0, rect.x1.max(rect.x0 + 0.5), rect.y1.max(rect.y0 + 0.5));
s.edit(|d, sel| {
@@ -1074,7 +1074,8 @@ fn adopt_or_dangle(dst: &mut Document, src: &Document, id: AssetId) -> AssetId {
dst.adopt_asset(src, id).unwrap_or_else(|| AssetId(dst.alloc()))
}
fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut HashMap<StoryId, StoryId>) {
/// Give `it` (an item of `src`) fresh ids from `dst`, with copies of its stories and the assets it uses.
pub(crate) fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut HashMap<StoryId, StoryId>) {
it.id = ItemId(dst.alloc());
let my_id = it.id;
if let Some(m) = it.media.as_mut()
@@ -2516,7 +2517,7 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result<Value> {
let Some(tf) = d.item_mut(*id).and_then(Item::text_frame_mut) else { continue };
let o = &mut tf.options;
if let Some(v) = p.get("columns").and_then(Value::as_u64) {
o.columns = (v as u32).clamp(1, 40);
o.columns = v.clamp(1, 40) as u32;
}
if let Some(v) = p.get("gutter").and_then(Value::as_f64) {
o.gutter = v.max(0.0);
@@ -3073,8 +3074,18 @@ fn align_reference(d: &Document, ids: &[ItemId], to: &str, key: Option<ItemId>)
fn align(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let edge = str_param(p, "edge").unwrap_or("left").to_string();
let to = str_param(p, "to").map(str::to_string).unwrap_or_else(|| if ids.len() > 1 { "selection".into() } else { "page".into() });
let key = s.doc()?.selection.key;
// A key object counts only while it is one of several items aligned. With one, InDesign's
// Align To switches to Key Object, so it is also the default target.
let key = s.doc()?.selection.key.filter(|k| ids.len() > 1 && ids.contains(k));
let to = str_param(p, "to").map(str::to_string).unwrap_or_else(|| {
if key.is_some() {
"keyObject".into()
} else if ids.len() > 1 {
"selection".into()
} else {
"page".into()
}
});
s.edit(|d, _| {
let r = align_reference(d, &ids, &to, key).ok_or_else(|| bad("object.align", "nothing to align"))?;
for id in &ids {
@@ -3787,6 +3798,21 @@ mod polygon_tests {
.unwrap();
assert_eq!(s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.subpaths[0].anchors.len(), 3);
}
#[test]
fn polygon_sides_clamp_before_narrowing() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("tool.polygonSettings", &json!({"sides": 4294967301u64})).unwrap();
assert_eq!(r["sides"], 100);
let r = s.execute("tool.polygonSettings", &json!({"sides": 4294967297u64})).unwrap();
assert_eq!(r["sides"], 100);
let id =
s.execute("frame.create", &json!({"rect": [0, 0, 50, 50], "shape": "polygon", "sides": 4294967301u64, "starInset": 0})).unwrap()["id"]
.as_u64()
.unwrap();
assert_eq!(s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.subpaths[0].anchors.len(), 100);
}
}
#[cfg(test)]
@@ -4497,6 +4523,21 @@ mod column_rule_tests {
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(opts(&s), before);
}
/// A column count past `u32::MAX` was cast to `u32` before the clamp, so
/// 2^32 + 1 wrapped to 1 column instead of clamping to the maximum, 40.
#[test]
fn huge_column_count_clamps_to_the_maximum() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text"})).unwrap();
let id = ItemId(r["id"].as_u64().unwrap());
let columns = |s: &Session| s.doc().unwrap().doc.item(id).unwrap().text_frame().unwrap().options.columns;
for (given, want) in [(4_294_967_297_u64, 40), (u64::MAX, 40), (0, 1)] {
s.execute("object.textFrameOptions", &json!({"ids": [id.0], "columns": given})).unwrap();
assert_eq!(columns(&s), want, "columns {given}");
}
}
}
#[cfg(test)]
+3 -2
View File
@@ -135,8 +135,9 @@ impl OutputRoot {
{
use std::os::unix::fs::PermissionsExt;
// Match fs::write for a new output: 0666 restricted by the caller's umask.
builder.permissions(std::fs::Permissions::from_mode(0o666));
// Like fs::write for a new output: request 0666 and let the process umask decide the
// effective mode (0644 with the usual 022, 0664 with 002).
builder.permissions(std::fs::Permissions::from_mode(0o666)); // NOSONAR
}
let file = builder.tempfile_in(parent).map_err(|e| path_error(path, e))?;
#[cfg(unix)]
+177 -15
View File
@@ -233,11 +233,12 @@ fn same_rect(a: Rect, b: Rect) -> bool {
}
/// Thread new frames until the story fits: first into the columns after the last frame's column
/// on its page (when it sits in one), then one frame per column on each page it adds. With
/// `primary` (Smart Text Reflow of the primary text frame), each added page gets one frame on the
/// live area with the page's columns instead. Empty frames it made at the end of the thread are
/// removed again, except the first on a page it didn't add; a page it added that holds only such
/// a frame goes with it. Returns pages added.
/// on its page (when it sits in one), then one frame per column on each page that follows it,
/// whatever else the page holds, and on each page it adds at the end of the document, as InDesign
/// does. With `primary` (Smart Text Reflow of the primary text frame), pages are added right after
/// the last frame's page, each with one frame on the live area with the page's columns instead.
/// Empty frames it made at the end of the thread are removed again; a page it added that holds
/// only such a frame goes with it. Returns pages added.
/// A story on a parent spread doesn't autoflow: its frames thread only on that parent. Errors
/// name `cmd`, the command that autoflows.
pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &str, primary: bool) -> Result<usize> {
@@ -252,6 +253,8 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s
let mut made = Vec::new();
let mut new_pages = Vec::new();
let mut first = true;
// The next page of the document autoflow may set frames on; it only moves forward.
let mut following = 0;
loop {
let (overset, held, full) = fit(d, sid);
let Some(at) = overset else { break };
@@ -267,16 +270,33 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s
let rects = flow_rects(d, dir, vertical, page);
let next = d.item(last).and_then(|it| rects.iter().position(|r| same_rect(*r, it.bounds()))).map_or(rects.len(), |k| k + 1);
if let (Some(rest), Some((si, _))) = (rects.get(next..).filter(|r| !r.is_empty()), d.page_loc(page)) {
for r in rest.iter().take(want) {
// Whole pages: an estimate that stops inside one would leave its later columns out
// of the thread for good. The cleanup below takes the frames the text doesn't reach.
for r in rest {
prev = add_flow_frame(d, si, *r, layer, prev)?;
made.push(prev);
}
continue;
}
}
following = following.max(page + 1);
if !primary && following < d.page_count() {
let mut left = want;
while left > 0 && following < d.page_count() {
let Some((si, _)) = d.page_loc(following) else { break };
for r in flow_rects(d, dir, vertical, following) {
prev = add_flow_frame(d, si, r, layer, prev)?;
made.push(prev);
left = left.saturating_sub(1);
}
following += 1;
}
continue;
}
if added >= max_pages {
return Err(bad(cmd, format!("autoflow stopped after {max_pages} pages")));
}
// Past the following pages, `page` is the document's last: autoflow adds pages at its end.
let per_page = if vertical { 1 } else { d.page(page).map_or(1, |p| p.column_rects().len().max(1)) };
let pages = want.div_ceil(per_page).clamp(1, max_pages - added);
let parent = d.page_loc(page).and_then(|(si, pi)| d.spreads[si].pages[pi].parent);
@@ -304,16 +324,23 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s
}
let page = d.page_of_item(f);
if d.page_of_item(*before) != page {
// The first frame on its page: the estimate added more pages than the text needs.
// Drop the page too when autoflow added it and the frame is all it holds.
let Some(pg) = page.filter(|pg| d.page(*pg).is_some_and(|p| new_pages.contains(&p.id))) else { break };
let alone = d.spreads.iter().flat_map(|sp| sp.items.iter()).all(|it| it.id == f || d.page_of_item(it.id) != Some(pg));
if !alone {
break;
// The first frame on its page: the estimate asked for more pages than the text needs.
// Drop the page too when autoflow added it and the frame is all it holds; a page the
// document had keeps whatever else is on it.
match page.filter(|pg| d.page(*pg).is_some_and(|p| new_pages.contains(&p.id))) {
Some(pg) => {
let alone = d.spreads.iter().flat_map(|sp| sp.items.iter()).all(|it| it.id == f || d.page_of_item(it.id) != Some(pg));
if !alone {
break;
}
d.remove_item(f)?;
d.delete_pages(&[pg])?;
added = added.saturating_sub(1);
}
None => {
d.remove_item(f)?;
}
}
d.remove_item(f)?;
d.delete_pages(&[pg])?;
added = added.saturating_sub(1);
} else {
d.remove_item(f)?;
}
@@ -568,6 +595,141 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// A facing-pages document with two columns, its inside margin wider than the outside one, so
/// left and right pages have their columns in different places. A graphic frame sits on the
/// page `marker` (0-based). Returns the session and the marker's id.
fn facing_document(pages: usize, marker: usize) -> (Session, u64) {
let mut s = Session::new();
let margins = json!({"top": 54, "bottom": 54, "inside": 72, "outside": 36});
s.execute("file.new", &json!({"preset": "Letter", "pages": pages, "facingPages": true, "columns": 2, "gutter": 18, "margins": margins}))
.unwrap();
let d = &s.doc().unwrap().doc;
let (si, _) = d.page_loc(marker).unwrap();
let m = d.page(marker).unwrap().margin_rect();
let rect = [m.x0 + 20.0, m.y0 + 20.0, m.x0 + 120.0, m.y0 + 120.0];
let id = s.execute("frame.create", &json!({"rect": rect, "spread": si, "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
// Selected, an empty frame would take the placed text.
s.execute("edit.deselectAll", &json!({})).unwrap();
(s, id)
}
/// Every frame of the story on the column of its page, the story running through the pages in
/// order, two frames a page.
fn assert_frames_in_columns(d: &Document, frames: &[designcraft_doc::ItemId]) {
for (i, f) in frames.iter().enumerate() {
let page = d.page_of_item(*f).unwrap();
assert_eq!(page, i / 2, "frame {i}");
assert_eq!(d.item(*f).unwrap().bounds(), d.page(page).unwrap().column_rects()[i % 2], "frame {i}");
}
}
#[test]
fn autoflow_fills_the_pages_that_follow_before_adding_any() {
// Gap 26: autoflow inserted pages after the first frame's page, in front of the empty
// pages the document already had. InDesign threads frames on those first.
let dir = std::env::temp_dir().join(format!("dc-place-following-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("long.txt");
std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 24].join("\n")).unwrap();
let (mut s, marker) = facing_document(6, 2);
let ids: Vec<_> = (0..6).map(|i| s.doc().unwrap().doc.page(i).unwrap().id).collect();
let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "page": 1, "autoflow": true})).unwrap();
assert_eq!(r["overset"], false, "{r}");
assert_eq!(r["pagesAdded"], 0, "{r}");
let d = &s.doc().unwrap().doc;
assert_eq!((0..d.page_count()).map(|i| d.page(i).unwrap().id).collect::<Vec<_>>(), ids);
let sid = StoryId(r["story"].as_u64().unwrap());
let frames = d.story(sid).unwrap().frames.clone();
// Past page 3, which holds the marker: frames go on a page whatever else it holds.
assert!(d.page_of_item(*frames.last().unwrap()).unwrap() > 2, "{} frames", frames.len());
assert_frames_in_columns(d, &frames);
// The text ends before the document does: no empty frame after it.
let cs = designcraft_compose::compose_story(d, sid, &Default::default());
assert!(frames.iter().all(|f| cs.frame(*f).is_some_and(|ft| !ft.range.is_empty())));
assert!(d.page_of_item(*frames.last().unwrap()).unwrap() < 5);
assert_eq!(d.page_of_item(designcraft_doc::ItemId(marker)), Some(2));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn autoflow_adds_pages_at_the_end_of_the_document() {
let dir = std::env::temp_dir().join(format!("dc-place-end-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("long.txt");
std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 60].join("\n")).unwrap();
let (mut s, marker) = facing_document(3, 2);
let ids: Vec<_> = (0..3).map(|i| s.doc().unwrap().doc.page(i).unwrap().id).collect();
let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "page": 1, "autoflow": true})).unwrap();
assert_eq!(r["overset"], false, "{r}");
let d = &s.doc().unwrap().doc;
assert!(d.page_count() > 4, "{r}");
assert_eq!(r["pagesAdded"].as_u64(), Some(d.page_count() as u64 - 3), "{r}");
// The document's own pages come first, the added ones after the last of them.
assert_eq!((0..3).map(|i| d.page(i).unwrap().id).collect::<Vec<_>>(), ids);
assert_eq!(d.page_of_item(designcraft_doc::ItemId(marker)), Some(2));
let frames = d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames.clone();
assert_frames_in_columns(d, &frames);
assert_eq!(d.page_of_item(*frames.last().unwrap()), Some(d.page_count() - 1));
// One undo step takes the frames, the pages and the story away.
s.execute("edit.undo", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!((0..d.page_count()).map(|i| d.page(i).unwrap().id).collect::<Vec<_>>(), ids);
assert!(d.story(StoryId(r["story"].as_u64().unwrap())).is_none());
assert_eq!(d.spreads.iter().map(|sp| sp.items.len()).sum::<usize>(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn autoflow_fills_every_column_of_a_following_page() {
// A first frame across the whole live area holds twice what a column does, so autoflow
// asks for half the frames it needs. Asked page by page, it left the later columns of a
// page out of the thread and went on to the next page.
let dir = std::env::temp_dir().join(format!("dc-place-under-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("long.txt");
std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 30].join("\n")).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({"preset": "Letter", "pages": 6, "facingPages": false, "columns": 2, "gutter": 18, "margins": 54})).unwrap();
let m = s.doc().unwrap().doc.page(0).unwrap().margin_rect();
let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "rect": [m.x0, m.y0, m.x1, m.y1], "autoflow": true})).unwrap();
assert_eq!(r["overset"], false, "{r}");
assert_eq!(r["pagesAdded"], 0, "{r}");
let d = &s.doc().unwrap().doc;
let frames = &d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames;
assert_eq!(d.item(frames[0]).unwrap().bounds(), m);
assert!(d.page_of_item(*frames.last().unwrap()).unwrap() >= 3, "{} frames", frames.len());
for (k, f) in frames[1..].iter().enumerate() {
let page = d.page_of_item(*f).unwrap();
assert_eq!(page, 1 + k / 2, "frame {}", k + 1);
assert_eq!(d.item(*f).unwrap().bounds(), d.page(page).unwrap().column_rects()[k % 2], "frame {}", k + 1);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn autoflow_leaves_no_empty_frame_on_the_pages_that_follow() {
// A small first frame makes autoflow ask for more frames than the columns need; the ones
// it set on the following pages past the end of the text go again, the pages stay.
let dir = std::env::temp_dir().join(format!("dc-place-small-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("long.txt");
std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 12].join("\n")).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({"preset": "Letter", "pages": 8, "facingPages": false, "margins": 54})).unwrap();
let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "rect": [100, 100, 200, 130], "autoflow": true})).unwrap();
assert_eq!(r["overset"], false, "{r}");
assert_eq!(r["pagesAdded"], 0, "{r}");
let d = &s.doc().unwrap().doc;
assert_eq!(d.page_count(), 8);
let sid = StoryId(r["story"].as_u64().unwrap());
let frames = &d.story(sid).unwrap().frames;
assert!(frames.len() >= 2, "{r}");
let cs = designcraft_compose::compose_story(d, sid, &Default::default());
assert!(frames.iter().all(|f| cs.frame(*f).is_some_and(|ft| !ft.range.is_empty())), "{} frames", frames.len());
assert_eq!(d.spreads.iter().map(|sp| sp.items.len()).sum::<usize>(), frames.len());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn autoflow_adds_only_the_pages_breaks_ask_for() {
// Regression (#262): a frame a break ends early made autoflow take it for a frame that
+4 -2
View File
@@ -17,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Print…",
["File"],
Some("Cmd+P"),
"{printer?, copies?: 1, pages?: \"1-3,5\", spreads?, marks?, bleed?, dryRun?: bool (export and preview submission without printing)} → {printer, copies, pages, command, bytes}",
"{printer?, copies?: 1, pages?: \"1-3,5\", spreads?, marks?, bleed?, dryRun?: bool (export and preview submission without printing), created?: int (Unix seconds, as file.exportPdf)} → {printer, copies, pages, command, bytes}",
has_doc,
print
),
@@ -95,7 +95,8 @@ mod tests {
fn print_submits_the_selected_export_and_reports_refusal() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 4})).unwrap();
let params = json!({"printer": "Office (east)", "copies": 2, "pages": "2-3", "marks": true, "bleed": true});
// A fixed creation date: both exports would otherwise take the clock's second.
let params = json!({"printer": "Office (east)", "copies": 2, "pages": "2-3", "marks": true, "bleed": true, "created": 1_700_000_000});
let expected = {
let d = &s.doc().unwrap().doc;
let options = super::super::export::options(&params, 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());
}
}
+28 -6
View File
@@ -297,7 +297,7 @@ fn table_specs() -> Vec<CommandSpec> {
"Cell Options",
[],
None,
"{fill?: swatch, tint?: 0..1, insets?: n | [t,l,b,r], vj? | verticalJustification?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|fixed|emboxHeight, firstBaselineMin?: pt, text?, stroke?: {weight?, color?: swatch, tint?: 0..1, type?: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed | {kind:…}, gapColor?: swatch, gapTint?: 0..1, edges?: name | [names] (all|outer|inner|top|left|bottom|right|innerHorizontal|innerVertical; default all)}, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — top/left/bottom/right are the target range's outer sides, innerHorizontal/innerVertical the lines between its rows/columns",
"{fill?: swatch, tint?: 0..1, insets?: n | [t,l,b,r], vj? | verticalJustification?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|fixed|emboxHeight, firstBaselineMin?: pt, rotation?: 0|90|180|270 (Cell Options › Text › Rotation; kept and written back to IDML, composed horizontally for now), text?, stroke?: {weight?, color?: swatch, tint?: 0..1, type?: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed | {kind:…}, gapColor?: swatch, gapTint?: 0..1, edges?: name | [names] (all|outer|inner|top|left|bottom|right|innerHorizontal|innerVertical; default all)}, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — top/left/bottom/right are the target range's outer sides, innerHorizontal/innerVertical the lines between its rows/columns",
in_table,
set_cell
),
@@ -971,6 +971,15 @@ fn set_cell(s: &mut Session, p: &Value) -> Result<Value> {
None => None,
};
let (first_baseline, first_baseline_min) = first_baseline_params(p, ID)?;
// Cell Options › Text › Rotation. The value is kept and written back to IDML; only 0 is
// composed so far, which the IDML import reports (#1468).
let rotation = match p.get("rotation").filter(|v| !v.is_null()) {
Some(v) => match v.as_f64().map(|d| d.rem_euclid(360.0)) {
Some(d) if d == 0.0 || d == 90.0 || d == 180.0 || d == 270.0 => Some(d),
_ => return Err(bad(ID, "rotation: 0, 90, 180 or 270 degrees")),
},
None => None,
};
let text = str_param(p, "text").map(|_| super::text_param(p, "text"));
let stroke = match p.get("stroke").filter(|v| !v.is_null()) {
Some(sv) => Some((StrokeEdit::parse(doc, sv, ID)?, EdgeSel::parse(sv.get("edges"), ID)?)),
@@ -1021,6 +1030,9 @@ fn set_cell(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = first_baseline_min {
cell.first_baseline_min = v;
}
if let Some(v) = rotation {
cell.rotation = v;
}
if let Some(tx) = &text {
let len = cell.text.len();
cell.text.replace(0..len, tx);
@@ -1393,14 +1405,14 @@ fn parse_alt(v: &Value) -> Option<AltFills> {
}
let d = AltFills::default();
Some(AltFills {
first: v.get("first").and_then(Value::as_u64).map_or(d.first, |x| x as u32),
first: v.get("first").and_then(Value::as_u64).map_or(d.first, |x| u32::try_from(x).unwrap_or(u32::MAX)),
first_color: color_param(v, "firstColor").unwrap_or(d.first_color),
first_tint: v.get("firstTint").and_then(Value::as_f64).map_or(d.first_tint, |x| x as f32),
next: v.get("next").and_then(Value::as_u64).map_or(d.next, |x| x as u32),
next: v.get("next").and_then(Value::as_u64).map_or(d.next, |x| u32::try_from(x).unwrap_or(u32::MAX)),
next_color: color_param(v, "nextColor").unwrap_or(d.next_color),
next_tint: v.get("nextTint").and_then(Value::as_f64).map_or(d.next_tint, |x| x as f32),
skip_first: v.get("skipFirst").and_then(Value::as_u64).map_or(0, |x| x as u32),
skip_last: v.get("skipLast").and_then(Value::as_u64).map_or(0, |x| x as u32),
skip_first: v.get("skipFirst").and_then(Value::as_u64).map_or(0, |x| u32::try_from(x).unwrap_or(u32::MAX)),
skip_last: v.get("skipLast").and_then(Value::as_u64).map_or(0, |x| u32::try_from(x).unwrap_or(u32::MAX)),
})
}
@@ -1548,7 +1560,7 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
.filter_map(|(r, c)| {
let cell = t.cell(r, c)?;
Some(json!({"row": r, "col": c, "rowSpan": cell.row_span, "colSpan": cell.col_span, "text": cell.text.text, "fill": cell.fill,
"insets": cell.insets, "vj": cell.vj, "firstBaseline": cell.first_baseline, "firstBaselineMin": cell.first_baseline_min, "style": cell.style}))
"insets": cell.insets, "vj": cell.vj, "firstBaseline": cell.first_baseline, "firstBaselineMin": cell.first_baseline_min, "rotation": cell.rotation, "style": cell.style}))
})
.collect();
Ok(json!({
@@ -1832,6 +1844,16 @@ mod style_tests {
let cell = got["cells"].as_array().unwrap().iter().find(|c| c["row"] == 0 && c["col"] == 0).unwrap().clone();
assert_eq!((cell["firstBaseline"].as_str(), cell["firstBaselineMin"].as_f64()), (Some("capHeight"), Some(3.0)));
assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaseline": "middle"})).is_err());
// Cell Options › Text › Rotation: carried and written back, composed horizontally so far,
// which the IDML import reports (#1468).
for (given, want) in [(90, 90.0), (270, 270.0), (0, 0.0), (-90, 270.0), (450, 90.0)] {
s.execute("table.setCell", &json!({"row": 0, "col": 0, "rotation": given})).unwrap_or_else(|e| panic!("{given}: {e}"));
let cell = s.execute("table.get", &json!({})).unwrap()["cells"][0].clone();
assert_eq!(cell["rotation"].as_f64(), Some(want), "{given}");
}
for bad in [45, 1, 100] {
assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "rotation": bad})).is_err(), "{bad}");
}
assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaselineMin": -1})).is_err());
s.execute("style.cell.create", &json!({"name": "Leading", "firstBaseline": "leading"})).unwrap();
s.execute("style.cell.apply", &json!({"name": "Leading", "row": 1, "col": 1})).unwrap();
+56
View File
@@ -1502,12 +1502,29 @@ fn ruby_cmd(s: &mut Session, p: &Value) -> Result<Value> {
/// A character fill must name a swatch of the document.
pub(crate) fn validate_char_fill(document: &designcraft_doc::Document, attrs: &CharAttrs, command_id: &str) -> Result<()> {
validate_char_size(attrs, command_id)?;
match &attrs.fill {
Some(fill) => super::object::known_swatch(document, fill, command_id, "fill"),
None => Ok(()),
}
}
/// Type size is 0.1 to 1296 pt, the range this engine already applies to a ruby annotation's
/// size. Nothing checked it for ordinary text, so `{"size": 0}` set type with no size, a negative
/// size was accepted as well, and 1e9 pt asked the composer for a line a million times the page.
pub(crate) fn validate_char_size(attrs: &CharAttrs, command_id: &str) -> Result<()> {
match attrs.size {
Some(size) if !(MIN_TYPE_SIZE..=MAX_TYPE_SIZE).contains(&size) => {
Err(bad(command_id, format!("size {size}: {MIN_TYPE_SIZE} to {MAX_TYPE_SIZE} pt")))
}
_ => Ok(()),
}
}
/// Type size limits (InDesign's, and the ones `type.ruby` already uses).
pub(crate) const MIN_TYPE_SIZE: f64 = 0.1;
pub(crate) const MAX_TYPE_SIZE: f64 = 1296.0;
/// Byte `pos` is the hidden paragraph mark of an *empty* paragraph: the only character on that
/// line, and so the one a line-affecting attribute like leading can land on right away instead of
/// waiting for the next typed character (otherwise an empty line could never get its own leading).
@@ -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, &params).expect_err("refused").to_string();
assert!(e.contains("0.1 to 1296 pt"), "{command}: {e}");
}
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Thread Text Frames",
[],
None,
"{from?, to?, rect?: [x0,y0,x1,y1] (spread coords), spread?, autoflow?: bool} — threads frame `to` right after frame `from` (between `from` and the frame that followed it, if any). `to` is an empty frame or a text frame nothing threads into yet; its text joins the end of the story. Give `rect` in place of `from` or `to` to thread a new text frame made there on `spread`: `{from, rect}` adds it after `from`, `{rect, to}` in front of `to`. autoflow: then add pages with threaded frames until the story fits. Selects the frame that joined the thread. → {story, from, to, overset, pagesAdded}",
"{from?, to?, rect?: [x0,y0,x1,y1] (spread coords), spread?, autoflow?: bool} — threads frame `to` right after frame `from` (between `from` and the frame that followed it, if any). `to` is an empty frame or a text frame nothing threads into yet; its text joins the end of the story. Give `rect` in place of `from` or `to` to thread a new text frame made there on `spread`: `{from, rect}` adds it after `from`, `{rect, to}` in front of `to`. autoflow: then thread a frame per column on the pages that follow, adding pages at the end of the document until the story fits. Selects the frame that joined the thread. → {story, from, to, overset, pagesAdded}",
has_doc,
thread
),
+47 -1
View File
@@ -22,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Hyperlink Destination…",
["Type", "Hyperlinks & Cross-References"],
None,
"{name} — a text anchor at the insertion point",
"{name} — a text anchor at the insertion point; the name is the document's, so it must not be one another anchor already has",
in_story_text,
create_anchor
),
@@ -103,6 +103,14 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
fn create_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("anchor.create", "missing name"))?.to_string();
if name.trim().is_empty() {
return Err(bad("anchor.create", "a text anchor needs a name (a cross-reference and a hyperlink find it by that name)"));
}
// Two anchors of the same name made every reference to it ambiguous: `xref.insert` and
// `hyperlink.create` take the first they find, and a file writes both under one name.
if s.doc()?.doc.stories.values().any(|st| st.anchors.iter().any(|a| a.name == name)) {
return Err(bad("anchor.create", format!("a text anchor named `{name}` is already in this document (anchor.list names them)")));
}
s.edit(|d, sel| {
let t = sel.text.ok_or_else(|| bad("anchor.create", "no insertion point"))?;
let st = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
@@ -215,6 +223,44 @@ mod tests {
assert_eq!(s.execute("xref.list", &json!({})).unwrap()[0]["text"], "(p. 3)");
}
/// Two text anchors of the same name made every reference to it ambiguous: `xref.insert
/// {"anchor": "A"}` and `hyperlink.create {"anchor": "A"}` take the first they find, and an
/// exported file writes both destinations under one name.
#[test]
fn a_text_anchor_name_is_the_documents_so_it_is_not_reused() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap();
let a = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Photo one here"})).unwrap();
let select = |s: &mut Session, story: serde_json::Value, from: usize| {
s.execute("text.select", &json!({"story": story, "anchor": from, "focus": from})).unwrap();
};
select(&mut s, a["story"].clone(), 0);
s.execute("anchor.create", &json!({"name": "Photo 1"})).unwrap();
// The same name again, in the same story and in another one.
select(&mut s, a["story"].clone(), 10);
let e = s.execute("anchor.create", &json!({"name": "Photo 1"})).expect_err("refused").to_string();
assert!(e.contains("already in this document"), "{e}");
let sp = s.doc().unwrap().doc.page_loc(1).unwrap().0;
let b = s.execute("frame.create", &json!({"spread": sp, "rect": [72, 72, 400, 200], "content": "text", "text": "Photo two"})).unwrap();
select(&mut s, b["story"].clone(), 0);
assert!(s.execute("anchor.create", &json!({"name": "Photo 1"})).is_err(), "not in another story either");
// A name of spaces is not a name.
assert!(s.execute("anchor.create", &json!({"name": " "})).is_err());
assert!(s.execute("anchor.create", &json!({"name": ""})).is_err());
// One anchor, so a reference by name can only mean that one.
let anchors = s.execute("anchor.list", &json!({})).unwrap();
assert_eq!(anchors.as_array().unwrap().len(), 1, "{anchors}");
// Another name is fine.
s.execute("anchor.create", &json!({"name": "Photo 2"})).unwrap();
assert_eq!(s.execute("anchor.list", &json!({})).unwrap().as_array().unwrap().len(), 2);
// The check is on the name, not on where it is: a name that differs only in case is a
// different name, as it is everywhere else in the document.
select(&mut s, b["story"].clone(), 0);
s.execute("anchor.create", &json!({"name": "photo 1"})).expect("a different name");
}
#[test]
fn cross_reference_into_a_story_with_a_running_header_composes() {
let mut s = Session::new();
+105
View File
@@ -1698,3 +1698,108 @@ fn direct_selection_on_a_selected_frame_deletes_only_its_image() {
let st = s.doc().unwrap();
assert!(st.selection.content && st.selection.items == [designcraft_doc::ItemId(holder)], "the pasted-in items are selected: {:?}", st.selection);
}
/// 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");
}
+23
View File
@@ -1097,3 +1097,26 @@ fn convert_rows_keeps_merged_cells_on_one_side() {
assert!(!convert_enabled(&s, "Body"));
check(&s);
}
#[test]
fn alternating_fill_counts_saturate_instead_of_wrapping() {
let (mut s, _, _) = session_with_frame();
s.execute("table.insert", &json!({"rows": 3, "cols": 2})).unwrap();
s.execute("table.options", &json!({"altRows": {"first": 4294967297u64, "next": 1, "skipFirst": 4294967296u64, "skipLast": 9999999999u64}}))
.unwrap();
let t = s.execute("table.get", &json!({})).unwrap();
let a = &t["options"]["altRows"];
assert_eq!(a["first"], u32::MAX);
assert_eq!(a["skipFirst"], u32::MAX);
assert_eq!(a["skipLast"], u32::MAX);
assert_eq!(a["next"], 1);
}
#[test]
fn index_next_paragraphs_saturates_instead_of_wrapping() {
let (mut s, _, _) = session_with_frame();
s.execute("index.addReference", &json!({"topic": "Intro", "range": "nextParagraphs", "paragraphs": 4294967296u64})).unwrap();
let l = s.execute("index.list", &json!({})).unwrap();
assert_eq!(l[0]["range"]["type"], "nextParagraphs");
assert_eq!(l[0]["range"]["value"], u32::MAX);
}
@@ -1,5 +1,3 @@
#![cfg(not(target_arch = "wasm32"))]
use designcraft_engine::Session;
use serde_json::json;
use std::path::Path;
@@ -1,5 +1,4 @@
//! Browser Print must reject native spooler operations without trapping the editor.
#![cfg(target_arch = "wasm32")]
use designcraft_engine::Session;
use serde_json::json;
@@ -1,5 +1,3 @@
#![cfg(not(target_arch = "wasm32"))]
use designcraft_engine::Session;
use serde_json::json;
+23
View File
@@ -0,0 +1,23 @@
#[cfg(not(target_arch = "wasm32"))]
mod book_safety;
#[cfg(target_arch = "wasm32")]
mod browser_print;
mod character_style_from_selection;
mod close_retention;
#[cfg(not(target_arch = "wasm32"))]
mod collection_state_atomicity;
mod combined_close_glyph;
mod distribute_locks;
mod export_page_input;
mod insert_pages_bounds;
mod param_keys;
mod pdf_backend;
mod preflight_resolution;
mod primary_frame_columns;
#[cfg(not(target_arch = "wasm32"))]
mod save_state_atomicity;
mod stroke_tint_range;
mod text_decorations;
mod text_insert_memory;
mod text_thread_regressions;
mod thread_color_safety;
File renamed without changes.
File renamed without changes.
@@ -1,5 +1,3 @@
#![cfg(not(target_arch = "wasm32"))]
use std::path::Path;
use std::sync::Arc;
+2
View File
@@ -0,0 +1,2 @@
mod export_tag_styles;
mod grouped_reading_order;
+4
View File
@@ -0,0 +1,4 @@
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod system_fonts;
@@ -1,7 +1,5 @@
//! A new session finds the installed system fonts by family name (#22). One test in its own
//! process, so the process-wide database starts fresh, as it does when the app starts.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use designcraft_fonts::{FontDb, system_font_dirs};
+10
View File
@@ -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() {
+33 -1
View File
@@ -2797,7 +2797,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 +2824,20 @@ impl<'a> Ex<'a> {
el
}
/// The frame of a table cell's graphic: an inline `Rectangle` around the shown image, or `None`
/// when the graphic's asset is missing.
fn cell_graphic_el(&mut self, g: &designcraft_doc::Graphic) -> Option<El> {
self.d.assets.get(&g.asset)?;
let shown = g.xf.transform_rect_bbox(designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1));
let layer = self.d.layers.first().map(|l| l.id).unwrap_or(designcraft_doc::ids::LayerId(0));
let mut item = Item::new(designcraft_doc::ids::ItemId(0), layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(shown));
item.content = Content::Graphic(g.clone());
let mut el = self.item_el(&item, Affine::IDENTITY);
el.set("Self", self.fresh());
el.push(El::new("AnchoredObjectSetting").attr("AnchoredPosition", "InlinePosition").attr("AnchorYoffset", "0"));
Some(el)
}
fn psr_el(&mut self, pf: &ParaFormat) -> El {
let mut el = El::new("ParagraphStyleRange").attr("AppliedParagraphStyle", names::style_self("ParagraphStyle", PARA_BUILTINS, &pf.style));
let mut props = Vec::new();
+43 -11
View File
@@ -295,6 +295,10 @@ struct Importer<'r> {
parents: Vec<Spread>,
/// Things read but not kept as they were, for the import report.
warnings: Vec<String>,
/// Cells whose text is rotated (Cell Options › Text › Rotation): the angle is kept and
/// written back, but only 0 is composed, so a narrow header column of vertical labels comes
/// in horizontal and wraps mid-word (#1468).
rotated_cells: usize,
parent_ids: HashMap<String, SpreadId>,
spreads: Vec<Spread>,
page_index: HashMap<String, usize>,
@@ -387,13 +391,10 @@ fn mojikumi_row(e: &El) -> Result<designcraft_doc::cjk::MojikumiAki> {
Ok(row)
}
/// The numbers in a whitespace-separated list; empty when any number is NaN or infinite (JSON
/// can't store them, and dropping just those would shift the rest).
fn nums(s: &str) -> Vec<f64> {
s.split_whitespace().filter_map(|v| v.parse().ok()).collect()
}
/// Like [`nums`], but empty when any number is NaN or infinite.
fn finite_nums(s: &str) -> Vec<f64> {
let v = nums(s);
let v: Vec<f64> = s.split_whitespace().filter_map(|v| v.parse().ok()).collect();
if v.iter().all(|x| x.is_finite()) { v } else { Vec::new() }
}
@@ -536,6 +537,7 @@ impl<'r> Importer<'r> {
story_ids: HashMap::new(),
parents: Vec::new(),
warnings: Vec::new(),
rotated_cells: 0,
parent_ids: HashMap::new(),
spreads: Vec::new(),
page_index: HashMap::new(),
@@ -791,6 +793,12 @@ impl<'r> Importer<'r> {
include_prefix: e.get("IncludeSectionPrefix") == Some("true"),
});
}
if self.rotated_cells > 0 {
let n = self.rotated_cells;
self.warnings.push(format!(
"{n} table cell(s) have rotated text (Cell Options › Text › Rotation): the angle is kept and written back, but the text is composed horizontally"
));
}
self.sections.sort_by_key(|s| s.start);
self.sections.dedup_by_key(|s| s.start);
if self.sections.first().is_none_or(|s| s.start != 0) {
@@ -895,6 +903,15 @@ impl<'r> Importer<'r> {
_ if v.len() >= 4 => Color::cmyk((v[0] / 100.0) as f32, (v[1] / 100.0) as f32, (v[2] / 100.0) as f32, (v[3] / 100.0) as f32),
_ => return None,
};
// Huge values (1e300) overflow `f32` to infinity.
let parts = match c {
Color::Rgb { r, g, b } => [r, g, b, 0.0],
Color::Cmyk { c, m, y, k } => [c, m, y, k],
Color::Gray { k } => [k, 0.0, 0.0, 0.0],
};
if !parts.iter().all(|v| v.is_finite()) {
return None;
}
let ty = if e.get("Model") == Some("Spot") { ColorType::Spot } else { ColorType::Process };
Some((c, ty))
}
@@ -947,14 +964,14 @@ impl<'r> Importer<'r> {
let (Some(id), Some(name)) = (e.get("Self"), e.get("Name")) else { continue };
let kind = match e.local() {
"StripedStrokeStyle" => designcraft_doc::StrokeType::Stripes {
bands: finite_nums(e.get("StripeArray").unwrap_or(""))
bands: nums(e.get("StripeArray").unwrap_or(""))
.as_chunks::<2>()
.0
.iter()
.map(|c| (c[0] / 100.0, (c[1] - c[0]) / 100.0))
.collect(),
},
"DashedStrokeStyle" => designcraft_doc::StrokeType::Dashed { pattern: finite_nums(e.get("DashArray").unwrap_or("")) },
"DashedStrokeStyle" => designcraft_doc::StrokeType::Dashed { pattern: nums(e.get("DashArray").unwrap_or("")) },
_ => designcraft_doc::StrokeType::Dotted,
};
let _ = id;
@@ -2170,9 +2187,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 +2255,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 +2266,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;
@@ -2856,7 +2883,7 @@ impl<'r> Importer<'r> {
{
it.fill.gradient_angle = Some(a);
}
let start: Option<Vec<f64>> = e.prop("GradientFillStart").map(|s| finite_nums(&s));
let start: Option<Vec<f64>> = e.prop("GradientFillStart").map(|s| nums(&s));
if let (Some([x, y]), Some(len)) = (start.as_deref().and_then(|v| <[f64; 2]>::try_from(v).ok()), e.num("GradientFillLength"))
&& len > 0.0
{
@@ -2874,7 +2901,7 @@ impl<'r> Importer<'r> {
{
it.stroke.gradient_angle = Some(a);
}
let start: Option<Vec<f64>> = e.prop("GradientStrokeStart").map(|s| finite_nums(&s));
let start: Option<Vec<f64>> = e.prop("GradientStrokeStart").map(|s| nums(&s));
if let (Some([x, y]), Some(len)) = (start.as_deref().and_then(|v| <[f64; 2]>::try_from(v).ok()), e.num("GradientStrokeLength"))
&& len > 0.0
{
@@ -3702,6 +3729,11 @@ fn path_capped(pg: &El, max: usize) -> PathData {
let closed = g.get("PathOpen") != Some("true");
let mut anchors = Vec::new();
if let Some(arr) = g.find("PathPointArray") {
// A NaN or infinite point drops its subpath (JSON can't store it).
let non_finite = |s: Option<&str>| s.is_some_and(|s| s.split_whitespace().any(|v| v.parse::<f64>().is_ok_and(|x| !x.is_finite())));
if arr.find_all("PathPointType").take(left).any(|p| ["Anchor", "LeftDirection", "RightDirection"].iter().any(|k| non_finite(p.get(k)))) {
continue;
}
for p in arr.find_all("PathPointType").take(left) {
let Some(a) = parse_point(p.get("Anchor")) else { continue };
let hin = parse_point(p.get("LeftDirection")).unwrap_or(a);
+24 -3
View File
@@ -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 {
+170
View File
@@ -1100,6 +1100,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#"<Cell Name="{name}" LeftEdge{attr}="{value}" RightEdge{attr}="{value}" TopEdge{attr}="{value}" BottomEdge{attr}="{value}">
<ParagraphStyleRange><CharacterStyleRange><Content>X</Content></CharacterStyleRange></ParagraphStyleRange></Cell>"#
)
};
let story = format!(
r#"<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging">
<Story Self="s1"><ParagraphStyleRange><CharacterStyleRange>
<Table HeaderRowCount="0" FooterRowCount="0">
<Row Name="0" MinimumHeight="24"/>
<Column Name="0" SingleColumnWidth="60"/><Column Name="1" SingleColumnWidth="60"/>
{}{}
</Table></CharacterStyleRange></ParagraphStyleRange></Story></idPkg:Story>"#,
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());
@@ -4368,3 +4397,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("<Cell ").nth(3).unwrap_or_default();
let cell = cell.split("</Cell>").next().unwrap_or_default();
assert!(cell.contains("<Image "), "{story}");
assert!(cell.contains("ClipContentToCell=\"true\""), "{story}");
}
/// Non-finite numbers in colour values, page bounds and item transforms (NaN, infinities and
/// values like 1e300 that overflow `f32`) never reach the model: JSON would write them as null.
#[test]
fn non_finite_number_lists_never_reach_the_model() {
let graphic = GRAPHIC.replace(r#"ColorValue="255 0 0""#, r#"ColorValue="NaN 1e300 inf""#);
let spread = SPREAD
.replace(r#"Self="p2" AppliedMaster="m1" GeometricBounds="0 0 700 500""#, r#"Self="p2" AppliedMaster="m1" GeometricBounds="0 0 NaN 500""#)
.replace(r#"ItemTransform="1 0 0 1 50 -300""#, r#"ItemTransform="1 0 0 1 inf -300""#);
let bytes = zip_files(&[
("designmap.xml", DESIGNMAP),
("Resources/Graphic.xml", &graphic),
("Resources/Styles.xml", STYLES),
("Resources/Preferences.xml", PREFS),
("MasterSpreads/MasterSpread_m1.xml", MASTER),
("Spreads/Spread_sp1.xml", &spread),
("Stories/Story_s1.xml", STORY),
]);
let doc = import_idml(&bytes).unwrap();
for s in &doc.swatches {
if let SwatchValue::Color { color, .. } | SwatchValue::Paper { color } = &s.value {
let parts: Vec<f32> = 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::<Vec<_>>()
);
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);
}
}
+1 -1
View File
@@ -118,7 +118,7 @@ fn select_font() -> usvg::FontSelectionFn<'static> {
return Some(id);
}
}
// A family (or generic family) that matches: usvg's own selection.
// A family (or generic family) that matches: the query below.
_ => break,
}
}
+1
View File
@@ -0,0 +1 @@
mod rgba_conversion_contract;
@@ -3,8 +3,6 @@
//! goes inside the write root. Paths that leave a root (`..`, another folder, a sibling with the
//! same prefix, a link) are refused, nothing from a refused file reaches the document, and nothing
//! is written outside.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::{Path, PathBuf};
+4
View File
@@ -0,0 +1,4 @@
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod automation_roots;
+84 -3
View File
@@ -1629,6 +1629,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 +1683,10 @@ fn lossless(data: &Arc<Vec<u8>>, fmt: Option<image::ImageFormat>, 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 +1741,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<Vec<u8>>,
/// Only when some pixel is not opaque.
alpha: Option<Arc<Vec<u8>>>,
size: (u32, u32),
icc: Option<Arc<Vec<u8>>>,
}
impl GreyImage {
/// `None` when `data` isn't grey or can't be decoded.
fn new(data: &[u8]) -> Option<Self> {
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 +1842,18 @@ fn recompress(data: &[u8], mode: RasterMode) -> Option<Image> {
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)]
+2 -2
View File
@@ -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<usize> = kids_str.split_whitespace().filter_map(|t| t.parse().ok()).collect();
let kids: Vec<usize> = nums.chunks(2).map(|c| c[0]).collect();
+126 -4
View File
@@ -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<i64>) -> Option<Vec<u8>>
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<String> {
issues.push("Type 3 fonts aren't allowed".into());
}
issues.extend(device_rgb(pdf));
issues.extend(fonts::unembedded_fonts(pdf));
issues
}
@@ -480,7 +483,7 @@ mod tests {
use crate::{ExportReport, PdfOptions, Standard, check_pdfx4, export_pdf_with_report};
fn place(d: &mut Document, name: &str, data: Vec<u8>, size: (f64, f64), at: (f64, f64)) {
pub(super) fn place(d: &mut Document, name: &str, data: Vec<u8>, 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 +582,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 +648,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<u8> {
tiny_pdf_with(entries, content, "<</N 3/Length 0>>\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<u8> {
let objects = [
"<</Type/Catalog/Pages 2 0 R/OutputIntents[<</Type/OutputIntent/S/GTS_PDFX/DestOutputProfile 4 0 R>>]>>".to_string(),
"<</Type/Pages/Kids[3 0 R]/Count 1>>".to_string(),
format!("<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]{entries}/Contents 5 0 R>>"),
"<</N 4/Length 0>>\nstream\n\nendstream".to_string(),
format!("<</Length {}>>\nstream\n{content}\nendstream", content.len()),
"<</N 3/Length 0>>\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 +676,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 = ["<</Type/Catalog/Pages 2 0 R/Metadata 3 0 R>>", "<</Type/Pages/Kids[]/Count 0>>", "<</Type/Metadata>>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<</Size 4/Root 1 0 R>>\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 +702,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<String> {
check_pdfx4(&tiny_pdf(entries, content)).into_iter().filter(|i| i.contains("font")).collect()
};
let helvetica = "/Resources<</Font<</F1<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>>>>>";
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<</Font<</F1<</Type/Font/Subtype/TrueType/BaseFont/ABCDEF+Arial/FontDescriptor<</Type/FontDescriptor/FontName/ABCDEF+Arial/FontFile2 6 0 R>>>>>>>>";
assert!(fonts(embedded, "BT /F1 12 Tf (x) Tj ET").is_empty());
let cid = "/Resources<</Font<</F1<</Type/Font/Subtype/Type0/BaseFont/KozMin/Encoding/Identity-H/DescendantFonts[<</Type/Font/Subtype/CIDFontType0/BaseFont/KozMin/FontDescriptor<</Type/FontDescriptor/FontName/KozMin>>>>]>>>>>>";
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!(
"<</Type/XObject/Subtype/Form/BBox[0 0 100 100]/Resources<</Font<</T<</Type/Font/Subtype/Type1/BaseFont/Times-Roman>>>>>>/Length {}>>\nstream\n{draw}\nendstream",
draw.len()
);
let pdf = tiny_pdf_with("/Resources<</XObject<</X1 6 0 R>>>>", "/X1 Do", &form);
let issues: Vec<String> = 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<</Font<</F1<</Type/Font/Subtype/Type1/BaseFont/Helvetica/Encoding/WinAnsiEncoding>>>>>>",
"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<</Font<</F1<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>>>>>";
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<</Font<</F1<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>>>/XObject<</X1 6 0 R>>>>";
let draw = "BT (inherited font) Tj /F1 10 Tf 3 Tr ET";
let form = format!("<</Type/XObject/Subtype/Form/BBox[0 0 100 100]/Length {}>>\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!(
"<</Type/XObject/Subtype/Form/BBox[0 0 100 100]/Resources<</Font<</F1<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>>>/XObject<</X1 6 0 R>>>>/Length {}>>\nstream\n{draw}\nendstream",
draw.len()
);
let pdf = tiny_pdf_with("/Resources<</XObject<</X1 6 0 R>>>>", "/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<</Font<</F1<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>>>/XObject<</X1 6 0 R>>>>";
let draw = "BT /F2 10 Tf 3 Tr (hidden) Tj ET";
let form = format!(
"<</Type/XObject/Subtype/Form/BBox[0 0 100 100]/Resources<</Font<</F2<</Type/Font/Subtype/Type1/BaseFont/Times-Roman>>>>>>/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<String> = 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() {
+172
View File
@@ -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<String> {
let Ok(file) = hayro_syntax::Pdf::new(pdf.to_vec()) else { return Vec::new() };
let mut found: BTreeMap<String, Vec<usize>> = 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<String> = found
.into_iter()
.map(|(name, pages)| {
let list: Vec<String> = pages.iter().take(10).map(|p| p.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::<Name<'_>>(b"Subtype").as_deref() {
Some(b"Type3") => return true,
Some(b"Type0") => {
font.get::<Array<'_>>(b"DescendantFonts").and_then(|a| a.iter::<Dict<'_>>().next()).and_then(|d| d.get::<Dict<'_>>(b"FontDescriptor"))
}
_ => font.get::<Dict<'_>>(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<String>,
active: HashSet<ObjectIdentifier>,
path: Vec<ObjectIdentifier>,
completed: HashMap<FormContext<'a>, BTreeSet<String>>,
font: Option<Dict<'a>>,
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<ObjectIdentifier>,
}
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::<Name<'_>>(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::<Name<'_>>(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::<Dict<'_>>(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;
}
}
}
_ => {}
}
}
}
}
+153
View File
@@ -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<u8> {
let mut objects = vec![
"<</Type/Catalog/Pages 2 0 R>>".to_string(),
"<</Type/Pages/Kids[3 0 R]/Count 1>>".to_string(),
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Resources<</XObject<</Next 5 0 R>>>>/Contents 4 0 R>>".to_string(),
stream("", "/Next Do"),
];
for level in 0..levels {
let resources = if level + 1 == levels {
"/Font<</F1<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>>>".to_string()
} else {
format!("/XObject<</Next {} 0 R>>", 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<u8> {
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<</Size {}/Root 1 0 R>>\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::<Vec<_>>(), ["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<</Font<</F1<</Type/Font/Subtype/Type1/BaseFont/{font}>>>>>>"),
"/Shared Do",
)
};
let bytes = pdf_objects(&[
"<</Type/Catalog/Pages 2 0 R>>".into(),
"<</Type/Pages/Kids[3 0 R]/Count 1>>".into(),
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Resources<</XObject<</A 5 0 R/B 6 0 R/Shared 7 0 R>>>>/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 = "<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Resources<</XObject<</Shared 6 0 R>>>>/Contents 5 0 R>>";
let bytes = pdf_objects(&[
"<</Type/Catalog/Pages 2 0 R>>".into(),
"<</Type/Pages/Kids[3 0 R 4 0 R]/Count 2>>".into(),
page.into(),
page.into(),
stream("", "/Shared Do"),
stream(
"/Type/XObject/Subtype/Form/BBox[0 0 100 100]/Resources<</Font<</F1<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>>>>>",
"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<</Font<</F1<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>/F2<</Type/Font/Subtype/Type1/BaseFont/Times-Roman>>>>/XObject<</X1 6 0 R>>>>";
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<</F1<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>>>";
let bytes = pdf_objects(&[
"<</Type/Catalog/Pages 2 0 R>>".to_string(),
"<</Type/Pages/Kids[3 0 R]/Count 1>>".to_string(),
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Resources<</XObject<</A 5 0 R>>>>/Contents 4 0 R>>".to_string(),
stream("", "/A Do"),
stream(&format!("/Type/XObject/Subtype/Form/BBox[0 0 100 100]/Resources<<{font}/XObject<</B 6 0 R>>>>"), "BT /F1 12 Tf (a) Tj ET /B Do"),
stream("/Type/XObject/Subtype/Form/BBox[0 0 100 100]/Resources<</XObject<</A 5 0 R/B 6 0 R>>>>", "/A Do /B Do"),
]);
let warnings = unembedded_fonts(&bytes);
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(warnings[0].contains("Helvetica isn't embedded"), "{warnings:?}");
}
+20
View File
@@ -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<u8> = 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::<tiff::encoder::colortype::Gray8>(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<u8> {
let img =
+37 -1
View File
@@ -160,7 +160,7 @@ fn cluster_chars(members: &[PlacedGlyph], cluster: &str) -> Option<Vec<(usize, c
fn text_rules(cs: &ComposedStory, ft: &FrameText, underline: bool) -> 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 {
@@ -638,3 +638,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");
}
}
+34 -1
View File
@@ -43,6 +43,14 @@ pub(crate) fn int_after(s: &str, key: &str) -> Option<usize> {
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<std::ops::Range<usize>> {
// The latest copy (incremental updates re-state objects further on).
@@ -132,7 +140,7 @@ pub fn add_page_entries(pdf: &[u8], extra: &[Option<String>]) -> Option<Vec<u8>>
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<usize> = kids_str.split_whitespace().filter_map(|t| t.parse().ok()).collect();
let kids: Vec<usize> = 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<Vec<u8>> {
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 = ["<</Type/Catalog/Pages 2 0 R>>", "<</Type/Pages/Kids ] [3 0 R/Count 1>>"];
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<</Size 3/Root 1 0 R>>\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};
-5
View File
@@ -1,5 +0,0 @@
//! Run public contracts with the production content-cache hasher.
#[path = "support/image_content_cache_contract.rs"]
mod contracts;
#[path = "support/image_fixtures.rs"]
mod image_fixtures;
@@ -42,7 +42,7 @@ fn mixed_gradient_export_survives_in_subprocess() {
const CHILD: &str = "DESIGNCRAFT_TEST_MIXED_GRADIENT_CHILD";
if std::env::var_os(CHILD).is_none() {
let out = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "mixed_gradient_export_survives_in_subprocess", "--nocapture"])
.args(["--exact", "gradient_color_spaces::mixed_gradient_export_survives_in_subprocess", "--nocapture"])
.env(CHILD, "1")
.output()
.unwrap();
@@ -0,0 +1,3 @@
//! Run public contracts with the production content-cache hasher.
#[path = "../support/image_content_cache_contract.rs"]
mod contracts;
+6
View File
@@ -0,0 +1,6 @@
#[path = "../support/image_fixtures.rs"]
mod image_fixtures;
mod gradient_color_spaces;
mod image_content_cache;
mod page_entry_search_contract;
+47 -1
View File
@@ -162,7 +162,7 @@ fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line, vertical: bool) -> Line
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];
@@ -185,6 +185,10 @@ fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line, vertical: bool) -> Line
}
}
}
// 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 +729,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);
}
}
File renamed without changes.
@@ -3,7 +3,7 @@
//!
//! Goldens live in `tests/golden/*.png` and are compared with a tolerance (antialiasing and blur
//! rounding may differ by a few levels between SIMD levels). Regenerate them after an intended
//! change with `DESIGNCRAFT_BLESS=1 cargo test -p designcraft-render --test effects`.
//! change with `DESIGNCRAFT_BLESS=1 cargo test -p designcraft-render --test it effects::`.
use designcraft_compose::Cache;
use designcraft_doc::build::NewDocument;
File renamed without changes.
File renamed without changes.
+12
View File
@@ -0,0 +1,12 @@
mod blend_over_paper;
mod colorize;
mod effects;
mod glyph_retention;
mod gradient_inks;
mod gradients;
mod pdf_geometry;
mod perf;
mod placed_pdf;
mod stroke_alignment;
mod text_frame_bottom;
mod text_stroke_behind;
File renamed without changes.
@@ -1,6 +1,6 @@
//! Renderer caching/culling behaviour, and a render-time regression test (`#[ignore]`: wall-clock
//! timings are only meaningful on an idle machine — run with
//! `cargo test --release -p designcraft-render --test perf -- --ignored`).
//! `cargo test --release -p designcraft-render --test it perf:: -- --ignored`).
use std::time::Instant;
File renamed without changes.
+28 -2
View File
@@ -115,6 +115,12 @@ pub(crate) fn parse(xml: &str) -> Result<El, ImportError> {
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::<f64>().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::<f64>().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("<a><t><![CDATA[x < y]]></t></a>").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#"<w:document {W}><w:body>
<w:p><w:r><w:rPr><w:sz w:val="0"/></w:rPr><w:t>a</w:t></w:r><w:r><w:rPr><w:sz w:val="99999999"/></w:rPr><w:t>b</w:t></w:r></w:p>
</w:body></w:document>"#
);
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.
Loaded 100 of 140 files, more files were not shown because too many files have changed in this diff. Show more