egui037: Port DesignCraft to egui 0.37

Pin the final egui, winit and shared UI revisions and reconcile current main.
Preserve document-epoch input and screenshot boundaries, host action ordering,
and Windows path regression coverage. Add click/End batch characterization.

Validation: all nine Linux CI stages pass (3230 tests, zero failures); Windows
CI and bounded native editing workflows pass on the same production source.
This commit is contained in:
skylartaylor
2026-10-25 00:52:21 +02:00
186 changed files with 17228 additions and 3025 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
# Same craft-fonts commit as release.yml.
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
timeout-minutes: 90
packages: >-
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
+1 -1
View File
@@ -27,4 +27,4 @@ jobs:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
+46 -2
View File
@@ -11,20 +11,64 @@ on:
default: ""
permissions:
contents: write
contents: read
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
git-sources:
name: Git dependencies come from storytold
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Git dependencies come from storytold
run: |
set -euo pipefail
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
# Source replacement, patches and path overrides in cargo config never show up
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
# Cargo still reads the legacy .cargo/config.
python3 - <<'PY'
import os, sys, tomllib
for path in (".cargo/config", ".cargo/config.toml"):
if not os.path.exists(path):
continue
config = tomllib.load(open(path, "rb"))
replaced = [name for name, source in config.get("source", {}).items()
if isinstance(source, dict) and "replace-with" in source]
if replaced or "patch" in config or "paths" in config:
print(f"{path} replaces, patches or overrides a source")
sys.exit(1)
PY
# Fails when Cargo.lock is out of date with the manifests.
cargo metadata --locked --format-version 1 > /dev/null
python3 - <<'PY'
import sys, tomllib
lock = tomllib.load(open("Cargo.lock", "rb"))
bad = sorted({p["source"] for p in lock.get("package", [])
if p.get("source", "").startswith("git+")
and not p["source"].startswith("git+https://github.com/storytold/")})
if bad:
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
sys.exit(1)
PY
release:
needs: git-sources
permissions:
contents: write
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
app-name: designcraft
app-title: DesignCraft
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
web-craft-fonts: true
version: ${{ inputs.version }}
secrets:
+2
View File
@@ -8,6 +8,8 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| Asset | Author | Source | Licence | Notes |
|---|---|---|---|---|
| `epaint_default_fonts/fonts/egui-icons.ttf` (egui dependency) | John Slegers (emoji-icon-font) | https://github.com/storytold/egui/blob/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts/egui-icons.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full text in NOTICE) | Small special-icon subset embedded by default_fonts |
| `epaint_default_fonts/fonts/emoji-icon-font.ttf` (egui dependency) | John Slegers | https://github.com/storytold/egui/blob/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full text in NOTICE) | Parent face embedded by monochrome_emoji_fonts |
| `crates/ui-egui/src/i18n/uk.rs` (332 common phrases) | VectorCraft contributors | https://github.com/storytold/vectorcraft/blob/01f329aa99fa0c9517311c7b6cf7233ffc207742/crates/ui-egui/src/i18n/uk.tsv | MIT OR Apache-2.0 | Adapted for DesignCraft context; remaining Ukrainian phrases are original or credited below |
| `crates/ui-egui/src/i18n/uk.rs` (68 common phrases) | PhotoCraft contributors | https://github.com/storytold/photocraft/blob/ed057bdc6fd89a858c5e0ea4eab158a2a7052dde/crates/ui-egui/src/i18n/uk.tsv | MIT OR Apache-2.0 | Adapted interface vocabulary |
| `crates/ui-egui/src/i18n/uk.rs` (37 common phrases) | WordCraft contributors | https://github.com/storytold/wordcraft/blob/7fb7b013591e2593fbd038f90bbc535be318e5a3/crates/ui-egui/src/i18n/uk.tsv | MIT OR Apache-2.0 | Adapted interface vocabulary |
Generated
+1598 -146
View File
File diff suppressed because it is too large Load Diff
+30 -13
View File
@@ -12,7 +12,7 @@ repository = "https://github.com/storytold/designcraft"
[workspace.dependencies]
# Shared reader and writer; publish the immutable revision before this consumer.
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.1.0", default-features = false }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.1.0", default-features = false }
# internal
designcraft-geom = { path = "crates/geom" }
designcraft-color = { path = "crates/color" }
@@ -79,18 +79,20 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l
# Atomic file replacement for saves and exports (craft-libs).
craft-atomic = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e9546e191dd62f2bc1d61eca45dbdc2f1b3c1d8e" }
# File › Print through the system print spooler (craft-libs).
craft-print = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" }
craft-print = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f" }
# ICC colour management: working spaces, transforms, black point compensation (craft-libs).
craft-cms = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e9546e191dd62f2bc1d61eca45dbdc2f1b3c1d8e" }
# ui
# Shared frontend components; keep this dependency above the engine boundary.
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
egui = "0.36"
egui_extras = { version = "0.36", default-features = false, features = ["svg"] }
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f", features = ["serde"] }
egui = "0.37"
egui_extras = { version = "0.37", default-features = false, features = ["svg"] }
eframe = { version = "0.37", default-features = false }
egui_kittest = "0.37"
# Monotonic clock on native and web (`Instant`); screenshot settlement must not use wall time.
web-time = "1"
rfd = "0.17"
arboard = { version = "3.6", default-features = false }
@@ -122,9 +124,24 @@ debug = 0
# Opt-in cancelable macOS termination on the pinned winit 0.30 line.
[patch.crates-io]
krilla = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.8.2" }
winit = { git = "https://github.com/storytold/winit.git", rev = "3d51077fd1a966f84df9ef287839dc84fd5b4ca7" }
hayro = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.7.1" }
hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.7.0" }
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.7.2" }
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.3.0" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.8.2" }
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
hayro = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.7.1" }
hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.7.0" }
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.7.2" }
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.3.0" }
hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.1.0" }
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+24 -5
View File
@@ -92,14 +92,15 @@ No Adobe icons, images or artwork are included.
PDF backend source
The patched hayro 0.7.1, hayro-interpret 0.7.0, hayro-syntax 0.7.2 and hayro-jbig2 0.3.0
are fetched from https://github.com/storytold/craft-libs at revision
89dcbb3060377a26c99a6d80caa16084366704c4. The four source snapshots were transferred
The patched hayro 0.7.1, hayro-interpret 0.7.0, hayro-syntax 0.7.2, hayro-jbig2 0.3.0 and
hayro-cmap 0.1.0 are fetched from https://github.com/storytold/craft-libs at revision
9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef. The first four source snapshots were transferred
from https://github.com/storytold/pdfcraft at revision
7437bde378f43856eada40af25b983812e615e8a, then patched in craft-libs. Their upstream project is
7437bde378f43856eada40af25b983812e615e8a and hayro-cmap at revision
a2ec27b4775f53c40cad3456936d9406611e3a8d, then patched in craft-libs. Their upstream project is
https://github.com/LaurenzV/hayro, licensed MIT OR Apache-2.0. The snapshot retains each
crate's upstream licence files. The patch ledger is at
https://github.com/storytold/craft-libs/blob/89dcbb3060377a26c99a6d80caa16084366704c4/vendor/README.craft-reader.md.
https://github.com/storytold/craft-libs/blob/9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef/vendor/README.craft-reader.md.
Per-crate vendor/SOURCE.json files record transferred and local source hashes; asset
origins and unchanged per-file hashes are recorded separately in ASSETS.md.
@@ -113,3 +114,21 @@ Assets already bundled through Hayro's default features retain their licences:
non-visual character-mapping data; see licenses/pdf-backend/hayro-cmap/LICENSE.txt.
Adobe and contributor names do not endorse or promote this product.
Per-file hashes and sources are recorded in ASSETS.md.
Bundled egui font icons
The pinned egui dependency includes egui-icons.ttf, a small subset of John Slegers'
emoji-icon-font, through default_fonts. The parent emoji-icon-font.ttf face is
included through monochrome_emoji_fonts. Their upstream project is
https://github.com/jslegers/emoji-icon-font; both use the same exact copyright
and permission notice below.
MIT License
Copyright (c) 2014 John Slegers
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+31 -6
View File
@@ -11,6 +11,14 @@
DesignCraft aims at full Adobe InDesign parity — and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web.
- **2026-10-23** · PDF layer choices now validate before editing all targets; rejected rewrites
leave document/undo state intact. Null trailer entries remain unencrypted, and unsupported
all-content crops retain the whole page rather than silently clipping hidden layers.
- **2026-10-23** · PDF layer visibility variants retain catalog/configuration entries and full
object references; bounded incremental updates replace the existing catalog. Synthetic
regression tests cover metadata, output-intent references, nonzero generations and
forward Prev links; repeated cross-reference offsets are rejected explicitly. No readiness percentage change.
## Status (2026-10-01)
**Working today**
@@ -350,12 +358,30 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-23** · `designcraft-cli mcp` takes `--automation-read-root` and `--automation-write-root` more than once, as the suite's other apps do (#1552): a path inside any root of its kind passes, and the roots' common parent, `..` or a link out of them and the other kind's roots stay refused. No readiness percentage change.
- **2026-10-23** · Layout › Go Back and Go Forward (Cmd+Page Up / Cmd+Page Down, Ctrl on Windows and Linux) retrace the jumps between pages (Go to Page, the page and spread commands, the Pages panel) as InDesign's do, returning to where the view was on each page at the current zoom; a new jump after going back drops the pages ahead, scrolling adds no entry, and pages deleted since are skipped. No readiness percentage change.
- **2026-10-24** · Type › Notes › Previous Note and Next Note (`note.previous`, `note.next`): as in InDesign, they move the insertion point from note to note in the story, putting the caret after the note's anchor. Each item is unavailable when no note lies in its direction in the story, or when the insertion point is in a table cell or a footnote. No readiness percentage change.
- **2026-10-24** · Type › Notes › Convert to Note (`note.convertFromText {author?}`): as in InDesign, the selected text leaves the story and becomes the text of a new note, whose anchor takes its place; Convert Note to Text puts it back. DesignCraft refuses a selection in a table cell, or one that holds a note, footnote, endnote, anchored object, index marker, text anchor, cross-reference or table. No readiness percentage change.
- **2026-10-23** · Effects dialog: with Use Global Light on, the Angle field (and the Bevel and Emboss one) stays editable and shows the document's global light angle; a change there sets the global light for every effect that uses it, applied with the effects in one undo step (`object.effects` takes `globalLight`). Before, the field was greyed out and the angle couldn't be changed in the dialog. No readiness percentage change.
- **2026-10-24** · Story panel (Window › Type & Tables › Story): Optical Margin Alignment and its size for the stories of the text selection or the selected text frames, disabled without either; `type.selectionAttrs` reports them as `storyOptions`, null where the stories differ. The Paragraph panel menu has Ignore Optical Margin in place of the per-paragraph Optical Margin Alignment switch, which stays in `type.para`, and Paragraph Style Options › Indents and Spacing has Ignore Optical Margin (#947). No readiness percentage change.
- **2026-10-24** · Optical Margin Alignment per story with a size (`story.options {opticalMargin, opticalMarginSize}`, off by default): when it is on, the story's paragraphs hang punctuation and letter edges by amounts set for that type size, except paragraphs with Ignore Optical Margin (`ignoreOpticalMargin` in `type.para` and paragraph styles). Table cells and footnotes don't take the story setting yet. When it is off, the per-paragraph switch still hangs at the text's own size. IDML imports and exports both (`StoryPreference/@OpticalMarginAlignment`, `@OpticalMarginSize`, `IgnoreEdgeAlignment`) (#947). No readiness percentage change.
- **2026-10-24** · Text frames: a custom baseline grid's Relative To (Text Frame Options › Baseline Options, `object.textFrameOptions {baselineGrid: {relativeTo}}`, IDML `BaselineFrameGridRelativeOption`) measures the grid's start from the top of the page, the top margin, the top of the frame or its top inset. Text Frame Options had no custom baseline grid controls, imports dropped the setting and every frame grid started from the page top, so grid-aligned text in a frame measured from its own top sat on the wrong lines. No readiness percentage change.
- **2026-10-24** · Paragraph Style Options › Indents and Spacing has an Align to Grid menu: None, All Lines or First Line Only. It shows the style's current setting. A First Line Only style could be set only in the CJK Grid Settings section, which shows only with CJK features on, or through commands. No readiness percentage change.
- **2026-10-24** · Anchored text frames, such as marginal notes, compose where their host story places them: they align to the baseline grid at that position, and page-number markers and spine-relative alignment inside them read the host's page. They composed against a grid measured from their stored position and on no page, so a note set to Only Align First Line to Grid could sit a fraction of a line off the body text's grid line. No readiness percentage change.
- **2026-10-24** · New documents start with Hyphenate Capitalized Words, Hyphenate Last Word and Hyphenate Across Column off in [Basic Paragraph], as in InDesign. Opened and imported files keep the values they carry. No readiness percentage change.
- **2026-10-24** · The baseline grid's Relative To setting applies: with Top Margin, the grid starts at each page's own top margin in text composition, on the canvas and for snapping. It started at the page top, so grid-aligned text sat on the wrong lines and a page could fit one line fewer. No readiness percentage change.
- **2026-10-24** · Custom-positioned anchored objects honour Relative to Spine (`anchored.options {spineRelative}`, IDML `SpineRelative`): the settings describe a left-hand page and mirror on a right-hand page, so marginal notes stay in the outside margin on both pages of a spread. On right-hand pages they took the left-hand placement and landed in the inside margin, across the spine or over the body text. No readiness percentage change.
- **2026-10-24** · Text frames: a custom baseline grid (Text Frame Options › Baseline Options) from an IDML file sets lines from its start by its increment. The composer read the two values in the other order, so lines landed on the wrong grid. No readiness percentage change.
- **2026-10-23** · Save and export dialogs (Save As, Save a Copy, Export PDF and the other exports, Package, libraries and books) start in the active document's folder once it has been saved, and for a document never saved in the folder of the last file saved or exported, remembered across sessions (#1653). A folder deleted since falls back to the system's choice. No readiness percentage change.
- **2026-10-22** · Autoflow (Shift-click with the loaded text cursor, autoflow on Place, `text.thread {autoflow}`) fills the pages that follow before it adds any, as InDesign does: one frame per column on each following page, whatever else the page holds, then new pages at the end of the document. It inserted pages right after the story's last frame, in front of the document's own pages. Frames it set past the end of the text go again; a page the document had stays, and a page autoflow added only for such a frame goes with it. Smart Text Reflow of the primary text frame is unchanged. No readiness percentage change.
- **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change.
- **2026-10-23** · Translations: a missing string shows in English and fails no test; `cargo xtask i18n-report`, run by `cargo xtask ci`, prints the missing strings per language. Tests keep duplicate-key and placeholder checks and the language mechanics (lookup, context captions, plural captions, right-to-left). No readiness percentage change.
- **2026-10-22** · Hyphenation Settings, as in InDesign: the Paragraph panel menu's Hyphenation… (`app.hyphenationDialog`) sets the selected paragraphs' Hyphenate, Words with at Least, After First, Before Last, Hyphen Limit, Hyphenation Zone, the Better Spacing ↔ Fewer Hyphens slider (InDesign's 0–10 steps), Hyphenate Capitalized Words, Last Word and Across Column, with Preview, as one undo step; Paragraph Style Options › Hyphenation has the same form. The panel menu lists its items in InDesign's order. Replicated from a hyphenation tutorial: the same text in Minion Pro breaks into the same lines as InDesign's with hyphenation on, off, and at Fewer Hyphens. No readiness percentage change.
- **2026-10-22** · Hyphenate Across Column (Hyphenation Settings; IDML HyphenateAcrossColumns) is applied: with it off, a paragraph whose line ending in an automatic hyphen would end a column or frame is re-composed without that hyphenation point, so the word moves on whole, as in InDesign. Opened and imported files keep their own setting, so their layouts don't change. No readiness percentage change.
- **2026-10-22** · The Paragraph panel is laid out as InDesign's: the nine alignment buttons in groups; indent, spacing and drop cap fields by their icons in two columns, with Space Between Paragraphs Using Same Style (Ignore or a space); Shading and Border with the Paragraph Borders and Shading button and colour; Hyphenate with Do Not Align / Align to Baseline Grid. Its panel menu follows InDesign's order: Paragraph and Single-line Composer, Only Align First Line to Grid, Balance Ragged Lines, Optical Margin Alignment, Justification…, Keep Options…, Span Columns…, Hyphenation…, Drop Caps and Nested Styles…, GREP Styles… (these three now open for the selected paragraphs), Paragraph Rules…, Paragraph Borders and Shading…, Bullets and Numbering…, Restart/Continue Numbering, Define Lists… and the paragraph direction. Convert Bullets and Numbering to Text is listed but not available yet. No readiness percentage change.
- **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change.
- **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change.
- **2026-10-23** · Type › Text Variables › Convert Variable to Text (`variables.convertToText`): the variable instances in the selected text, in a story, a table cell or a footnote, become plain text of the value each shows on its own page (a running header on page 3 becomes page 3's heading). `variables.convertAllToText {name}` does the same for every instance of one variable in the document, as the Text Variables dialog's Convert to Text button. The variable stays defined; one undo step. 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.
@@ -371,6 +397,11 @@ Newest first.
- **2026-10-16** · Lock Guides and Lock Column Guides are commands in View ▸ Grids & Guides (`view.lockGuides`, ⌥⌘;, and `view.lockColumnGuides`), so the control channel and MCP reach them. Lock Column Guides is an undo step, is saved with the document, on in new ones, and read from and written to IDML's `ColumnGuideLocked`, which export always wrote as locked (#1096). No readiness percentage change.
- **2026-10-16** · IDML: a page keeps column guides that were dragged off centre (InDesign's Custom columns). They were divided evenly on import, so the gutter sprang back to the middle; the imported positions now place the columns, with the canvas guides, snapping and text placement following them, and they are written back on export. Layout › Margins and Columns re-divides them evenly when the count or gutter changes, as InDesign's does. No readiness percentage change.
- **2026-10-16** · IDML: custom column positions are checked before they are used (#1096). A set must give two edges per column, ascending from the left margin to the right one; any other (a wrong count, unsorted or non-numeric values, a list longer than 216 columns allow, or positions left behind by a margin or page-size change) is the even split, in the canvas, on import and on export, instead of columns drawn outside the margins. No readiness percentage change.
- **2026-10-21** · Properties › Character and Paragraph: ••• (More Options) expands the section in place, as InDesign's: Character adds vertical and horizontal scale, baseline shift, skew, language and OpenType, and All Caps, Small Caps, Superscript, Subscript, Underline and Strikethrough; Paragraph adds the left, right, first line and last line indents, space before and after, the drop cap's lines and characters, Align to Baseline Grid and Hyphenate. It used to open the Character or Paragraph panel; the language field moved under ••• as in InDesign. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change.
- **2026-10-21** · The text Control panel has InDesign's two modes: A (Character Formatting Controls: font and style, size and leading, All Caps/Superscript/Underline and Small Caps/Subscript/Strikethrough, kerning and tracking, vertical and horizontal scale, baseline shift and skew, character style and language) and ¶ (Paragraph Formatting Controls: the nine alignments, left/first line and right/last line indents, space before and after, the drop cap's lines and characters, paragraph style, Hyphenate and Align to Baseline Grid). It used to show a fixed mix of both. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change.
- **2026-10-20** · With the Type tool, the Control panel's font size, leading and tracking fields have a spinner and a list of common values, as in InDesign (auto leading in parentheses). No readiness percentage change.
- **2026-10-21** · The ¶ (Paragraph Formatting Controls) mode of the text Control panel has InDesign's Bulleted List and Numbered List buttons (with the default hanging indent) and Shading and Border: a checkbox, a button opening Paragraph Borders and Shading on that tab, and the colour. Found in the Learn Skills Daily course, chapter 21. No readiness percentage change.
- **2026-10-21** · The Bulleted and Numbered List buttons (Properties, and Type › Bulleted & Numbered Lists › Apply Bullets / Apply Numbers) work as InDesign's: a paragraph with no indents gets the default hanging indent (0.25 in, −0.25 in), so the text lines up after the bullet instead of at the next default tab stop, and pressing the button again removes the list and that indent (`list.bullets`, `list.numbers`). Found in the Learn Skills Daily course, chapter 21. No readiness percentage change.
- **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change.
- **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change.
- **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change.
@@ -378,14 +409,8 @@ Newest first.
- **2026-10-17** · Object strokes reject malformed types and unknown type fields before changing the document; command help lists every stroke kind and its required fields. No readiness percentage change.
- **2026-10-17** · Docs: documented a persistent source CLI install with Cargo, MCP-client PATH setup and bare-name server registration in `docs/mcp.md`. No readiness percentage change.
- **2026-10-16** · Selection: drag a column guide to move its gutter, as in InDesign (#1096). Both guides of the gutter move together and keep its width, stop at the neighbouring guides and the margins, and snap to the document grid, guides and objects, or with Shift to the ruler's tick marks; Escape cancels and the drag is one undo step. Locked column guides (the default) are not picked. A document page takes the result as its own columns; on a parent page it passes to the pages that follow it. `layout.moveColumnGuide` does the same over the control channel and MCP. No readiness percentage change.
- **2026-10-16** · Layout: Margins and Columns keeps dragged column guides unless the column count or gutter changes (#1096). The dialog sends every field on OK, so a margin-only change re-divided the columns evenly. Margin changes keep the positions; the columns follow them only while they fit the margin box. No readiness percentage change.
- **2026-10-16** · Layout: autoflow, inserted pages and new parents follow dragged column guides (#1096). Autoflow sets one frame per dragged column and counts the pages it adds from them; Insert Pages and New Parent keep the custom positions with the column count and gutter; `document.inspect` reports each page's gutter, column rects and whether its columns are custom. No readiness percentage change.
- **2026-10-16** · Layout: autoflow, inserted pages and new parents follow dragged column guides (#1096). Autoflow sets one frame per dragged column and counts the pages it adds from them; Insert Pages and New Parent keep the custom positions with the column count and gutter; a page that moves to the other side of the spine (inserted, deleted or moved pages, Duplicate Spread, a new parent's other page) has them mirrored across its margin box, as InDesign does; `document.inspect` reports each page's gutter, column rects and whether its columns are custom. No readiness percentage change.
- **2026-10-16** · IDML: custom column positions are checked before they are used (#1096). A set must give two edges per column, ascending from the left margin to the right one; any other (a wrong count, unsorted or non-numeric values, a list longer than 216 columns allow, or positions left behind by a margin or page-size change) is the even split, in the canvas, on import and on export, instead of columns drawn outside the margins. No readiness percentage change.
- **2026-10-16** · IDML: a page keeps column guides that were dragged off centre (InDesign's Custom columns). They were divided evenly on import, so the gutter sprang back to the middle; the imported positions now place the columns, with the canvas guides, snapping and text placement following them, and they are written back on export. Layout › Margins and Columns re-divides them evenly when the count or gutter changes, as InDesign's does. No readiness percentage change.
- **2026-10-16** · Lock Guides and Lock Column Guides are commands in View ▸ Grids & Guides (`view.lockGuides`, ⌥⌘;, and `view.lockColumnGuides`), so the control channel and MCP reach them. Lock Column Guides is saved with the document, on in new ones, and read from and written to IDML's `ColumnGuideLocked`, which export always wrote as locked (#1096). No readiness percentage change.
- **2026-10-16** · Lock Guides and Lock Column Guides are commands in View ▸ Grids & Guides (`view.lockGuides`, ⌥⌘;, and `view.lockColumnGuides`), so the control channel and MCP reach them. Lock Column Guides is an undo step, is saved with the document, on in new ones, and read from and written to IDML's `ColumnGuideLocked`, which export always wrote as locked (#1096). No readiness percentage change.
- **2026-10-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change.
- **2026-10-16** · Greek text hyphenates. The patterns were never compiled in, so Greek set unhyphenated however narrow the column; `"Greek"`, `"Greek: Greece"`, `el` and `el-GR` all resolve now and ανθρωπος breaks αν-θρω-πος. They are the monotonic patterns (hyph-el-monoton, Dimitrios Filippou, under MIT), which is what polytonic text hyphenates by too. Five of InDesign's ~40 hyphenation languages. No readiness percentage change.
- **2026-10-16** · A swatch applied to a placed greyscale or bitmap image colours it, as in InDesign: IDML's `<Image FillColor>` is read and written, greyscale pixels become a tint of the swatch on opaque white and a bitmap's white lets the frame show through, on screen and in PDF (spot swatches print on their plate). Colour images keep their colours. Applying the swatch from the Swatches panel comes next. No readiness percentage change.
+25 -31
View File
@@ -3,30 +3,22 @@
use std::path::PathBuf;
#[derive(Default)]
pub struct AutomationArgs {
pub read_root: Option<PathBuf>,
pub write_root: Option<PathBuf>,
read_given: bool,
write_given: bool,
pub read_roots: Vec<PathBuf>,
pub write_roots: Vec<PathBuf>,
}
impl AutomationArgs {
pub fn new(read_root: Option<PathBuf>, write_root: Option<PathBuf>) -> Self {
Self { read_root, write_root, read_given: false, write_given: false }
}
/// Returns whether this argument is a root flag. A second occurrence of a flag is an error,
/// including when the two spellings (`--flag DIR`, `--flag=DIR`) are mixed.
/// Returns whether this argument is a root flag. Each flag may be given more than once (#1552),
/// in either spelling (`--flag DIR`, `--flag=DIR`): every use adds a folder, none replaces one.
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value)));
let (root, given) = match flag {
"--automation-read-root" => (&mut self.read_root, &mut self.read_given),
"--automation-write-root" => (&mut self.write_root, &mut self.write_given),
let roots = match flag {
"--automation-read-root" => &mut self.read_roots,
"--automation-write-root" => &mut self.write_roots,
_ => return Ok(false),
};
if *given {
return Err(format!("{flag} given twice"));
}
let value = match inline {
Some(value) => value.to_owned(),
None => {
@@ -40,8 +32,7 @@ impl AutomationArgs {
if value.is_empty() {
return Err(format!("{flag} needs a non-empty folder"));
}
*root = Some(value.into());
*given = true;
roots.push(value.into());
Ok(true)
}
}
@@ -51,40 +42,43 @@ mod tests {
use super::*;
#[test]
fn roots_are_taken_once_and_other_arguments_are_left() {
let mut roots = AutomationArgs::new(None, None);
fn roots_add_up_and_other_arguments_are_left() {
let mut roots = AutomationArgs::default();
let mut args = ["a folder".to_owned(), "doc.designcraft".to_owned()].into_iter();
assert!(roots.consume("--automation-read-root", &mut args).unwrap());
assert!(roots.consume("--automation-write-root=out", &mut args).unwrap());
assert!(!roots.consume("--sample", &mut args).unwrap());
assert_eq!(roots.read_root, Some("a folder".into()));
assert_eq!(roots.write_root, Some("out".into()));
assert_eq!(roots.read_roots, [PathBuf::from("a folder")]);
assert_eq!(roots.write_roots, [PathBuf::from("out")]);
assert_eq!(args.next().as_deref(), Some("doc.designcraft"));
assert!(roots.consume("--automation-read-root=other", &mut args).is_err());
assert_eq!(roots.read_root, Some("a folder".into()));
// #1552: a flag given again adds a folder, in either spelling, and keeps the first.
assert!(roots.consume("--automation-read-root=other", &mut args).unwrap());
assert!(roots.consume("--automation-write-root", &mut ["out2".to_owned()].into_iter()).unwrap());
assert_eq!(roots.read_roots, [PathBuf::from("a folder"), PathBuf::from("other")]);
assert_eq!(roots.write_roots, [PathBuf::from("out"), PathBuf::from("out2")]);
}
#[test]
fn invalid_operands_leave_roots_unchanged() {
for flag in ["--automation-read-root", "--automation-write-root"] {
for operand in [None, Some(""), Some("--version"), Some("--connect"), Some("-folder")] {
let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into()));
let mut roots = AutomationArgs { read_roots: vec!["read".into()], write_roots: vec!["write".into()] };
let mut args = operand.map(str::to_owned).into_iter();
assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}");
assert_eq!(roots.read_root, Some("read".into()));
assert_eq!(roots.write_root, Some("write".into()));
assert_eq!(roots.read_roots, [PathBuf::from("read")]);
assert_eq!(roots.write_roots, [PathBuf::from("write")]);
}
let mut roots = AutomationArgs::new(None, None);
let mut roots = AutomationArgs::default();
assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err());
}
}
#[test]
fn explicit_dash_folder_and_relative_dash_folder_are_valid() {
let mut roots = AutomationArgs::new(None, None);
let mut roots = AutomationArgs::default();
assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap());
assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap());
assert_eq!(roots.read_root, Some("-folder".into()));
assert_eq!(roots.write_root, Some("./-folder".into()));
assert_eq!(roots.read_roots, [PathBuf::from("-folder")]);
assert_eq!(roots.write_roots, [PathBuf::from("./-folder")]);
}
}
+42 -36
View File
@@ -10,7 +10,7 @@
//! # run a command script (crates/engine/src/script.rs): `$N.path` references
//! designcraft-cli app [--port PORT] COMMAND [JSON] # run a command in the running app (designcraft --control PORT)
//! designcraft-cli app [--port PORT] --method METHOD [JSON] # any control-channel method (ui.screenshot, ui.render, …)
//! designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR]
//! designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR]... [--automation-write-root DIR]...
//! # MCP server over stdio (docs/mcp.md); the roots confine the files it reads and writes
//! designcraft-cli perf [--pages N] [--frames N] [--chars N] [--images N] [--runs N] [--strict] # budgets on a synthetic stress document
//! designcraft-cli bench FILE [--runs N] # the same measurements on one document
@@ -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 validate-pdf FILE.pdf # built-in PDF/X-4 checks (including visible unembedded fonts)\n designcraft-cli links\n designcraft-cli --version";
const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR]... [--automation-write-root DIR]...\n the roots confine every file the agent's commands read (open, placed images, data sources, libraries) and\n write (save, export, package) to those folders, links followed. Each may be given more than once; a kind\n left out grants none of its access.\n They run the in-process server, so they conflict with --connect (a running app is not confined by them).\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli validate-pdf FILE.pdf # built-in PDF/X-4 checks (including visible unembedded fonts)\n designcraft-cli links\n designcraft-cli --version";
/// `script --help`: the script syntax, otherwise only in `describe script.run` and docs/agents.md.
const SCRIPT_HELP: &str = r#"usage: designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]
@@ -124,7 +124,7 @@ fn main() -> ExitCode {
Some("bench") => report(perf::bench(&args[1..])),
Some("validate-pdf") => report(validate_pdf(args.get(1).map(String::as_str))),
Some("links") => {
use designcraft_engine::links::*;
use designcraft_engine::links::{APP_PAGE, DISCORD, GITHUB, ISSUES, WEBSITE};
outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}\nIssues {ISSUES}");
ExitCode::SUCCESS
}
@@ -170,7 +170,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
use designcraft_mcp::{Backend, Headless, Remote, Server, control_addr};
let mut connect: Option<String> = None;
let mut sample = false;
let mut roots_args = automation_args::AutomationArgs::new(None, None);
let mut roots_args = automation_args::AutomationArgs::default();
let mut it = args.iter().cloned();
while let Some(a) = it.next() {
if roots_args.consume(&a, &mut it)? {
@@ -181,12 +181,14 @@ fn mcp(args: &[String]) -> Result<(), String> {
"--sample" => sample = true,
other => {
return Err(format!(
"unknown mcp option `{other}` (usage: designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR])"
"unknown mcp option `{other}` (usage: designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR]... [--automation-write-root DIR]...)"
));
}
}
}
let roots = AutomationRoots::new(roots_args.read_root.as_deref(), roots_args.write_root.as_deref())?;
let read: Vec<&std::path::Path> = roots_args.read_roots.iter().map(std::path::PathBuf::as_path).collect();
let write: Vec<&std::path::Path> = roots_args.write_roots.iter().map(std::path::PathBuf::as_path).collect();
let roots = AutomationRoots::new(&read, &write)?;
if roots.is_some() && connect.is_some() {
return Err("--automation-read-root and --automation-write-root confine the in-process server; a running app is not confined by them, \
so drop --connect (or use --connect without the roots)"
@@ -209,8 +211,7 @@ so drop --connect (or use --connect without the roots)"
if let Some(roots) = roots {
// Every thread of this process, not only the server's: nothing reaches past the roots.
file_access::confine_process(roots.clone());
let show = |root: Option<&std::path::Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
eprintln!("designcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root()));
eprintln!("designcraft-cli: files confined: {}", roots.describe());
h = h.with_automation_roots(Some(roots));
}
Box::new(h)
@@ -263,7 +264,7 @@ impl Export {
/// A name with another extension is an error: it used to get a PNG of the page. One with no
/// extension still does (`--export /dev/stdout`).
fn of(out: &str) -> Result<Self, String> {
let ext = std::path::Path::new(out).extension().and_then(|e| e.to_str()).map(str::to_lowercase);
let ext = std::path::Path::new(out).extension().and_then(std::ffi::OsStr::to_str).map(str::to_lowercase);
Ok(match ext.as_deref() {
None | Some("png" | "jpg" | "jpeg") => Export::Image,
Some("pdf") => Export::Pdf,
@@ -345,33 +346,7 @@ fn run(args: &[String]) -> Result<(), String> {
}
"--export" => {
let out = val()?;
match Export::of(&out)? {
Export::Image => export(&mut s, &out, page, scale)?,
Export::Pdf => {
let mut p = pdf_opts.clone();
p["path"] = json!(out);
let r = s.execute("file.exportPdf", &p).map_err(|e| e.to_string())?;
eprintln!("wrote {out} ({} pages, {} bytes)", r["pages"], r["bytes"]);
for w in r["warnings"].as_array().into_iter().flatten() {
eprintln!("warning: {}", w.as_str().unwrap_or_default());
}
}
Export::Document => {
s.execute("file.saveAs", &json!({"path": out})).map_err(|e| e.to_string())?;
eprintln!("saved {out}");
}
Export::Pptx => {
let r = s.execute("file.exportPptx", &json!({"path": out})).map_err(|e| e.to_string())?;
eprintln!("wrote {out} ({} slides, {} bytes)", r["slides"], r["bytes"]);
for w in r["warnings"].as_array().into_iter().flatten() {
eprintln!("warning: {}", w.as_str().unwrap_or_default());
}
}
Export::Command(cmd) => {
let r = s.execute(cmd, &json!({"path": out})).map_err(|e| e.to_string())?;
eprintln!("wrote {out} ({} bytes)", r["bytes"]);
}
}
handle_export(&mut s, &out, page, scale, &pdf_opts)?;
}
"--all-pages" => {
let dir = val()?;
@@ -387,6 +362,37 @@ fn run(args: &[String]) -> Result<(), String> {
Ok(())
}
fn handle_export(s: &mut Session, out: &str, page: usize, scale: f64, pdf_opts: &Value) -> Result<(), String> {
match Export::of(out)? {
Export::Image => export(s, out, page, scale)?,
Export::Pdf => {
let mut p = pdf_opts.clone();
p["path"] = json!(out);
let r = s.execute("file.exportPdf", &p).map_err(|e| e.to_string())?;
eprintln!("wrote {out} ({} pages, {} bytes)", r["pages"], r["bytes"]);
for w in r["warnings"].as_array().into_iter().flatten() {
eprintln!("warning: {}", w.as_str().unwrap_or_default());
}
}
Export::Document => {
s.execute("file.saveAs", &json!({"path": out})).map_err(|e| e.to_string())?;
eprintln!("saved {out}");
}
Export::Pptx => {
let r = s.execute("file.exportPptx", &json!({"path": out})).map_err(|e| e.to_string())?;
eprintln!("wrote {out} ({} slides, {} bytes)", r["slides"], r["bytes"]);
for w in r["warnings"].as_array().into_iter().flatten() {
eprintln!("warning: {}", w.as_str().unwrap_or_default());
}
}
Export::Command(cmd) => {
let r = s.execute(cmd, &json!({"path": out})).map_err(|e| e.to_string())?;
eprintln!("wrote {out} ({} bytes)", r["bytes"]);
}
}
Ok(())
}
/// A page as PNG, or as JPEG for `.jpg` and `.jpeg`.
fn export(s: &mut Session, out: &str, page: usize, scale: f64) -> Result<(), String> {
let st = s.doc().map_err(|e| e.to_string())?;
+2 -2
View File
@@ -335,7 +335,7 @@ fn row(rows: &mut Vec<Row>, name: impl Into<String>, ms: f64, budget: Option<f64
/// Measure `doc`. `runs` = repetitions per row (median).
fn measure(doc: &Document, runs: usize) -> Result<Vec<Row>, String> {
let mut rows = vec![];
let cores = std::thread::available_parallelism().map_or(1, |c| c.get());
let cores = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
let chars: usize = doc.stories.values().map(|s| s.text.len()).sum();
// Budget: 250 ms per 300k characters (MT), scaled to this document.
let compose_budget = 250.0 * (chars as f64 / 300_000.0).max(1.0);
@@ -532,7 +532,7 @@ fn print(title: &str, rows: &[Row], noisy: bool) -> Result<(), String> {
}
fn header() -> (usize, bool) {
let cores = std::thread::available_parallelism().map_or(1, |c| c.get());
let cores = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
let load = load_average();
let noisy = load.is_some_and(|l| l > cores as f64 * 0.75);
outln!(
+96 -3
View File
@@ -401,6 +401,97 @@ fn mcp_automation_roots_confine_the_server() {
let _ = std::fs::remove_dir_all(base);
}
/// #1552: each root flag may be given more than once. A path inside any root of its kind passes;
/// the roots' common parent, a sibling whose name starts like a root's, `..` out of a root, the
/// other kind's roots and a link out of a root stay refused.
#[test]
fn mcp_automation_roots_take_several_folders() {
let base = std::env::temp_dir().join(format!("dc-cli-roots-several-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
for d in ["in", "shared", "out", "scratch", "outside", "in2", "scratch2"] {
std::fs::create_dir_all(base.join(d)).unwrap();
}
// Resolved, as the server shows the roots (`/var` is `/private/var` on macOS).
let base = std::fs::canonicalize(base).unwrap();
for f in ["in/a.designcraft", "shared/b.designcraft", "outside/secret.designcraft", "parent.designcraft", "in2/c.designcraft"] {
make_document(&base.join(f));
}
// Windows' canonical `\\?\` form is not a prefix under which `..` is a path component:
// traversal requests use the ordinary spelling, while the other requests keep the resolved one.
let plain = |path: &std::path::Path| -> String {
let text = path.to_string_lossy();
if !cfg!(windows) {
return text.into_owned();
}
if let Some(rest) = text.strip_prefix(r"\\?\UNC\") {
return format!(r"\\{rest}");
}
match text.strip_prefix(r"\\?\") {
Some(rest) => rest.to_owned(),
None => text.into_owned(),
}
};
let p = |f: &str| base.join(f).to_str().unwrap().to_string();
let sep = std::path::MAIN_SEPARATOR;
let up = |root: &str, rest: &str| format!("{}{sep}..{sep}{rest}", plain(&base.join(root)));
let calls = [
("open_document", serde_json::json!({"path": p("in/a.designcraft")})),
("open_document", serde_json::json!({"path": p("shared/b.designcraft")})),
// The roots' common parent is not a root.
("open_document", serde_json::json!({"path": p("parent.designcraft")})),
("open_document", serde_json::json!({"path": up("shared", &format!("outside{sep}secret.designcraft"))})),
// `in2` only starts like `in`.
("open_document", serde_json::json!({"path": p("in2/c.designcraft")})),
// `..` from one read root into another stays inside.
("open_document", serde_json::json!({"path": up("shared", &format!("in{sep}a.designcraft"))})),
("save_document", serde_json::json!({"path": p("out/x.designcraft")})),
("save_document", serde_json::json!({"path": p("scratch/y.designcraft")})),
("save_document", serde_json::json!({"path": p("z.designcraft")})),
("save_document", serde_json::json!({"path": p("scratch2/z.designcraft")})),
// Read and write stay separate: a read root is not a write root, nor the other way round.
("save_document", serde_json::json!({"path": p("shared/w.designcraft")})),
("open_document", serde_json::json!({"path": p("out/x.designcraft")})),
("export_png", serde_json::json!({"path": up("scratch", &format!("outside{sep}p.png"))})),
];
let read2 = format!("--automation-read-root={}", p("shared"));
let args = ["--automation-read-root", &p("in"), &read2, "--automation-write-root", &p("out"), "--automation-write-root", &p("scratch")];
let (success, stderr, replies) = mcp_session(&args, &calls);
assert!(success, "{stderr}");
let confined = format!(
"files confined: read {}, {}; write {}, {}",
plain(&base.join("in")),
plain(&base.join("shared")),
plain(&base.join("out")),
plain(&base.join("scratch"))
);
assert!(stderr.contains(&confined), "expected {confined:?} in {stderr:?}");
let errors: Vec<bool> = replies.iter().map(|r| r["result"]["isError"] == true).collect();
assert_eq!(errors, [false, false, true, true, true, false, false, false, true, true, true, true, true], "{replies:?}");
for (i, kind) in [(2, "read"), (3, "read"), (4, "read"), (8, "write"), (9, "write"), (10, "write"), (11, "read"), (12, "write")] {
assert!(text(&replies[i]).contains(&format!("outside the {kind} roots ")), "{i}: {}", text(&replies[i]));
}
assert!(base.join("out/x.designcraft").is_file() && base.join("scratch/y.designcraft").is_file());
for f in ["z.designcraft", "scratch2/z.designcraft", "shared/w.designcraft", "outside/p.png"] {
assert!(!base.join(f).exists(), "{f} was written outside the write roots");
}
// A link in one root that leads out of all of them is outside, for reading and for writing.
#[cfg(unix)]
{
std::os::unix::fs::symlink(base.join("outside/secret.designcraft"), base.join("shared/leak.designcraft")).unwrap();
std::os::unix::fs::symlink(base.join("outside"), base.join("scratch/away")).unwrap();
let calls = [
("open_document", serde_json::json!({"path": p("shared/leak.designcraft")})),
("save_document", serde_json::json!({"path": p("scratch/away/x.designcraft")})),
];
let (_, _, replies) = mcp_session(&args, &calls);
assert!(text(&replies[0]).contains("outside the read roots"), "{}", text(&replies[0]));
assert!(text(&replies[1]).contains("outside the write roots"), "{}", text(&replies[1]));
assert!(!base.join("outside/x.designcraft").exists());
}
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn mcp_automation_root_flags_are_checked_up_front() {
let base = std::env::temp_dir().join(format!("dc-cli-roots-args-{}", std::process::id()));
@@ -419,9 +510,11 @@ fn mcp_automation_root_flags_are_checked_up_front() {
// The flags confine the in-process server, so they cannot be combined with --connect.
fails(&["--connect", "7979", "--automation-read-root", dir], "--connect");
fails(&["--automation-write-root", dir, "--connect", "7979"], "--connect");
fails(&["--automation-read-root", dir, "--automation-read-root", dir], "given twice");
fails(&["--automation-write-root", dir, "--automation-write-root", dir], "given twice");
fails(&["--automation-read-root", dir, "--automation-read-root=x"], "given twice");
// #1552: a flag may be given again, but every folder it names must exist and be given.
fails(&["--automation-read-root", dir, "--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root");
fails(&["--automation-write-root", missing.to_str().unwrap(), "--automation-write-root", dir], "cannot open the automation write root");
fails(&["--automation-write-root", dir, "--automation-write-root"], "needs a folder");
fails(&["--automation-read-root", dir, "--automation-read-root="], "needs a non-empty folder");
fails(&["--automation-read-root"], "needs a folder");
fails(&["--automation-write-root", "--sample"], "needs a folder");
fails(&["--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root");
+9 -10
View File
@@ -350,9 +350,14 @@ struct WebShell {
impl eframe::App for WebShell {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
let graphics = self.graphics.clone();
let _ = graphics.run_if_healthy(|| {
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
for f in dropped {
let _ = graphics.run_if_healthy(|| self.app.logic(ctx));
}
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
let _ = self.graphics.run_if_healthy(|| {
// Dropped files belong to this host iteration's raw input, not to stale logic input:
// read them here once, before the batch is saved as pending for a hidden pass.
for f in std::mem::take(&mut raw.dropped_files) {
let request = self.app.import_request("drop");
let inbox = self.inbox.clone();
let ctx = ctx.clone();
@@ -367,14 +372,8 @@ impl eframe::App for WebShell {
}
});
}
self.app.logic(ctx);
});
}
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
let _ = self.graphics.run_if_healthy(|| {
designcraft_ui_egui::drop_key_name_text(raw, ctx.text_edit_focused() || self.app.canvas_ime());
self.app.raw_input_hook(raw);
self.app.raw_input_hook(ctx, raw);
});
}
+1 -1
View File
@@ -13,7 +13,7 @@ workspace = true
[dependencies]
designcraft-engine = { workspace = true }
designcraft-ui-egui = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links", "clipboard"] }
egui = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
+37 -28
View File
@@ -51,39 +51,45 @@ struct App {
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.startup.enter();
if let Some(note) = self.startup.take_notice() {
self.app.status(note);
}
// The third call: two frames went through `ui`, so the first was presented and the driver
// survived it (a fault at the first present is what the fallback in `gpu` is for).
if self.frames == 2 {
self.startup.presented();
}
self.frames = self.frames.saturating_add(1);
if u64::from(self.frames) >= gpu::STARTUP_FRAMES
&& let Some(device) = self.log_errors_after_startup.take()
{
gpu_limits::log_uncaptured_errors(&device);
}
#[cfg(target_os = "macos")]
{
if self.menu.is_none() && std::env::var_os("DESIGNCRAFT_NO_NATIVE_MENU").is_none() {
self.menu = Some(native_menu::NativeMenu::install(&mut self.app, ctx));
}
if let Some(m) = &mut self.menu {
m.poll(&mut self.app, ctx);
}
// Noted for a start again after a graphics failure, which reopens them (`gpu::restart`).
self.startup.opening(apple_events::poll(&self.apple_events, &mut self.app, ctx));
}
// Queue/deadline work only. A hidden `run_logic` tick also reports pass index zero, so
// startup counters and native-menu polling must not live here.
self.app.logic(ctx);
self.startup.leave();
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.app.raw_input_hook(raw);
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
self.app.raw_input_hook(ctx, raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.startup.enter();
// `App::ui` is the only signal that a real layout pass ran. A discarded multipass first
// pass reports index 0 once, so these once-per-frame counters/menu polls still run once.
if ui.ctx().current_pass_index() == 0 {
if let Some(note) = self.startup.take_notice() {
self.app.status(note);
}
// The third UI callback: two frames went through `ui`, so the first was presented and
// the driver survived it (a fault at the first present is what `gpu` falls back for).
if self.frames == 2 {
self.startup.presented();
}
self.frames = self.frames.saturating_add(1);
if u64::from(self.frames) >= gpu::STARTUP_FRAMES
&& let Some(device) = self.log_errors_after_startup.take()
{
gpu_limits::log_uncaptured_errors(&device);
}
#[cfg(target_os = "macos")]
{
if self.menu.is_none() && std::env::var_os("DESIGNCRAFT_NO_NATIVE_MENU").is_none() {
self.menu = Some(native_menu::NativeMenu::install(&mut self.app, ui.ctx()));
}
if let Some(m) = &mut self.menu {
m.poll(&mut self.app, ui.ctx());
}
// Noted for a start again after a graphics failure, which reopens them (`gpu::restart`).
self.startup.opening(apple_events::poll(&self.apple_events, &mut self.app, ui.ctx()));
}
}
self.app.ui(ui);
self.startup.leave();
#[cfg(target_os = "macos")]
@@ -256,7 +262,7 @@ const ALL_FILES: &str = if cfg!(target_os = "macos") { "public.data" } else { "*
/// The extension of the file name a save dialog suggests (`Brochure.designcraft`), if it has one.
/// The dialog filters on it, so the macOS and Windows dialogs add it to a name typed without it.
fn suggested_extension(name: &str) -> Option<&str> {
std::path::Path::new(name).extension().and_then(|e| e.to_str()).filter(|e| !e.is_empty() && !e.contains(char::is_whitespace))
std::path::Path::new(name).extension().and_then(std::ffi::OsStr::to_str).filter(|e| !e.is_empty() && !e.contains(char::is_whitespace))
}
/// The file a save dialog's answer writes: `picked` with the suggested extension added when it has
@@ -407,6 +413,9 @@ fn main() -> std::process::ExitCode {
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false),
// DesignCraft's own FontDb supplies script-specific system fallbacks; egui's system-font
// provider would put its discovery ahead of that order and change line metrics.
system_font_fallback: false,
..Default::default()
};
options.viewport = options.viewport.with_app_id(APP_ID);
+11 -7
View File
@@ -65,10 +65,11 @@ impl NativeMenu {
// muda keeps the first handler for the life of the process, so this channel serves every
// menu bar built later (`build_bar`).
let (tx, events) = channel();
let repaint = ctx.clone();
let notify = app.native_action_notifier(ctx.clone());
MenuEvent::set_event_handler(Some(move |e: MenuEvent| {
let _ = tx.send(e);
repaint.request_repaint();
if tx.send(e).is_ok() {
notify();
}
}));
Self::build_bar(app, events)
}
@@ -170,11 +171,14 @@ impl NativeMenu {
}
pub fn poll(&mut self, app: &mut DesignApp, ctx: &egui::Context) {
while let Ok(ev) = self.events.try_recv() {
if let Some((cmd, params, _)) = self.items.get(ev.id.as_ref()) {
menus::activate_native(app, ctx, &cmd.clone(), &params.clone());
app.poll_native_actions(ctx, || {
loop {
let ev = self.events.try_recv().ok()?;
if let Some((cmd, params, _)) = self.items.get(ev.id.as_ref()) {
return Some((cmd.clone(), params.clone()));
}
}
}
});
// Interface Language and Show CJK Features (set in Preferences or by `app.language`, or put
// back by Cancel): the system menu bar follows at once, like the panels (the new menu
// replaces the old one as the application's menu before that one is dropped).
+10 -10
View File
@@ -17,7 +17,7 @@ pub mod lab;
use std::borrow::Cow;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock, RwLock};
use std::sync::{Arc, Mutex, OnceLock, PoisonError, RwLock};
use serde::{Deserialize, Serialize};
@@ -201,7 +201,7 @@ static USER: RwLock<Vec<Arc<IccProfile>>> = RwLock::new(Vec::new());
fn builtin_rgb_cache(name: &str) -> Option<Arc<IccProfile>> {
static CACHE: Mutex<Vec<Arc<IccProfile>>> = Mutex::new(Vec::new());
let mut c = CACHE.lock().unwrap_or_else(|e| e.into_inner());
let mut c = CACHE.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(p) = c.iter().find(|p| p.name == name) {
return Some(p.clone());
}
@@ -211,7 +211,7 @@ fn builtin_rgb_cache(name: &str) -> Option<Arc<IccProfile>> {
}
fn user_profile(name: &str) -> Option<Arc<IccProfile>> {
USER.read().unwrap_or_else(|e| e.into_inner()).iter().find(|p| p.name == name).cloned()
USER.read().unwrap_or_else(PoisonError::into_inner).iter().find(|p| p.name == name).cloned()
}
/// The default CMYK space: Coated FOGRA39, or Generic CMYK should that profile be unusable.
@@ -255,7 +255,7 @@ pub fn profiles() -> Vec<ProfileInfo> {
.map(|n| ProfileInfo { name: (*n).into(), kind: ProfileKind::Rgb, builtin: true })
.chain([COATED_FOGRA39, GENERIC_CMYK, DEVICE_CMYK].iter().map(|n| ProfileInfo { name: (*n).into(), kind: ProfileKind::Cmyk, builtin: true }))
.collect();
for p in USER.read().unwrap_or_else(|e| e.into_inner()).iter() {
for p in USER.read().unwrap_or_else(PoisonError::into_inner).iter() {
v.push(ProfileInfo { name: p.name.clone(), kind: p.kind, builtin: false });
}
v
@@ -275,7 +275,7 @@ pub fn register_icc(bytes: &[u8], name: Option<String>) -> Result<ProfileInfo, C
return Err(CmsError::Unsupported(format!("{}: no usable transform to/from sRGB", p.name)));
}
let info = ProfileInfo { name: p.name.clone(), kind: p.kind, builtin: false };
let mut u = USER.write().unwrap_or_else(|e| e.into_inner());
let mut u = USER.write().unwrap_or_else(PoisonError::into_inner);
u.retain(|x| x.name != info.name);
u.push(Arc::new(p));
drop(u);
@@ -522,7 +522,7 @@ impl Cms {
/// A cached 3-D lookup table implementing [`Cms::proof_srgb`] for 8-bit pixels.
pub fn proof_lut(&self, proof: &ProofSetup) -> Arc<ProofLut> {
let key = format!("{:?}|{:?}|{:?}|{}", self.settings, proof.target, proof.intent, proof.simulate_paper);
let mut cache = PROOF_CACHE.lock().unwrap_or_else(|e| e.into_inner());
let mut cache = PROOF_CACHE.lock().unwrap_or_else(PoisonError::into_inner);
if let Some((_, l)) = cache.iter().find(|(k, _)| *k == key) {
return l.clone();
}
@@ -538,7 +538,7 @@ impl Cms {
static PROOF_CACHE: Mutex<Vec<(String, Arc<ProofLut>)>> = Mutex::new(Vec::new());
fn clear_proof_cache() {
PROOF_CACHE.lock().unwrap_or_else(|e| e.into_inner()).clear();
PROOF_CACHE.lock().unwrap_or_else(PoisonError::into_inner).clear();
}
fn cmyk4(v: Option<Vec<f32>>) -> Option<[f32; 4]> {
@@ -704,10 +704,10 @@ pub fn generation() -> u64 {
/// The active colour-management context (Edit → Color Settings).
pub fn active() -> Arc<Cms> {
if let Some(c) = ACTIVE.read().unwrap_or_else(|e| e.into_inner()).as_ref() {
if let Some(c) = ACTIVE.read().unwrap_or_else(PoisonError::into_inner).as_ref() {
return c.clone();
}
let mut w = ACTIVE.write().unwrap_or_else(|e| e.into_inner());
let mut w = ACTIVE.write().unwrap_or_else(PoisonError::into_inner);
w.get_or_insert_with(|| Arc::new(Cms::default())).clone()
}
@@ -721,7 +721,7 @@ pub fn set_active(settings: &ColorSettings) -> Result<(), CmsError> {
let changed = active().settings != *settings;
RGB_IS_SRGB.store(cms.rgb_is_srgb(), Ordering::Relaxed);
CMYK_IS_DEVICE.store(matches!(cms.cmyk, CmykSpace::Device), Ordering::Relaxed);
*ACTIVE.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(cms));
*ACTIVE.write().unwrap_or_else(PoisonError::into_inner) = Some(Arc::new(cms));
if changed {
GENERATION.fetch_add(1, Ordering::Relaxed);
}
+32 -15
View File
@@ -48,6 +48,9 @@ pub struct Spacing {
pub hyph_zone: f64,
/// Optical margin alignment: punctuation hangs outside the measure (counted as extra width).
pub optical: bool,
/// The type size the hang amounts are set for (Story ▸ Optical Margin Alignment's size);
/// `None`: each glyph's own size, so the hang is [`hang`]'s proportion of its advance.
pub optical_size: Option<f64>,
/// Korean text breaks between syllables instead of at spaces.
pub korean_char_breaks: bool,
/// 0: minimum adjustment; 1: push in; 2: push out first; 3: push out only.
@@ -72,6 +75,7 @@ impl Default for Spacing {
ragged_stretch: 24.0,
hyph_zone: 0.0,
optical: false,
optical_size: None,
korean_char_breaks: false,
kinsoku_priority: 0,
}
@@ -295,7 +299,7 @@ fn cjk_break(a: &Glyph, b: &Glyph, sp: &Spacing) -> bool {
}
pub fn is_forced(c: char) -> bool {
use designcraft_doc::story::*;
use designcraft_doc::story::{FORCED_LINE_BREAK, is_break_char};
c == FORCED_LINE_BREAK || is_break_char(c)
}
@@ -318,16 +322,32 @@ pub fn hang(c: char) -> (f64, f64) {
}
}
fn hang_left(g: &Glyph) -> f64 {
hang(g.ch).0 * g.adv
/// How much more than [`hang`]'s proportions `g` hangs: the hang is set for
/// [`Spacing::optical_size`] and grows with it, relative to the glyph's own size.
pub(crate) fn optical_scale(g: &Glyph, sp: &Spacing) -> f64 {
match sp.optical_size {
Some(s) if s.is_finite() && g.size.is_finite() && g.size > 0.0 => s / g.size,
_ => 1.0,
}
}
/// Optical hang of `g` at the start of a line (0 without optical margin alignment).
pub(crate) fn hang_left(g: &Glyph, sp: &Spacing) -> f64 {
if sp.optical { hang(g.ch).0 * g.adv * optical_scale(g, sp) } else { 0.0 }
}
/// Optical hang of `g` at the end of a line (0 without optical margin alignment).
pub(crate) fn hang_right(g: &Glyph, sp: &Spacing) -> f64 {
if sp.optical { hang(g.ch).1 * g.adv * optical_scale(g, sp) } else { 0.0 }
}
fn right_hang(g: &Glyph, sp: &Spacing) -> f64 {
g.cjk_hang.max(if sp.optical { hang_right(g) } else { 0.0 }) - g.moji.end
g.cjk_hang.max(hang_right(g, sp)) - g.moji.end
}
fn hang_right(g: &Glyph) -> f64 {
hang(g.ch).1 * g.adv
/// The part of a hyphen after `g` (of width `hw`) that hangs past the line's end.
fn hyphen_hang(g: &Glyph, hw: f64, sp: &Spacing) -> f64 {
if sp.optical { hw * hang('-').1 * optical_scale(g, sp) } else { 0.0 }
}
/// Advance of the hyphen glyph that would follow `g` (cached per face).
@@ -428,15 +448,13 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], emergency_after: &[bool], sp: &S
let mut p = Item::penalty(hw, sp.hyphen_penalty, true);
p.auto = true;
p.zone_from = last_space;
if sp.optical {
p.hang = hw * hang('-').1;
}
p.hang = hyphen_hang(g, hw, sp);
push_inword(&mut it, &mut ig, p, i + 1, sp);
continue;
}
let (st, sh) = sp.box_elastic(g);
let mut bx = Item::boxed(g.adv, st, sh);
bx.hang = (if sp.optical { hang_left(g) } else { 0.0 }) - g.moji.start;
bx.hang = hang_left(g, sp) - g.moji.start;
bx.start_elastic = crate::mojikumi::start_elastic(g, sp.justify, sp.kinsoku_priority != 3);
it.push(bx);
ig.push(i);
@@ -457,9 +475,7 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], emergency_after: &[bool], sp: &S
let mut p = Item::penalty(hw, sp.hyphen_penalty, true);
p.auto = true;
p.zone_from = last_space;
if sp.optical {
p.hang = hw * hang('-').1;
}
p.hang = hyphen_hang(g, hw, sp);
push_inword(&mut it, &mut ig, p, i + 1, sp);
} else if emergency_after.get(i) == Some(&true) {
let mut p = Item::penalty(0.0, EMERGENCY_PENALTY, false);
@@ -876,7 +892,7 @@ pub fn greedy(
let may_hyphenate = |hyphens: u32| sp.hyphen_limit == 0 || hyphens < sp.hyphen_limit;
while start < n {
let w = width(line).max(1.0);
let mut x = (if sp.optical { -hang_left(&glyphs[start]) } else { 0.0 }) + glyphs[start].moji.start;
let mut x = -hang_left(&glyphs[start], sp) + glyphs[start].moji.start;
let mut shrink = crate::mojikumi::start_elastic(&glyphs[start], sp.justify, sp.kinsoku_priority != 3)[1];
let mut last_natural: Option<(usize, bool)> = None;
let mut last_ok: Option<(usize, bool)> = None; // (break position = end glyph, hyphen)
@@ -939,7 +955,8 @@ pub fn greedy(
last_natural = last_ok;
}
} else if hyph_after[i] && may_hyphenate(hyphens) {
let hy = hyphen_width(g, &mut hy_cache) * if sp.optical { 1.0 - hang('-').1 } else { 1.0 };
let hw = hyphen_width(g, &mut hy_cache);
let hy = (hw - hyphen_hang(g, hw, sp)).max(0.0);
if x + hy <= w + shrink + FIT_EPS {
last_ok = Some((i + 1, true));
}
+57 -24
View File
@@ -3,11 +3,12 @@
//! An entry is valid while the inputs it was composed from are the *same allocations*: the story
//! `Arc` (and its revision), the styles `Arc`, the frame items' `Arc`s and the `Arc`s of every
//! wrapping item on the frames' spreads, and the same for every story whose anchored objects wrap
//! its text ([`crate::wrap::neighbours`]). Entries hold clones of those `Arc`s, so the pointers
//! its text ([`crate::wrap::neighbours`]). An anchored frame's entry also records where its host
//! story's composition places it. Entries hold clones of those `Arc`s, so the pointers
//! can't be reused by other allocations while the entry lives.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::sync::{Arc, Mutex, PoisonError};
use designcraft_doc::{Document, Item, Story, StoryId, Styles};
@@ -99,11 +100,12 @@ impl Cache {
/// [`Cache::get`] for any [`ShownOn`].
pub fn get_on(&self, doc: &Document, sid: StoryId, on: ShownOn) -> Arc<ComposedStory> {
self.get_with(doc, sid, on, true)
self.get_with(doc, sid, on, true, 0)
}
/// [`Cache::get_on`], wrapped by other stories' anchored objects only when `cross` is set.
fn get_with(&self, doc: &Document, sid: StoryId, on: ShownOn, cross: bool) -> Arc<ComposedStory> {
/// [`Cache::get_on`], wrapped by other stories' anchored objects only when `cross` is set, for
/// a story `depth` hosts down from the story asked for.
fn get_with(&self, doc: &Document, sid: StoryId, on: ShownOn, cross: bool, depth: usize) -> Arc<ComposedStory> {
let Some(story) = doc.stories.get(&sid) else { return Arc::new(ComposedStory { story: sid, ..Default::default() }) };
let (mut sig, mut keep_items) = signature(doc, story);
let neighbours = if cross { crate::wrap::neighbours(doc, sid) } else { Vec::new() };
@@ -117,6 +119,24 @@ impl Cache {
keep_stories.push(ns.clone());
}
}
// Anchored frames compose where their host story places them, so the host is composed
// first and the placements are part of the signature.
let placed = if depth < crate::placed::MAX_DEPTH {
crate::placed::placements(doc, sid, &mut |host| self.get_with(doc, host, ShownOn::Own, true, depth + 1))
} else {
Vec::new()
};
for (id, p) in &placed {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
(id, p.spread, p.xf.as_coeffs().map(f64::to_bits)).hash(&mut h);
// The placed spread's pages give the frame its page, side and page rect.
for pg in doc.spread(p.spread).map_or(&[][..], |sp| &sp.pages[..]) {
let m = &pg.margins;
(pg.side as u8, [pg.x, pg.width, pg.height, m.top, m.bottom, m.inside, m.outside].map(f64::to_bits)).hash(&mut h);
}
sig.push(h.finish() as usize);
}
let key = (sid, on, !neighbours.is_empty());
// The key holds the page's index, which a renumbering doesn't change: its name (section
// start number, style, prefix) is part of the signature.
@@ -186,7 +206,7 @@ impl Cache {
}
// Advanced Type sizes super/subscripts.
let a = doc.settings.advanced_type;
sig.extend([a.superscript_size, a.superscript_position, a.subscript_size, a.subscript_position].map(|v| v.to_bits() as usize));
sig.extend([a.superscript_size, a.superscript_position, a.subscript_size, a.subscript_position].into_iter().flat_map(bits));
// Missing glyphs: fallback fonts or the font's box.
sig.push(usize::from(doc.settings.glyph_fallback));
// Named lists continue from earlier stories.
@@ -196,7 +216,7 @@ impl Cache {
sig.push(std::hash::Hasher::finish(&h) as usize);
}
{
let mut g = self.map.lock().unwrap_or_else(|e| e.into_inner());
let mut g = self.map.lock().unwrap_or_else(PoisonError::into_inner);
g.1 += 1;
let stamp = g.1;
if let Some(e) = g.0.get_mut(&key)
@@ -209,14 +229,14 @@ impl Cache {
// Parent-page items are composed per page: markers show its name, variables need its index.
let page = page.filter(|_| with_vars);
let running = running.then(|| self.running_index(doc));
let others: Vec<Arc<ComposedStory>> = neighbours.iter().map(|n| self.neighbour(doc, *n)).collect();
let others: Vec<Arc<ComposedStory>> = neighbours.iter().map(|n| self.neighbour(doc, *n, depth)).collect();
let out = Arc::new(crate::wrap::compose_among(
doc,
sid,
&ComposeOptions { page_name, section_marker, page, running, label: None, xrefs: with_xrefs.then(|| self.xref_index(doc)) },
&ComposeOptions { page_name, section_marker, page, running, label: None, xrefs: with_xrefs.then(|| self.xref_index(doc)), placed },
&others.iter().map(|o| &**o).collect::<Vec<_>>(),
));
let mut g = self.map.lock().unwrap_or_else(|e| e.into_inner());
let mut g = self.map.lock().unwrap_or_else(PoisonError::into_inner);
let stamp = g.1;
if g.0.len() > 4096 {
g.0.retain(|_, e| stamp - e.stamp < 64);
@@ -228,7 +248,7 @@ impl Cache {
&& cross
&& let Some(old) = replaced
{
self.previous.lock().unwrap_or_else(|e| e.into_inner()).insert(sid, (old.sig, old.out));
self.previous.lock().unwrap_or_else(PoisonError::into_inner).insert(sid, (old.sig, old.out));
}
out
}
@@ -241,7 +261,7 @@ impl Cache {
// Only the geometry/story part of the signature (variables etc. add to it).
let matches = |s: &[usize]| s.len() >= sig.len() && s[..sig.len()] == sig[..];
{
let g = self.map.lock().unwrap_or_else(|e| e.into_inner());
let g = self.map.lock().unwrap_or_else(PoisonError::into_inner);
for cross in [true, false] {
if let Some(e) = g.0.get(&(sid, ShownOn::Own, cross))
&& matches(&e.sig)
@@ -250,7 +270,7 @@ impl Cache {
}
}
}
let g = self.previous.lock().unwrap_or_else(|e| e.into_inner());
let g = self.previous.lock().unwrap_or_else(PoisonError::into_inner);
g.get(&sid).filter(|(s, _)| matches(s)).map(|(_, c)| c.clone())
}
@@ -295,49 +315,56 @@ impl Cache {
/// Story `sid` composed for the wrap of its anchored objects in other stories: without other
/// stories' anchored objects (no cycles), and outside the cache when it needs the document-wide
/// indexes (they compose every story).
fn neighbour(&self, doc: &Document, sid: StoryId) -> Arc<ComposedStory> {
fn neighbour(&self, doc: &Document, sid: StoryId, depth: usize) -> Arc<ComposedStory> {
let indexed =
doc.stories.get(&sid).is_some_and(|st| crate::xref::has_xrefs(st) || (crate::vars::has_vars(st) && crate::vars::has_running(doc, st)));
if indexed {
Arc::new(crate::wrap::compose_among(doc, sid, &ComposeOptions::default(), &[]))
} else {
self.get_with(doc, sid, ShownOn::Own, false)
self.get_with(doc, sid, ShownOn::Own, false, depth)
}
}
pub fn clear(&self) {
self.map.lock().unwrap_or_else(|e| e.into_inner()).0.clear();
*self.running.lock().unwrap_or_else(|e| e.into_inner()) = None;
*self.xrefs.lock().unwrap_or_else(|e| e.into_inner()) = None;
self.map.lock().unwrap_or_else(PoisonError::into_inner).0.clear();
*self.running.lock().unwrap_or_else(PoisonError::into_inner) = None;
*self.xrefs.lock().unwrap_or_else(PoisonError::into_inner) = None;
}
/// Anchor pages for cross-references (rebuilt when the document changes).
pub fn xref_index(&self, doc: &Document) -> Arc<crate::xref::XrefIndex> {
let sig = doc_signature(doc);
if let Some((s, r)) = &*self.xrefs.lock().unwrap_or_else(|e| e.into_inner())
if let Some((s, r)) = &*self.xrefs.lock().unwrap_or_else(PoisonError::into_inner)
&& *s == sig
{
return r.clone();
}
let r = Arc::new(crate::xref::XrefIndex::build(doc, &|sid| self.get(doc, sid, None)));
*self.xrefs.lock().unwrap_or_else(|e| e.into_inner()) = Some((sig, r.clone()));
*self.xrefs.lock().unwrap_or_else(PoisonError::into_inner) = Some((sig, r.clone()));
r
}
/// The running-header index for `doc` (rebuilt when the document changes).
pub fn running_index(&self, doc: &Document) -> Arc<RunningIndex> {
let sig = doc_signature(doc);
if let Some((s, r)) = &*self.running.lock().unwrap_or_else(|e| e.into_inner())
if let Some((s, r)) = &*self.running.lock().unwrap_or_else(PoisonError::into_inner)
&& *s == sig
{
return r.clone();
}
let r = Arc::new(RunningIndex::build(doc, &|sid| self.get(doc, sid, None)));
*self.running.lock().unwrap_or_else(|e| e.into_inner()) = Some((sig, r.clone()));
*self.running.lock().unwrap_or_else(PoisonError::into_inner) = Some((sig, r.clone()));
r
}
}
/// Both halves of `v`'s bits: a 32-bit `usize` (wasm) would drop the high half, where whole
/// numbers keep their differences.
fn bits(v: f64) -> [usize; 2] {
let b = v.to_bits();
[(b & 0xffff_ffff) as usize, (b >> 32) as usize]
}
fn signature(doc: &Document, story: &Arc<Story>) -> (Vec<usize>, Vec<Arc<Item>>) {
// The document's font scope: another document's fonts of the same name set it differently.
// Sections name the pages under the frames (page-number and section markers).
@@ -348,9 +375,9 @@ fn signature(doc: &Document, story: &Arc<Story>) -> (Vec<usize>, Vec<Arc<Item>>)
Arc::as_ptr(&doc.styles) as usize,
sections_hash(doc) as usize,
doc.font_scope as usize,
g.start.to_bits() as usize,
g.increment.to_bits() as usize,
usize::from(g.relative_to == designcraft_doc::GridRelative::TopMargin),
];
sig.extend([g.start, g.increment].into_iter().flat_map(bits));
let mut keep = Vec::new();
let mut spreads = Vec::new();
for f in &story.frames {
@@ -373,6 +400,12 @@ fn signature(doc: &Document, story: &Arc<Story>) -> (Vec<usize>, Vec<Arc<Item>>)
sig.push(loc.top());
if !spreads.contains(&loc.spread) {
spreads.push(loc.spread);
// The page under a frame (found by x) gives its grid origin, page rect and side.
for p in &sp.pages {
let m = &p.margins;
sig.extend([p.x, p.width, p.height, m.top, m.bottom, m.inside, m.outside].into_iter().flat_map(bits));
sig.push(p.side as usize);
}
for it in &sp.items {
let mut wraps = false;
it.walk(&mut |i| wraps |= i.has_wrap());
+18
View File
@@ -23,6 +23,13 @@ use designcraft_doc::story;
pub const MAX_PATTERN: usize = 4096;
/// Backtracking steps one search may take before it gives up.
pub const BACKTRACK_LIMIT: usize = 200_000;
/// Matches one GREP style may contribute to one paragraph.
///
/// [`BACKTRACK_LIMIT`] bounds a single search, not a scan: `find_all` pays it again for every
/// match, so a pattern tuned just under the limit cost `BACKTRACK_LIMIT × matches` per paragraph
/// on every relayout (#1598). A paragraph cannot usefully carry more styled runs than this, and
/// a scan that reaches it stops with what it has.
pub const MAX_MATCHES_PER_PARAGRAPH: usize = 4096;
/// Compiled program size cap (bytes) for the underlying regex engine.
const SIZE_LIMIT: usize = 4 << 20;
@@ -378,6 +385,17 @@ impl Grep {
self.re.find_iter(text).take(limit).map(|m| m.map(|m| m.range()).map_err(run_err)).collect()
}
/// Non-empty matches in `text`, at most `limit` of them: an empty match styles nothing, so it
/// doesn't count toward a GREP style's cap.
pub fn find_nonempty(&self, text: &str, limit: usize) -> Result<Vec<Range<usize>>, GrepError> {
self.re
.find_iter(text)
.filter(|m| !m.as_ref().is_ok_and(|m| m.range().is_empty()))
.take(limit)
.map(|m| m.map(|m| m.range()).map_err(run_err))
.collect()
}
/// Matches in `text` and their replacements, `template` expanded per match (`$1`…; see
/// [`translate_replacement`]). Stops after the first match when `first_only`.
pub fn replacements(&self, text: &str, template: &str, first_only: bool) -> Result<Vec<(Range<usize>, String)>, GrepError> {
+208 -45
View File
@@ -23,6 +23,7 @@ pub mod optical;
mod outline;
mod overlay;
mod parabox;
mod placed;
mod ruby;
pub mod shape;
pub mod shatai;
@@ -50,6 +51,7 @@ use crate::notes::Notes;
pub use crate::notes::{find_note, hit_note, hit_note_with, note_caret};
pub use crate::outline::FrameOutline;
use crate::parabox::ParaBox;
pub use crate::placed::Placement;
use crate::shape::{Glyph, StyleTable, SubstCtx};
pub use crate::table::{BorderHit, PlacedCell, StrokeSeg, TableFrag, cell_caret, find_cell, hit_border, hit_cell, hit_cell_with};
@@ -583,6 +585,9 @@ pub struct FrameSpec {
pub grid: Option<(f64, f64)>,
/// The frame is on a left page (for towards/away-from-spine alignment).
pub left_page: bool,
/// The frame is on a right page of a facing spread (spine-relative anchored objects mirror
/// there; a single page has no spine).
pub right_page: bool,
/// The page and its margins in the frame's inner space (custom anchored objects).
pub page_rect: Option<(Rect, Rect)>,
}
@@ -624,6 +629,9 @@ pub struct ComposeOptions {
pub label: Option<String>,
/// Anchor pages for cross-references (needed when the story contains cross-references).
pub xrefs: Option<Arc<xref::XrefIndex>>,
/// Where the story's anchored frames sit on their spreads; an anchored frame not listed is
/// composed where it is stored, on no page.
pub placed: Vec<(ItemId, Placement)>,
}
/// Nesting depth past which wrap obstacles inside groups are not collected.
@@ -656,18 +664,34 @@ fn wrap_obstacles(it: &Item, parent: Affine, inv: Affine, skip: ItemId, left_pag
/// Build the frame specs of a story from the document (geometry, wrap, page names, grid).
pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> {
frame_specs_placed(doc, sid, &[])
}
/// [`frame_specs`] with anchored frames where their host story placed them (`placed`).
pub(crate) fn frame_specs_placed(doc: &Document, sid: StoryId, placed: &[(ItemId, Placement)]) -> Vec<FrameSpec> {
let Some(st) = doc.story(sid) else { return vec![] };
let mut out = Vec::with_capacity(st.frames.len());
for &fid in &st.frames {
let loc = doc.find(fid);
let Some(item) = loc.as_ref().and_then(|l| doc.item_at(l)).or_else(|| doc.anchored_item(fid)) else { continue };
let Some(tf) = item.text_frame() else { continue };
let xf = loc.as_ref().map(|l| doc.parent_xf(l)).unwrap_or(designcraft_geom::Affine::IDENTITY) * item.xf;
// An anchored frame takes its page, page side, page rect and grid from where it is placed.
let placement = if loc.is_none() { placed.iter().find(|(id, _)| *id == fid).map(|(_, p)| *p) } else { None };
let parent = match (&loc, placement) {
(Some(l), _) => doc.parent_xf(l),
(None, Some(p)) => p.xf,
(None, None) => Affine::IDENTITY,
};
let xf = parent * item.xf;
// Where the frame is on the spread (a group's transform included), to find its page.
let center_x = parent.transform_rect_bbox(item.bounds()).center().x;
let inv = xf.inverse();
let mut exclusions = Vec::new();
let spread_ref = loc.as_ref().map(|l| l.spread);
let spread_ref = loc.as_ref().map(|l| l.spread).or(placement.map(|p| p.spread));
let spread = spread_ref.and_then(|r| doc.spread(r));
// Page items wrap the text of frames on the spread, not of anchored frames.
if !tf.options.ignore_wrap
&& loc.is_some()
&& let Some(sp) = spread
{
let left = |x: f64| sp.page_at_x(x).and_then(|pi| sp.pages.get(pi)).is_some_and(|p| p.side == designcraft_doc::PageSide::Left);
@@ -686,36 +710,45 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> {
r.is_finite() && r.x0 < a.x1 && r.x1 > a.x0 && r.y0 < a.y1 && r.y1 > a.y0
});
}
let (page_name, page, left_page) = match (spread_ref, spread) {
let (page_name, page, side) = match (spread_ref, spread) {
(Some(designcraft_doc::SpreadRef::Doc(si)), Some(sp)) => {
let c = item.bounds().center();
let pi = sp.page_at_x(c.x).unwrap_or(0);
let pi = sp.page_at_x(center_x).unwrap_or(0);
let abs = doc.first_page_of_spread(si) + pi;
(Some(doc.page_name(abs)), Some(abs), sp.pages.get(pi).is_some_and(|p| p.side == designcraft_doc::PageSide::Left))
(Some(doc.page_name(abs)), Some(abs), sp.pages.get(pi).map(|p| p.side))
}
(Some(designcraft_doc::SpreadRef::Parent(_)), Some(sp)) => {
let prefix = sp.parent.as_ref().map(|p| p.prefix.clone()).unwrap_or_else(|| "A".into());
// 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)
let under = sp.page_at_x(center_x).and_then(|pi| sp.pages.get(pi));
(Some(prefix), None, under.map(|p| p.side))
}
_ => (None, None, false),
_ => (None, None, None),
};
let left_page = side == Some(designcraft_doc::PageSide::Left);
let right_page = side == Some(designcraft_doc::PageSide::Right);
let section_marker = page.map(|abs| section_marker(doc, abs));
// The page under the frame and its margins, in inner space (bounding boxes).
let page_rect = match (spread_ref, spread) {
(_, Some(sp)) => sp
.page_at_x(item.bounds().center().x)
.and_then(|pi| sp.pages.get(pi))
.map(|pg| (inv.transform_rect_bbox(pg.bounds()), inv.transform_rect_bbox(pg.margin_rect()))),
_ => None,
};
// The page under the frame, and its bounds and margins in inner space (bounding boxes).
let under = spread.and_then(|sp| sp.page_at_x(center_x).and_then(|pi| sp.pages.get(pi)));
let page_rect = under.map(|pg| (inv.transform_rect_bbox(pg.bounds()), inv.transform_rect_bbox(pg.margin_rect())));
let g = &doc.settings.baseline_grid;
let (inc, start) = tf.options.baseline_grid.unwrap_or((g.increment, g.start));
// Grid lines are at spread y = start + n·inc (page tops are y = 0); map into inner space (translation only).
let ty = xf.translation().y;
let grid = (inc > 0.0).then_some((start - ty, inc));
let (start, inc) = match tf.options.baseline_grid {
None => (under.map_or(g.start, |pg| g.origin(pg)), g.increment),
Some(f) => {
use designcraft_doc::FrameGridRelative as R;
// The frame's own edges are in inner space; `ty` takes them to spread y as it takes
// the grid back, so a frame-relative grid keeps its place in a turned frame.
let from = match f.relative_to {
R::TopOfPage => 0.0,
R::TopMargin => under.map_or(0.0, |pg| pg.margins.top),
R::TopOfFrame => ty + item.inner_bounds().y0,
R::TopInset => ty + item.text_area().y0,
};
(from + f.start, f.increment)
}
};
let grid = (inc > 0.0 && inc.is_finite() && start.is_finite()).then_some((start - ty, inc));
// Type on a path: one line as long as the path (from the start offset).
let (area, opts) = match &tf.options.path {
Some(pt) => {
@@ -773,6 +806,7 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> {
page,
grid,
left_page,
right_page,
page_rect,
});
}
@@ -781,10 +815,23 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> {
/// Compose a story of `doc` through its frames, with the text wrap of page items and of anchored
/// objects ([`wrap`]).
/// Anchored frames are composed where their host story places them (unless `opts` places them).
pub fn compose_story(doc: &Document, sid: StoryId, opts: &ComposeOptions) -> ComposedStory {
compose_story_at(doc, sid, opts, 0)
}
/// [`compose_story`] for a story `depth` hosts down from the story asked for.
fn compose_story_at(doc: &Document, sid: StoryId, opts: &ComposeOptions, depth: usize) -> ComposedStory {
let others: Vec<ComposedStory> =
wrap::neighbours(doc, sid).into_iter().map(|o| wrap::compose_among(doc, o, &ComposeOptions::default(), &[])).collect();
wrap::compose_among(doc, sid, opts, &others.iter().collect::<Vec<_>>())
let others: Vec<&ComposedStory> = others.iter().collect();
if opts.placed.is_empty() && depth < placed::MAX_DEPTH {
let placed = placed::placements(doc, sid, &mut |host| Arc::new(compose_story_at(doc, host, &ComposeOptions::default(), depth + 1)));
if !placed.is_empty() {
return wrap::compose_among(doc, sid, &ComposeOptions { placed, ..opts.clone() }, &others);
}
}
wrap::compose_among(doc, sid, opts, &others)
}
const DEFAULT_TAB: f64 = 36.0;
@@ -861,6 +908,12 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
let mut info: Vec<ParaInfo> = Vec::with_capacity(np);
let mut force_col = vec![false; np];
let mut line_cap: Vec<Option<usize>> = vec![None; np];
// Hyphenate Across Column off: the hyphenation points (story bytes) a paragraph is re-laid
// without, because a line ending at one of them ended a column or frame. Each re-lay leaves
// out one more point of the paragraph, so they are bounded by its points; the cap only guards
// against keep options clearing them over and over.
let mut no_hyph: Vec<Vec<usize>> = vec![Vec::new(); np];
let mut hyph_relays = 0usize;
let mut restores = 0usize;
let mut pi = 0;
let var_story = vars::has_vars(story);
@@ -900,7 +953,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
&& let Some(j) = trial_failed(&mut trial, &mut limits, balance_runs < balance_budget)
{
balance_runs += 1;
rewind(j, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
rewind(j, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph);
pi = j;
continue 'paras;
}
@@ -910,7 +963,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
&& let Some(j) = balance_end(&mut trial, &mut limits, &cur, &cols, frames, &out, np, balance_runs < balance_budget)
{
balance_runs += 1;
rewind(j, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
rewind(j, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph);
pi = j;
continue 'paras;
}
@@ -964,7 +1017,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
if !table::place_table(doc, t, anchor, pi, &pp, frames, &cols, &mut cur, &mut out, opts) {
if let Some(j) = trial_failed(&mut trial, &mut limits, balance_runs < balance_budget) {
balance_runs += 1;
rewind(j, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
rewind(j, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph);
pi = j;
continue 'paras;
}
@@ -1010,9 +1063,9 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
};
if !story.endnotes.is_empty() {
// Endnote reference numbers continue through the document.
let first = doc.endnote_start(story.id) + story.text[..prange.start].matches(designcraft_doc::ENDNOTE_REF).count() as u32;
let first = doc.endnote_start(story.id).saturating_add(story.text[..prange.start].matches(designcraft_doc::ENDNOTE_REF).count() as u32);
for (k, (i, _)) in story.text[prange.clone()].match_indices(designcraft_doc::ENDNOTE_REF).enumerate() {
sub.notes.insert(prange.start + i, doc.endnote_options.label(first + k as u32));
sub.notes.insert(prange.start + i, doc.endnote_options.label(first.saturating_add(k as u32)));
}
}
if !story.objects.is_empty() {
@@ -1083,7 +1136,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
let cols_here = &cols[cur.fi];
let col = span_rect(cols_here, cols_here[cur.col.min(cols_here.len() - 1)], pp.span_columns);
let col = SplitCfg::of(&pp).map_or(col, |c| c.sub_rect(col, 0));
let spacing = spacing_for(&pp, base_chars.size);
let spacing = spacing_for(&pp, base_chars.size, story);
let mut lines: Vec<(std::ops::Range<usize>, String)> = Vec::new();
for _ in 0..3 {
// The same drop cap, spacing, hyphenation and breaker as the layout below.
@@ -1099,7 +1152,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
};
apply_desired_spacing(&mut gl, &pp);
mojikumi::apply(&mut gl, &doc.styles, &pp.mojikumi);
let hy = hyphenation_points(&story.text, &gl, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars));
let mut hy = hyphenation_points(&story.text, &gl, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars));
suppress_hyphens(&mut hy, &gl, no_hyph.get(pi).map_or(&[], Vec::as_slice));
let breaks = if pp.composer.single_line() || gl.iter().any(|g| g.ch == '\t') || gl.len() > 4000 {
let tab = |j: usize, x: f64, i: usize| {
let ind = pp.left_indent + if j == 0 { pp.first_line_indent } else { 0.0 };
@@ -1234,9 +1288,11 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
bidi::resolve_mirroring(&mut glyphs, &bidi_info);
apply_desired_spacing(&mut glyphs, &pp);
mojikumi::apply(&mut glyphs, &doc.styles, &pp.mojikumi);
let hyph_after = hyphenation_points(&story.text, &glyphs, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars));
let mut hyph_after =
hyphenation_points(&story.text, &glyphs, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars));
suppress_hyphens(&mut hyph_after, &glyphs, no_hyph.get(pi).map_or(&[], Vec::as_slice));
let base_size = base_chars.size;
let spacing = spacing_for(&pp, base_size);
let spacing = spacing_for(&pp, base_size, story);
// An empty paragraph takes its height from its paragraph mark's character format.
let mark_chars = if glyphs.is_empty() {
std::borrow::Cow::Owned(doc.styles.resolve_char(&base_chars, story.char_format_of(prange.start)))
@@ -1341,7 +1397,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
if !t.done {
trial = Some(t);
balance_runs += 1;
rewind(key.1, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
rewind(
key.1,
&snaps,
&note_snaps,
&mut notes,
&mut out,
&mut cur,
&mut list_counter,
&mut force_col,
&mut line_cap,
&mut no_hyph,
);
pi = key.1;
continue 'paras;
}
@@ -1377,6 +1444,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
let mut line_no = 0usize;
// Paragraph line number of the first line in the current column.
let mut col_first_line = 0usize;
// The story byte at which the last line set ended at an automatic hyphenation point.
let mut hyphen_at: Option<usize> = None;
let has_tabs = glyphs.iter().any(|g| g.ch == '\t');
let mut first_line_rect: Option<(usize, f64, f64, f64)> = None; // frame, baseline, ascent, x-span
let rtl = pp.direction == designcraft_doc::TextDirection::RightToLeft;
@@ -1394,7 +1463,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
if cur.fi >= frames.len() {
if let Some(j) = trial_failed(&mut trial, &mut limits, balance_runs < balance_budget) {
balance_runs += 1;
rewind(j, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
rewind(j, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph);
pi = j;
continue 'paras;
}
@@ -1621,6 +1690,17 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
0.0
};
let fits = baseline + fit_descent <= col.y1 - reserve + 0.01 && !capped;
if !fits
&& !pp.hyph_across_column
&& line_no > col_first_line
&& let Some(at) = hyphen_at
&& hyph_relays < story.text.len().saturating_add(64).saturating_mul(restores.saturating_add(1))
{
hyph_relays += 1;
no_hyph[pi].push(at);
rewind(pi, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph);
continue 'paras;
}
if !fits {
if cur.can_next_sub() {
g0 = s;
@@ -1642,7 +1722,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
};
if let Some(j) = j {
split_runs += 1;
rewind(j, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
rewind(
j,
&snaps,
&note_snaps,
&mut notes,
&mut out,
&mut cur,
&mut list_counter,
&mut force_col,
&mut line_cap,
&mut no_hyph,
);
pi = j;
continue 'paras;
}
@@ -1675,7 +1766,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
j
}
};
rewind(j, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
rewind(
j,
&snaps,
&note_snaps,
&mut notes,
&mut out,
&mut cur,
&mut list_counter,
&mut force_col,
&mut line_cap,
&mut no_hyph,
);
pi = j;
continue 'paras;
}
@@ -1787,7 +1889,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
if notes.earlier_columns_collide(&out.frames[cur.fi].lines, cur.fi, cur.col, &cols[cur.fi], r)
&& let Some(j) = out.frames[cur.fi].lines.first().map(|l| l.para)
{
rewind(j, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
rewind(
j,
&snaps,
&note_snaps,
&mut notes,
&mut out,
&mut cur,
&mut list_counter,
&mut force_col,
&mut line_cap,
&mut no_hyph,
);
pi = j;
continue 'paras;
}
@@ -1819,6 +1932,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
cur.last_descent = desc;
cur.pending = 0.0;
cur.grid_next = grid_end.map(|end| (baseline, end));
hyphen_at =
e.checked_sub(1).filter(|&i| b.hyphen && hyph_after.get(i) == Some(&true)).and_then(|i| glyphs.get(i)).map(|g| g.byte + g.len);
line_no += 1;
// A column / frame / page break inside the paragraph: the rest of it continues in the
// new column/frame/page, re-broken there. (A break that ends the paragraph moves the
@@ -1946,7 +2061,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
if !t.done {
split_trial = Some(t);
split_runs += 1;
rewind(key.2, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
rewind(
key.2,
&snaps,
&note_snaps,
&mut notes,
&mut out,
&mut cur,
&mut list_counter,
&mut force_col,
&mut line_cap,
&mut no_hyph,
);
pi = key.2;
continue 'paras;
}
@@ -2004,6 +2130,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
object_point,
ref_point,
keep_within_column,
spine_relative,
} => {
use designcraft_doc::anchored::AnchorRelative as R;
let col = ft.columns.get(l.column as usize).copied().unwrap_or(f.area);
@@ -2015,15 +2142,21 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
R::PageEdge => Some(f.page_rect.map_or(f.area, |p| p.0)),
};
let frac = |i: u8| ([0.0, 0.5, 1.0][(i % 3) as usize], [0.0, 0.5, 1.0][(i / 3).min(2) as usize]);
let (rfx, rfy) = frac(*ref_point);
let (ofx, ofy) = frac(*object_point);
let (mut rfx, rfy) = frac(*ref_point);
let (mut ofx, ofy) = frac(*object_point);
let mut dx = *x_offset;
// The settings describe a left-hand page; a right-hand page mirrors them
// across the spine.
if *spine_relative && f.right_page {
(rfx, ofx, dx) = (1.0 - rfx, 1.0 - ofx, -dx);
}
let rx = area(*x_relative).map_or(o.x, |a| a.x0 + a.width() * rfx);
let ry = area(*y_relative).map_or(l.baseline, |a| a.y0 + a.height() * rfy);
let mut y = ry + y_offset - h * ofy;
if *keep_within_column {
y = y.clamp(col.y0, (col.y1 - h).max(col.y0));
}
Point::new(rx + x_offset - w * ofx, y)
Point::new(rx + dx - w * ofx, y)
}
};
}
@@ -2170,8 +2303,22 @@ pub fn column_rule_rects(columns: &[Rect], lines: &[Line], opts: &TextFrameOptio
out
}
/// Optical margin alignment for a paragraph of `story`: (on, the size the hang is set for).
/// Ignore Optical Margin turns it off; the story's setting, when on, applies to every other
/// paragraph; otherwise the paragraph's own switch hangs at each glyph's own size.
fn optical_for(pp: &ParaProps, story: &Story) -> (bool, Option<f64>) {
if pp.ignore_optical_margin {
return (false, None);
}
match story.optical_margin.hang_size() {
Some(size) => (true, Some(size)),
None => (pp.optical_margin, None),
}
}
/// Breaker parameters from the paragraph's settings.
fn spacing_for(pp: &ParaProps, base_size: f64) -> Spacing {
fn spacing_for(pp: &ParaProps, base_size: f64, story: &Story) -> Spacing {
let (optical, optical_size) = optical_for(pp, story);
Spacing {
justify: pp.align.is_justified(),
word_min: pp.word_space_min,
@@ -2189,7 +2336,8 @@ fn spacing_for(pp: &ParaProps, base_size: f64) -> Spacing {
// The zone is the Single-line Composer's (unjustified text only), as in InDesign: the
// Paragraph Composer weighs every break of the paragraph instead.
hyph_zone: if pp.align.is_justified() || !pp.composer.single_line() { 0.0 } else { pp.hyph_zone },
optical: pp.optical_margin,
optical,
optical_size,
korean_char_breaks: pp.korean_char_breaks,
kinsoku_priority: match pp.kinsoku_type.as_str() {
"KinsokuPushInFirst" => 1,
@@ -2304,9 +2452,11 @@ fn rewind(
list_counter: &mut ListCounter,
force_col: &mut [bool],
line_cap: &mut [Option<usize>],
no_hyph: &mut [Vec<usize>],
) {
force_col.iter_mut().skip(j + 1).for_each(|f| *f = false);
line_cap.iter_mut().skip(j + 1).for_each(|c| *c = None);
no_hyph.iter_mut().skip(j + 1).for_each(Vec::clear);
if let Some(s) = snaps.get(j) {
s.restore(out, cur, list_counter);
}
@@ -3313,8 +3463,8 @@ fn layout_line(
if (sp.optical || pp.kinsoku_hang != designcraft_doc::cjk::KinsokuHang::None) && !has_tab {
let first = line.iter().find(|g| g.adv > 0.0 && !g.is_space());
let lastg = line.iter().rev().find(|g| g.adv > 0.0 && !g.is_space());
let hl = if sp.optical { first.map_or(0.0, |g| breaker::hang(g.ch).0 * g.adv) } else { 0.0 };
let optical_right = if sp.optical { lastg.map_or(0.0, |g| breaker::hang(g.ch).1 * g.adv) } else { 0.0 };
let hl = first.map_or(0.0, |g| breaker::hang_left(g, sp));
let optical_right = lastg.map_or(0.0, |g| breaker::hang_right(g, sp));
let cjk_right = lastg.map_or(0.0, |g| g.cjk_hang);
let cjk_right = if pp.kinsoku_hang == designcraft_doc::cjk::KinsokuHang::Regular {
cjk_right.min((line.iter().map(|g| g.adv).sum::<f64>() - measure).max(0.0))
@@ -4006,6 +4156,18 @@ fn foreign_ranges(
.collect()
}
/// Leave out the hyphenation points after the glyphs that end at story bytes `at`.
fn suppress_hyphens(hy: &mut [bool], glyphs: &[Glyph], at: &[usize]) {
if at.is_empty() {
return;
}
for (h, g) in hy.iter_mut().zip(glyphs) {
if *h && at.contains(&(g.byte + g.len)) {
*h = false;
}
}
}
fn hyphenation_points(
text: &str,
glyphs: &[Glyph],
@@ -4026,7 +4188,7 @@ fn hyphenation_points(
// Words repeat a lot: remember their points per set of limits.
static CACHE: Mutex<Vec<(LimitsKey, HashMap<(hyphen::Lang, Box<str>), Box<[usize]>>)>> = Mutex::new(Vec::new());
let key = (lim.min_word, lim.after_first, lim.before_last, lim.capitalized);
let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner());
let mut guard = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let idx = match guard.iter().position(|e| e.0 == key) {
Some(i) => i,
None => {
@@ -4527,7 +4689,7 @@ pub fn adjacent_row(l: &Line, x: f64, caret_y: f64, up: bool) -> Option<f64> {
// count as still inside the note.
let over = |r: &(f64, f64, f64)| x >= r.1 - 0.01 && x < r.2 - 0.01;
let candidates = rows.iter().filter(|r| over(r)).filter(|r| if up { r.0 < caret_y - ROW_Y } else { r.0 > caret_y + ROW_Y });
if up { candidates.map(|r| r.0).max_by(|a, b| a.total_cmp(b)) } else { candidates.map(|r| r.0).min_by(|a, b| a.total_cmp(b)) }
if up { candidates.map(|r| r.0).max_by(f64::total_cmp) } else { candidates.map(|r| r.0).min_by(f64::total_cmp) }
}
#[cfg(test)]
@@ -4649,6 +4811,7 @@ mod column_allocation_safety_tests {
page: None,
grid: None,
left_page: false,
right_page: false,
page_rect: None,
outline: None,
}
+6 -3
View File
@@ -72,7 +72,7 @@ pub(crate) struct Notes {
pub key: Option<usize>,
pub placed: Vec<Placed>,
/// Composed footnote text by (index, label, width).
cache: HashMap<(usize, String, u64), (Arc<ComposedStory>, NoteMetrics)>,
cache: HashMap<(usize, String, u64, bool, bool), (Arc<ComposedStory>, NoteMetrics)>,
notes: Vec<Arc<designcraft_doc::Footnote>>,
/// Footnotes span the columns of their frame.
span: bool,
@@ -130,7 +130,7 @@ impl Notes {
self.num = o.start_at;
}
self.numbers[k] = self.num;
self.num += 1;
self.num = self.num.saturating_add(1);
sub.notes.insert(self.refs[k], o.label(self.numbers[k], false));
}
sub.note_position = o.ref_position;
@@ -153,7 +153,8 @@ impl Notes {
fn compose(&mut self, doc: &Document, k: usize, w: f64, f: &FrameSpec, opts: &ComposeOptions) -> (Arc<ComposedStory>, NoteMetrics) {
let o = &doc.footnote_options;
let label = format!("{}{}", o.label(self.numbers[k], true), o.separator);
let key = (k, label.clone(), w.to_bits());
// The page side sets towards/away-from-spine alignment and spine-relative anchored objects.
let key = (k, label.clone(), w.to_bits(), f.left_page, f.right_page);
if let Some(c) = self.cache.get(&key) {
return c.clone();
}
@@ -169,6 +170,7 @@ impl Notes {
page: f.page,
grid: None,
left_page: f.left_page,
right_page: f.right_page,
page_rect: None,
};
let nopts = ComposeOptions {
@@ -178,6 +180,7 @@ impl Notes {
running: opts.running.clone(),
label: Some(label),
xrefs: opts.xrefs.clone(),
placed: Vec::new(),
};
let cs = crate::compose(doc, &self.notes[k].text, std::slice::from_ref(&spec), &nopts);
let lines = cs.frames.first().map(|ft| ft.lines.as_slice()).unwrap_or_default();
+77 -9
View File
@@ -9,9 +9,42 @@ use designcraft_doc::{GrepStyle, NestedStyle, NestedUntil};
use crate::grep::{Grep, GrepOptions};
/// Patterns kept compiled at once. A document's GREP styles are few; the cache grew without
/// bound across every pattern a session ever composed (#1598).
const MAX_CACHED_PATTERNS: usize = 64;
thread_local! {
/// Compiled GREP styles by pattern; None for one that doesn't compile or gave up on a search.
static PATTERNS: RefCell<HashMap<String, Option<Grep>>> = RefCell::new(HashMap::new());
/// Compiled GREP styles by pattern, with the tick they were last used at; `None` for one that
/// doesn't compile or gave up on a search. Bounded to [`MAX_CACHED_PATTERNS`], least recently
/// used evicted first.
static PATTERNS: RefCell<HashMap<String, (Option<Grep>, u64)>> = RefCell::new(HashMap::new());
/// Counts cache uses, so the least recently used entry can be found.
static TICK: RefCell<u64> = const { RefCell::new(0) };
}
/// The compiled pattern for `pattern`, compiling it on first use and evicting the least recently
/// used entry once the cache is full. `f` is given `None` when the pattern does not compile.
fn with_pattern<R>(pattern: &str, f: impl FnOnce(&mut Option<Grep>) -> R) -> R {
let now = TICK.with(|t| {
let mut t = t.borrow_mut();
*t += 1;
*t
});
PATTERNS.with(|p| {
let mut p = p.borrow_mut();
if !p.contains_key(pattern)
&& p.len() >= MAX_CACHED_PATTERNS
&& let Some(oldest) = p.iter().min_by_key(|(_, (_, used))| *used).map(|(k, _)| k.clone())
{
p.remove(&oldest);
}
let slot = p.entry(pattern.to_string()).or_insert_with(|| {
let re = Grep::new(pattern, GrepOptions::default()).map_err(|e| log::warn!("GREP style {pattern:?} skipped: {e}")).ok();
(re, now)
});
slot.1 = now;
f(&mut slot.0)
})
}
fn is_event(u: &NestedUntil, c: char, prev: Option<char>, next: Option<char>) -> bool {
@@ -71,14 +104,15 @@ pub(crate) fn overlays(text: &str, range: Range<usize>, nested: &[NestedStyle],
if g.style.is_empty() || g.pattern.is_empty() {
continue;
}
PATTERNS.with(|p| {
let mut p = p.borrow_mut();
let slot = p.entry(g.pattern.clone()).or_insert_with(|| {
Grep::new(&g.pattern, GrepOptions::default()).map_err(|e| log::warn!("GREP style {:?} skipped: {e}", g.pattern)).ok()
});
with_pattern(&g.pattern, |slot| {
let Some(re) = slot else { return };
match re.find_all(text.get(range.start..end).unwrap_or(""), usize::MAX) {
Ok(ms) => out.extend(ms.into_iter().filter(|m| !m.is_empty()).map(|m| (range.start + m.start..range.start + m.end, g.style.clone()))),
match re.find_nonempty(text.get(range.start..end).unwrap_or(""), crate::grep::MAX_MATCHES_PER_PARAGRAPH) {
Ok(ms) => {
if ms.len() == crate::grep::MAX_MATCHES_PER_PARAGRAPH {
log::warn!("GREP style {:?} matched more than {} times in one paragraph; the rest are not styled", g.pattern, ms.len());
}
out.extend(ms.into_iter().filter(|m| !m.is_empty()).map(|m| (range.start + m.start..range.start + m.end, g.style.clone())));
}
Err(e) => {
log::warn!("GREP style {:?} disabled: {e}", g.pattern);
*slot = None;
@@ -93,6 +127,40 @@ pub(crate) fn overlays(text: &str, range: Range<usize>, nested: &[NestedStyle],
mod tests {
use super::*;
/// A pattern that can match nothing still styles a paragraph longer than the cap: empty
/// matches don't count toward it.
#[test]
fn empty_matches_do_not_use_up_the_grep_cap() {
let text = format!("{}a", "b".repeat(crate::grep::MAX_MATCHES_PER_PARAGRAPH + 100));
let o = overlays(&text, 0..text.len(), &[], &[GrepStyle { style: "A".into(), pattern: "a*".into() }], 0);
assert_eq!(o.len(), 1, "the one non-empty match at the end is styled");
}
/// A GREP style's scan is bounded two ways (#1598): the matches one paragraph can take, and
/// the patterns kept compiled at once.
#[test]
fn a_grep_scan_and_the_pattern_cache_are_bounded() {
// More matches in one paragraph than the cap: the scan stops with what it has instead of
// paying the backtrack limit again for each one.
let text = "a ".repeat(crate::grep::MAX_MATCHES_PER_PARAGRAPH + 500);
let o = overlays(&text, 0..text.len(), &[], &[GrepStyle { style: "A".into(), pattern: "a".into() }], 0);
assert_eq!(o.len(), crate::grep::MAX_MATCHES_PER_PARAGRAPH, "the scan stops at the cap");
// The cache holds at most MAX_CACHED_PATTERNS, and the one just used is not the one
// dropped: a document's own styles stay compiled however many patterns a session saw.
PATTERNS.with(|p| p.borrow_mut().clear());
for i in 0..MAX_CACHED_PATTERNS + 20 {
let pattern = format!("x{i}y");
overlays("x1y", 0..3, &[], &[GrepStyle { style: "S".into(), pattern }], 0);
}
let (len, has_last) = PATTERNS.with(|p| {
let p = p.borrow();
(p.len(), p.contains_key(&format!("x{}y", MAX_CACHED_PATTERNS + 19)))
});
assert_eq!(len, MAX_CACHED_PATTERNS, "the cache is bounded");
assert!(has_last, "the most recently used pattern is still cached");
}
#[test]
fn nested_and_grep_ranges() {
let text = "Q: what is 42? A drop of 7 words.\n".to_string();
+43 -32
View File
@@ -122,6 +122,45 @@ pub(crate) fn draw(out: &mut ComposedStory, boxes: &[Option<ParaBox>], frames: &
}
}
/// The shading box for one part of a paragraph run, if it has any area.
fn shading_deco(s: &Shading, part: &Part<'_>, frames: &[FrameSpec], first_part: bool, last_part: bool) -> Option<Deco> {
let r = inner_rect(&part.lines, s.width, s.top, s.bottom)?;
let [t, l, bo, rr] = s.offsets;
let mut rect = Rect::new(r.x0 - l, r.y0 - t, r.x1 + rr, r.y1 + bo);
if s.clip
&& let Some(f) = frames.get(part.fi)
{
rect = rect.intersect(frame_rect(f));
}
if rect.width() > 0.0 && rect.height() > 0.0 {
// Corners stay square where the box is cut by a split.
let corners = cut_corners(s.corners, !first_part, !last_part);
let path = (!corners.iter().all(Corner::is_none)).then(|| box_outline(rect, &corners, [true; 4]));
return Some(Deco { rect, color: s.color.clone(), tint: s.tint, path, nonprinting: s.nonprinting });
}
None
}
/// The border strokes (and plain edges) for one part of a paragraph run, pushed onto `decos`.
fn border_decos(bd: &Border, part: &Part<'_>, first_part: bool, last_part: bool, decos: &mut Vec<(usize, bool, Deco)>) {
let Some(r) = inner_rect(&part.lines, bd.width, bd.top, bd.bottom) else { return };
let [t, l, bo, rr] = bd.offsets;
let o = Rect::new(r.x0 - l, r.y0 - t, r.x1 + rr, r.y1 + bo);
let open_top = !first_part && !bd.display_if_splits;
let open_bottom = !last_part && !bd.display_if_splits;
for (path, gap) in border_paths(bd, o, open_top, open_bottom) {
let (color, tint) = if gap { (bd.gap_color.clone(), bd.gap_tint) } else { (bd.color.clone(), bd.tint) };
decos.push((part.fi, false, Deco { rect: path.bounding_box(), color, tint, path: Some(path), nonprinting: false }));
}
if is_plain(bd, open_top, open_bottom) {
decos.extend(
plain_edges(bd, o, open_top, open_bottom)
.into_iter()
.map(|rect| (part.fi, false, Deco { rect, color: bd.color.clone(), tint: bd.tint, path: None, nonprinting: false })),
);
}
}
/// The decorations of paragraphs `paras` sharing box `b`: (frame, drawn under the rest, deco).
fn group_decos(out: &ComposedStory, paras: std::ops::RangeInclusive<usize>, b: &ParaBox, frames: &[FrameSpec]) -> Vec<(usize, bool, Deco)> {
let mut parts: Vec<Part<'_>> = Vec::new();
@@ -136,40 +175,12 @@ fn group_decos(out: &ComposedStory, paras: std::ops::RangeInclusive<usize>, b: &
for (k, part) in parts.iter().enumerate() {
let (first_part, last_part) = (k == 0, k + 1 == n);
if let Some(s) = &b.shading
&& let Some(r) = inner_rect(&part.lines, s.width, s.top, s.bottom)
&& let Some(deco) = shading_deco(s, part, frames, first_part, last_part)
{
let [t, l, bo, rr] = s.offsets;
let mut rect = Rect::new(r.x0 - l, r.y0 - t, r.x1 + rr, r.y1 + bo);
if s.clip
&& let Some(f) = frames.get(part.fi)
{
rect = rect.intersect(frame_rect(f));
}
if rect.width() > 0.0 && rect.height() > 0.0 {
// Corners stay square where the box is cut by a split.
let corners = cut_corners(s.corners, !first_part, !last_part);
let path = (!corners.iter().all(Corner::is_none)).then(|| box_outline(rect, &corners, [true; 4]));
decos.push((part.fi, true, Deco { rect, color: s.color.clone(), tint: s.tint, path, nonprinting: s.nonprinting }));
}
decos.push((part.fi, true, deco));
}
if let Some(bd) = &b.border
&& let Some(r) = inner_rect(&part.lines, bd.width, bd.top, bd.bottom)
{
let [t, l, bo, rr] = bd.offsets;
let o = Rect::new(r.x0 - l, r.y0 - t, r.x1 + rr, r.y1 + bo);
let open_top = !first_part && !bd.display_if_splits;
let open_bottom = !last_part && !bd.display_if_splits;
for (path, gap) in border_paths(bd, o, open_top, open_bottom) {
let (color, tint) = if gap { (bd.gap_color.clone(), bd.gap_tint) } else { (bd.color.clone(), bd.tint) };
decos.push((part.fi, false, Deco { rect: path.bounding_box(), color, tint, path: Some(path), nonprinting: false }));
}
if is_plain(bd, open_top, open_bottom) {
decos.extend(
plain_edges(bd, o, open_top, open_bottom)
.into_iter()
.map(|rect| (part.fi, false, Deco { rect, color: bd.color.clone(), tint: bd.tint, path: None, nonprinting: false })),
);
}
if let Some(bd) = &b.border {
border_decos(bd, part, first_part, last_part, &mut decos);
}
}
decos
+84
View File
@@ -0,0 +1,84 @@
//! Where anchored text frames sit on their spread.
//!
//! An anchored frame is not a spread item: its host story's composition places it on a line. Its
//! own story is composed against that placement (page, page side, page rect, baseline grid), so
//! the host story is composed first.
use std::sync::Arc;
use designcraft_doc::{Document, Item, ItemId, SpreadRef, StoryId};
use designcraft_geom::Affine;
use crate::{ComposedStory, FrameText};
/// Hosts nested deeper than this (a note anchored in a note anchored in …, or stories anchored in
/// each other in a cycle) are composed where they are stored.
pub(crate) const MAX_DEPTH: usize = 8;
/// An anchored item's parent space on its spread, where its host story's composition placed it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Placement {
pub spread: SpreadRef,
/// The item's parent space → spread space.
pub xf: Affine,
}
/// The placements of `sid`'s anchored frames, from their host stories composed by `host`.
pub(crate) fn placements(doc: &Document, sid: StoryId, host: &mut dyn FnMut(StoryId) -> Arc<ComposedStory>) -> Vec<(ItemId, Placement)> {
let Some(st) = doc.story(sid) else { return Vec::new() };
let mut anchored = None;
let mut out = Vec::new();
for &fid in &st.frames {
if doc.find(fid).is_some() {
continue;
}
let items = anchored.get_or_insert_with(|| doc.anchored_items());
// A frame anchored in its own story would need its own composition to place it.
let Some(owner) = items.iter().find_map(|(owner, it)| (it.id == fid).then_some(*owner)).filter(|o| *o != sid) else { continue };
if let Some(p) = locate(doc, &host(owner), fid) {
out.push((fid, p));
}
}
out
}
/// Where `cs` (a host story's composition) places item `fid`: in its frames' anchored objects,
/// footnotes and table cells.
fn locate(doc: &Document, cs: &ComposedStory, fid: ItemId) -> Option<Placement> {
cs.frames.iter().find_map(|ft| {
let loc = doc.find(ft.frame)?;
let frame = doc.item_at(&loc)?;
let xf = in_frame(ft, doc.parent_xf(&loc) * doc.text_xf(frame), fid, 0)?;
Some(Placement { spread: loc.spread, xf })
})
}
/// The parent space of `fid` among the objects of `ft`, whose text space maps to the spread by
/// `xf`.
fn in_frame(ft: &FrameText, xf: Affine, fid: ItemId, depth: usize) -> Option<Affine> {
if depth > MAX_DEPTH {
return None;
}
let objects = ft.objects.iter().find_map(|o| in_item(&o.object.item, xf * Affine::translate(o.origin.to_vec2()), fid, 0));
objects
.or_else(|| ft.notes.iter().find_map(|n| in_frame(n.text.frames.first()?, xf * Affine::translate(n.origin.to_vec2()), fid, depth + 1)))
.or_else(|| {
ft.tables
.iter()
.flat_map(|t| &t.cells)
.find_map(|c| in_frame(c.text.frames.first()?, xf * Affine::translate(c.origin.to_vec2()), fid, depth + 1))
})
}
/// The parent space of `fid` in `it` (it or a group member), `it`'s parent space mapping to the
/// spread by `parent`.
fn in_item(it: &Item, parent: Affine, fid: ItemId, depth: usize) -> Option<Affine> {
if it.id == fid {
return Some(parent);
}
if depth > crate::MAX_WRAP_DEPTH {
return None;
}
let xf = parent * it.xf;
it.children().iter().find_map(|c| in_item(c, xf, fid, depth + 1))
}
+31 -25
View File
@@ -71,6 +71,36 @@ fn tcy_char(c: char, roman: bool) -> bool {
c.is_ascii_digit() || (roman && c.is_ascii_alphabetic())
}
/// Tate-chu-yoko in vertical ruby: each short run of digits (and Latin letters) is set in one
/// ruby em across the line, scaled down to fit when the spec asks for it.
fn apply_tcy(glyphs: &mut [RubyGlyph], spec: &RubySpec, size: f64) {
let roman = spec.auto_tcy_include_roman;
let mut i = 0;
while i < glyphs.len() {
let j = i + glyphs.get(i..).unwrap_or_default().iter().take_while(|g| tcy_char(g.ch, roman)).count();
if j == i {
i += 1;
continue;
}
if u32::try_from(j - i).is_ok_and(|n| n <= spec.auto_tcy_digits)
&& let Some(run) = glyphs.get_mut(i..j)
{
let total: f64 = run.iter().map(|g| g.adv).sum();
let f = if spec.auto_tcy_auto_scale && total > size && total > 0.0 { size / total } else { 1.0 };
let mut across = -total * f / 2.0;
for (n, g) in run.iter_mut().enumerate() {
g.tcy = Some([size / 2.0, across + g.x * f, size]);
across += g.adv * f;
g.sx *= f;
g.x = 0.0;
g.adv = if n == 0 { size } else { 0.0 };
g.joined = n > 0;
}
}
i = j;
}
}
/// Shape `text` as the ruby of parent text `parent_size` points big.
fn shape_ruby(db: &ScopedFonts<'_>, parent: FaceRef, text: &str, spec: &RubySpec, parent_size: f64, fallback: bool, vertical: bool) -> Shaped {
let size = spec.size_for(parent_size);
@@ -96,31 +126,7 @@ fn shape_ruby(db: &ScopedFonts<'_>, parent: FaceRef, text: &str, spec: &RubySpec
.collect();
// Tate-chu-yoko: short runs of digits (and Latin letters) take one ruby em along the line.
if vertical && spec.auto_tcy_digits > 0 {
let roman = spec.auto_tcy_include_roman;
let mut i = 0;
while i < glyphs.len() {
let j = i + glyphs.get(i..).unwrap_or_default().iter().take_while(|g| tcy_char(g.ch, roman)).count();
if j == i {
i += 1;
continue;
}
if u32::try_from(j - i).is_ok_and(|n| n <= spec.auto_tcy_digits)
&& let Some(run) = glyphs.get_mut(i..j)
{
let total: f64 = run.iter().map(|g| g.adv).sum();
let f = if spec.auto_tcy_auto_scale && total > size && total > 0.0 { size / total } else { 1.0 };
let mut across = -total * f / 2.0;
for (n, g) in run.iter_mut().enumerate() {
g.tcy = Some([size / 2.0, across + g.x * f, size]);
across += g.adv * f;
g.sx *= f;
g.x = 0.0;
g.adv = if n == 0 { size } else { 0.0 };
g.joined = n > 0;
}
}
i = j;
}
apply_tcy(&mut glyphs, spec, size);
}
// Pen positions: a joined glyph sits at its group's start.
let mut pen = 0.0;
+2 -2
View File
@@ -950,10 +950,10 @@ static WORD_CACHE: RwLock<Vec<(WordKey, Arc<RwLock<WordMap>>)>> = RwLock::new(Ve
const WORD_CACHE_MAX: usize = 50_000;
fn word_map(key: WordKey) -> Arc<RwLock<WordMap>> {
if let Some((_, m)) = WORD_CACHE.read().unwrap_or_else(|e| e.into_inner()).iter().find(|e| e.0 == key) {
if let Some((_, m)) = WORD_CACHE.read().unwrap_or_else(std::sync::PoisonError::into_inner).iter().find(|e| e.0 == key) {
return m.clone();
}
let mut w = WORD_CACHE.write().unwrap_or_else(|e| e.into_inner());
let mut w = WORD_CACHE.write().unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some((_, m)) = w.iter().find(|e| e.0 == key) {
return m.clone();
}
+27 -22
View File
@@ -121,7 +121,7 @@ impl CellLineExtent {
}
/// Compose a cell story at width `w` (content space, top-left at the origin).
fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, left_page: bool) -> CellLayout {
fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, (left_page, right_page): (bool, bool)) -> CellLayout {
let spec = FrameSpec {
id: ItemId(0),
area: Rect::new(0.0, 0.0, w.max(1.0), 1.0e6),
@@ -134,6 +134,7 @@ fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, left
page: opts.page,
grid: None,
left_page,
right_page,
page_rect: None,
};
let cs = crate::compose(doc, &cell.text, std::slice::from_ref(&spec), opts);
@@ -200,7 +201,7 @@ pub(crate) fn place_table(
let (nr, nc) = (table.nrows(), table.ncols());
let owners = table.owners();
let is_owner = |r: usize, c: usize| owners[r * nc + c] == (r, c);
let left_page = frames[cur.fi].left_page;
let side = (frames[cur.fi].left_page, frames[cur.fi].right_page);
// Compose every owner cell.
let mut layouts: Vec<Option<CellLayout>> = Vec::with_capacity(nr * nc);
for r in 0..nr {
@@ -212,7 +213,7 @@ pub(crate) fn place_table(
let cell = &table.cells[r * nc + c];
let cs = (cell.col_span.max(1) as usize).min(nc - c);
let w = colx[c + cs] - colx[c] - cell.insets[1] - cell.insets[3];
layouts.push(Some(compose_cell(doc, cell, w, opts, left_page)));
layouts.push(Some(compose_cell(doc, cell, w, opts, side)));
}
}
// Row heights.
@@ -342,6 +343,26 @@ pub(crate) fn place_table(
true
}
/// A cell's text cut to the lines that fit `clip_height`, with where the overset text begins.
/// Uses the same baseline/bounds test as auto-growth, and retains a prefix: overset text cannot
/// reappear after a line that did not fit.
fn clip_overset(lay: &CellLayout, clip_height: f64) -> Arc<ComposedStory> {
let Some(f) = lay.text.frames.first() else { return lay.text.clone() };
let keep = lay.line_heights.iter().take_while(|h| **h <= clip_height + 0.01).count();
let overset_at = f.lines.get(keep).map(|l| l.range.start);
let mut t = (*lay.text).clone();
if let Some(frame) = t.frames.first_mut() {
frame.lines.truncate(keep);
frame.objects.retain(|o| o.line < keep);
frame.tables.retain(|table| table.line < keep);
frame.range.end = frame.lines.last().map_or(frame.range.start, |l| l.range.end);
}
if let Some(at) = overset_at {
t.overset_at = Some(at);
}
Arc::new(t)
}
/// Build one fragment from its rows and push it (with its glyph-less line) to the current frame.
#[allow(clippy::too_many_arguments)]
fn emit(
@@ -406,24 +427,7 @@ fn emit(
_ => 0.0,
};
let overset = space < -0.01 || lay.text.is_overset();
let mut text = lay.text.clone();
if overset && let Some(f) = text.frames.first() {
// Use the same baseline/bounds test as auto-growth, and retain a prefix:
// overset text cannot reappear after a line that did not fit.
let keep = lay.line_heights.iter().take_while(|h| **h <= clip.height() + 0.01).count();
let overset_at = f.lines.get(keep).map(|l| l.range.start);
let mut t = (*text).clone();
if let Some(frame) = t.frames.first_mut() {
frame.lines.truncate(keep);
frame.objects.retain(|o| o.line < keep);
frame.tables.retain(|table| table.line < keep);
frame.range.end = frame.lines.last().map_or(frame.range.start, |l| l.range.end);
}
if let Some(at) = overset_at {
t.overset_at = Some(at);
}
text = Arc::new(t);
}
let text = if overset { clip_overset(lay, clip.height()) } else { lay.text.clone() };
cells.push(PlacedCell {
row: r,
col: c,
@@ -813,7 +817,7 @@ mod height_tests {
}
fn measure(cell: &Cell, width: f64) -> CellLayout {
compose_cell(&Document::new(&NewDocument::default()), cell, width, &ComposeOptions::default(), false)
compose_cell(&Document::new(&NewDocument::default()), cell, width, &ComposeOptions::default(), (false, false))
}
fn compose_table(table: Table, height: f64) -> ComposedStory {
@@ -1061,6 +1065,7 @@ mod height_tests {
object_point: 0,
ref_point: 0,
keep_within_column: false,
spine_relative: false,
}),
);
assert!((measure(&cell, 92.0).content_h - original).abs() < 1e-6);
+464 -2
View File
@@ -1013,6 +1013,92 @@ fn hyphenation_zone_limits_ragged_hyphens() {
assert!(free > 0 && zoned == free && huge == free, "{c:?}: {free} {zoned} {huge}");
}
#[test]
fn hyphenate_across_column_off_ends_no_column_with_a_hyphen() {
let text = CORPUS.join(" ");
// Column-end lines that are hyphenated, over frame heights that end the first column at each
// line in turn.
let ends = |across: bool, composer: designcraft_doc::Composer| {
let mut ends = 0;
let mut lines = 0;
for h in (30..400).step_by(3) {
let para = ParaAttrs {
align: Some(Align::Left),
hyph_weight: Some(0.0),
hyph_across_column: Some(across),
composer: Some(composer),
..Default::default()
};
let (mut d, sid, fid) = doc_with(&text, Rect::new(0.0, 0.0, 240.0, h as f64), para);
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2;
let cs = compose_story(&d, sid, &ComposeOptions::default());
let col0: Vec<&Line> = all_lines(&cs).into_iter().filter(|l| l.column == 0).collect();
ends += usize::from(col0.last().is_some_and(|l| l.hyphenated));
lines += col0.len();
// The frame's last line too, when the text goes on (overset).
let last = all_lines(&cs).into_iter().last();
ends += usize::from(cs.is_overset() && last.is_some_and(|l| l.hyphenated && !l.last_in_para));
}
(ends, lines)
};
for c in [designcraft_doc::Composer::Paragraph, designcraft_doc::Composer::SingleLine] {
let (on, lines_on) = ends(true, c);
let (off, lines_off) = ends(false, c);
// With it on, columns do end in hyphens here; with it off, none does, and the columns
// still fill.
assert!(on > 3, "{c:?}: {on}");
assert_eq!(off, 0, "{c:?}");
assert!(lines_off * 10 >= lines_on * 9, "{c:?}: {lines_off} {lines_on}");
}
}
#[test]
fn hyphenate_across_column_off_keeps_nested_line_styles_on_their_lines() {
// The first line in a character style of another colour (the same widths): when its hyphen is
// left out at a column end, the style still covers exactly the first line as set.
let text = CORPUS.join(" ");
let compose = |w: f64, h: f64, across: bool| {
let para = ParaAttrs {
align: Some(Align::Left),
hyph_weight: Some(0.0),
hyph_across_column: Some(across),
nested_line_styles: Some(vec![designcraft_doc::NestedLineStyle { style: "Lead".into(), lines: 1 }]),
..Default::default()
};
let (mut d, sid, fid) = doc_with(&text, Rect::new(0.0, 0.0, w, h), para);
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2;
let chars = designcraft_doc::CharAttrs { fill: Some("[Registration]".into()), ..Default::default() };
std::sync::Arc::make_mut(&mut d.styles).character.push(designcraft_doc::CharacterStyle {
name: "Lead".into(),
based_on: None,
chars,
shortcut: String::new(),
});
compose_story(&d, sid, &ComposeOptions::default())
};
let mut checked = 0;
for w in (160..320).step_by(10) {
for h in (12..40).step_by(2) {
// Where, with the setting on, a hyphenated first line ends column 1.
let on = compose(w as f64, h as f64, true);
let lines = all_lines(&on);
if !(lines.iter().filter(|l| l.column == 0).count() == 1 && lines[0].hyphenated) {
continue;
}
let cs = compose(w as f64, h as f64, false);
let lines = all_lines(&cs);
assert!(!lines[0].hyphenated, "{w}×{h}");
let lead = |l: &Line| {
l.glyphs.iter().filter(|g| g.len > 0 && g.visible).map(|g| cs.styles[g.style as usize].fill == "[Registration]").collect::<Vec<_>>()
};
assert!(lead(lines[0]).iter().all(|&r| r), "{w}×{h}: first line");
assert!(lead(lines[1]).iter().all(|&r| !r), "{w}×{h}: second line");
checked += 1;
}
}
assert!(checked > 3, "{checked}");
}
#[test]
fn discretionary_hyphen_replaces_automatic_points() {
let text = "aaaa bbbb cccc extraordi\u{AD}narily dddd eeee";
@@ -1150,7 +1236,7 @@ fn nonbreaking_space_alone_fills_a_justified_line() {
/// Story of `n` one-line filler paragraphs followed by `extra` paragraphs, in a 2-column frame.
fn keep_doc(fillers: usize, extra: &[&str], height: f64) -> (Document, StoryId, Vec<usize>) {
let mut parts: Vec<String> = (0..fillers).map(|k| format!("Filler {k}")).collect();
parts.extend(extra.iter().map(|s| s.to_string()));
parts.extend(extra.iter().map(ToString::to_string));
let text = parts.join("\n");
let (mut d, sid, fid) = doc_with(&text, Rect::new(0.0, 0.0, 400.0, height), ParaAttrs::default());
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2;
@@ -1272,6 +1358,56 @@ fn optical_margin_hangs_punctuation() {
assert!(lines.iter().all(|l| l.end_x <= l.x1 + 12.0));
}
/// How far the opening quote of a one-line paragraph at `text_size` hangs into the left margin,
/// with the story's Optical Margin Alignment `story` (on, size) and the paragraph's attributes.
fn quote_hang(text_size: f64, story: Option<f64>, para: ParaAttrs) -> f64 {
let text = "“Quoted” words.";
let first_x = |story: Option<f64>, para: ParaAttrs| {
let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 400.0, 200.0), para);
let st = d.story_mut(sid).unwrap();
st.format_chars(0..text.len(), |f| f.over.size = Some(text_size));
if let Some(size) = story {
st.optical_margin = designcraft_doc::OpticalMargin { on: true, size };
}
let cs = compose_story(&d, sid, &ComposeOptions::default());
let lines = all_lines(&cs);
assert_eq!(lines.len(), 1);
lines[0].glyphs[0].x
};
first_x(None, ParaAttrs::default()) - first_x(story, para)
}
#[test]
fn story_optical_margin_hang_scales_with_its_size() {
let at12 = quote_hang(12.0, Some(12.0), ParaAttrs::default());
let at24 = quote_hang(12.0, Some(24.0), ParaAttrs::default());
assert!(at12 > 1.0, "{at12}");
assert!((at24 - 2.0 * at12).abs() < 0.01, "{at12} {at24}");
// The size is the type size the hang is set for: 24 pt text at 24 hangs like 12 at 12, twice as far.
let big = quote_hang(24.0, Some(24.0), ParaAttrs::default());
assert!((big - 2.0 * at12).abs() < 0.01, "{at12} {big}");
}
#[test]
fn ignore_optical_margin_paragraph_does_not_hang() {
let ignored = ParaAttrs { ignore_optical_margin: Some(true), optical_margin: Some(true), ..Default::default() };
assert_eq!(quote_hang(12.0, Some(12.0), ignored.clone()), 0.0);
assert_eq!(quote_hang(12.0, None, ignored), 0.0);
}
#[test]
fn paragraph_optical_margin_hangs_at_the_text_size() {
// Documents saved before the story setting existed: the paragraph's switch hangs each
// character by its own size, not the story's default 12 pt.
let own = ParaAttrs { optical_margin: Some(true), ..Default::default() };
let at24 = quote_hang(24.0, None, own.clone());
assert!((at24 - quote_hang(24.0, Some(24.0), ParaAttrs::default())).abs() < 0.01);
assert!((at24 - 2.0 * quote_hang(12.0, None, own)).abs() < 0.01);
// The story setting, when on, sets the size.
let story = quote_hang(24.0, Some(12.0), ParaAttrs { optical_margin: Some(true), ..Default::default() });
assert!((story - at24 / 2.0).abs() < 0.01, "{story} {at24}");
}
// ---------- golden paragraph corpus ----------
/// Original prose (written for this corpus) with a realistic mix of short and long words.
@@ -2752,6 +2888,7 @@ fn custom_anchors_do_not_change_bottom_baseline_fitting() {
object_point: 0,
ref_point: 0,
keep_within_column: false,
spine_relative: false,
};
d.story_mut(sid).unwrap().insert_object(0, designcraft_doc::AnchoredObject::new(it, pos));
let cs = compose_story(&d, sid, &ComposeOptions::default());
@@ -6610,7 +6747,7 @@ fn roman_word_break_breaks_inside_words_without_hyphens() {
fn grid_doc(text: &str, para: ParaAttrs, edit: impl FnOnce(&mut Document, StoryId)) -> (Vec<Line>, (f64, f64)) {
let (mut d, sid, fid) = doc_with(text, Rect::new(0.0, 0.0, 300.0, 400.0), para);
if let Some(tf) = d.item_mut(fid).and_then(|i| i.text_frame_mut()) {
tf.options.baseline_grid = Some((12.0, 0.0));
tf.options.baseline_grid = Some(designcraft_doc::FrameBaselineGrid::new(0.0, 12.0));
}
edit(&mut d, sid);
// Vertical frames have no baseline grid.
@@ -6740,6 +6877,7 @@ fn anchor_box(d: &mut Document, sid: StoryId, at: usize, x: f64, y: f64, y_rel:
object_point: 0,
ref_point: 0,
keep_within_column: false,
spine_relative: false,
};
d.story_mut(sid).unwrap().insert_object(at, designcraft_doc::AnchoredObject::new(it, position));
}
@@ -6808,6 +6946,7 @@ fn anchored_object_wraps_with_its_contour_and_side() {
object_point: 0,
ref_point: 0,
keep_within_column: false,
spine_relative: false,
};
d.story_mut(a).unwrap().insert_object(0, designcraft_doc::AnchoredObject::new(it, position));
let cs = compose_story(&d, b, &ComposeOptions::default());
@@ -6821,6 +6960,70 @@ fn anchored_object_wraps_with_its_contour_and_side() {
assert!(top > mid + 5.0, "top {top}, middle {mid}");
}
/// Relative to Spine: a marginal note set up for a left-hand page (its right edge 12 pt left of
/// the column) mirrors on the right-hand page (its left edge 12 pt right of the column), so the
/// notes of a story that runs across a spread all sit in the outside margin. Placed as on a
/// left-hand page, the right page's note sat in the inside margin and reached across the spine.
#[test]
fn spine_relative_anchored_notes_stay_in_the_outside_margin_on_both_pages() {
use designcraft_doc::anchored::AnchorRelative as R;
let compose_spread = |spine_relative: bool| {
let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() });
let lid = d.default_layer();
let sp = &d.spreads[1];
let (l, r) = (sp.pages[0].clone(), sp.pages[1].clone());
assert_eq!((l.side, r.side), (designcraft_doc::PageSide::Left, designcraft_doc::PageSide::Right));
// Body frames leave a 120 pt outside margin for the notes.
let left_rect = Rect::new(l.x + 120.0, 36.0, l.x + l.width - 36.0, 120.0);
let right_rect = Rect::new(r.x + 36.0, 36.0, r.x + r.width - 120.0, 600.0);
let text = [LOREM; 3].join(" ");
let (lf, sid) = d.add_text_frame(SpreadRef::Doc(1), left_rect, lid, &text, ParaFormat::default()).unwrap();
let (rf, _) = d.add_text_frame(SpreadRef::Doc(1), right_rect, lid, "", ParaFormat::default()).unwrap();
d.thread(lf, rf).unwrap();
let plain = compose_story(&d, sid, &ComposeOptions::default());
let second_line = |fid| plain.frame(fid).unwrap().lines[1].range.start;
let (at_left, at_right) = (second_line(lf), second_line(rf));
// Top-right corner of the note 12 pt left of the column's top-left, on the anchor's baseline.
let position = designcraft_doc::AnchorPosition::Custom {
x_relative: R::ColumnEdge,
y_relative: R::Anchor,
x_offset: -12.0,
y_offset: 0.0,
object_point: 2,
ref_point: 0,
keep_within_column: false,
spine_relative,
};
for at in [at_right, at_left] {
let note = designcraft_doc::Item::new(
ItemId(d.alloc()),
lid,
designcraft_doc::Shape::Rectangle,
designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 72.0, 40.0)),
);
d.story_mut(sid).unwrap().insert_object(at, designcraft_doc::AnchoredObject::new(note, position.clone()));
}
let cs = compose_story(&d, sid, &ComposeOptions::default());
// Per page: the note's left and right edges, the column, and the page's left and right edges.
let placed = |fid, page: &designcraft_doc::Page| {
let ft = cs.frame(fid).unwrap();
assert_eq!(ft.objects.len(), 1);
let o = &ft.objects[0];
((o.origin.x, o.origin.x + o.size.0), ft.columns[0], (page.x, page.x + page.width))
};
(placed(lf, &l), placed(rf, &r))
};
let near = |a: f64, b: f64| (a - b).abs() < 1e-6;
let (((lx0, lx1), lcol, (lp0, _)), ((rx0, rx1), rcol, (_, rp1))) = compose_spread(true);
assert!(near(lx1, lcol.x0 - 12.0) && lx0 >= lp0, "left page: note {lx0}..{lx1}, column {lcol:?}");
assert!(near(rx0, rcol.x1 + 12.0) && rx1 <= rp1, "right page: note {rx0}..{rx1}, column {rcol:?}");
// Without Relative to Spine both pages take the settings as written.
let (_, ((rx0, rx1), rcol, _)) = compose_spread(false);
assert!(near(rx1, rcol.x0 - 12.0), "right page as written: note {rx0}..{rx1}, column {rcol:?}");
}
const OBJECT_LEN: usize = designcraft_doc::OBJECT_MARK.len_utf8();
#[test]
@@ -7199,6 +7402,158 @@ fn cache_recomposes_when_the_baseline_grid_changes() {
assert!((la.baseline - lb.baseline).abs() > 1.0, "{} {}", la.baseline, lb.baseline);
}
/// Two single pages 618 pt tall, top margins 36 and 48, each with an overset 11/13 story aligned
/// to a baseline grid (start 0, increment 13) and a frame filling its margin box (bottom 582).
fn grid_margin_doc(relative_to: designcraft_doc::GridRelative) -> (Document, [StoryId; 2]) {
let mut d = Document::new(&NewDocument {
height: 618.0,
pages: 2,
facing_pages: false,
margins: designcraft_doc::Margins::uniform(36.0),
..Default::default()
});
Arc::make_mut(&mut d.spreads[1]).pages[0].margins.top = 48.0;
Arc::make_mut(&mut d.spreads[1]).pages[0].local_margins = true;
d.settings.baseline_grid.start = 0.0;
d.settings.baseline_grid.increment = 13.0;
d.settings.baseline_grid.relative_to = relative_to;
let lid = d.default_layer();
let para = ParaFormat { para: ParaAttrs { grid_align: Some(GridAlign::AllLines), ..Default::default() }, ..Default::default() };
let text = [LOREM; 12].join(" ");
let mut sids = Vec::new();
for (si, top) in [(0, 36.0), (1, 48.0)] {
let (_, sid) = d.add_text_frame(SpreadRef::Doc(si), Rect::new(36.0, top, 336.0, 582.0), lid, &text, para.clone()).unwrap();
let st = d.story_mut(sid).unwrap();
st.paras[0].chars =
designcraft_doc::CharAttrs { size: Some(11.0), leading: Some(designcraft_doc::Leading::Points(13.0)), ..Default::default() };
sids.push(sid);
}
(d, [sids[0], sids[1]])
}
/// Page y of a frame's baselines (the frame's inner space is its page's: it has no transform).
fn page_baselines(d: &Document, sid: StoryId) -> Vec<f64> {
let cs = compose_story(d, sid, &ComposeOptions::default());
assert!(cs.is_overset());
cs.frames[0].lines.iter().map(|l| l.baseline).collect()
}
#[test]
fn baseline_grid_relative_to_the_top_margin_starts_at_each_pages_margin() {
use designcraft_doc::GridRelative;
let (d, [p1, p2]) = grid_margin_doc(GridRelative::TopMargin);
// Grid lines at 36 + 13n on page 1: 42 lines from 49 to 582.
let lines = page_baselines(&d, p1);
assert!((lines[0] - 49.0).abs() < 1e-6, "{lines:?}");
assert!(lines.iter().enumerate().all(|(n, y)| (y - (49.0 + 13.0 * n as f64)).abs() < 1e-6), "{lines:?}");
assert_eq!(lines.len(), 42);
// Page 2's own margin: lines at 48 + 13n.
let lines = page_baselines(&d, p2);
assert!((lines[0] - 61.0).abs() < 1e-6, "{lines:?}");
// From the top of the page the lines are at 13n: the first at 52, and one line fewer.
let (d, [p1, _]) = grid_margin_doc(GridRelative::TopOfPage);
let lines = page_baselines(&d, p1);
assert!((lines[0] - 52.0).abs() < 1e-6, "{lines:?}");
assert_eq!(lines.len(), 41);
}
/// A frame's custom grid (start 10, increment 13) measured from the page top, the top margin
/// (36), the frame top (100) or the top inset (6 below it).
#[test]
fn frame_baseline_grid_relative_to_page_margin_frame_or_inset() {
use designcraft_doc::{FrameBaselineGrid, FrameGridRelative as R};
let first_baseline = |relative_to: Option<R>| {
let mut d = Document::new(&NewDocument { margins: designcraft_doc::Margins::uniform(36.0), ..Default::default() });
let lid = d.default_layer();
let para = ParaFormat { para: ParaAttrs { grid_align: Some(GridAlign::AllLines), ..Default::default() }, ..Default::default() };
let (fid, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 100.0, 336.0, 400.0), lid, "Grid", para).unwrap();
let st = d.story_mut(sid).unwrap();
st.paras[0].chars =
designcraft_doc::CharAttrs { size: Some(11.0), leading: Some(designcraft_doc::Leading::Points(13.0)), ..Default::default() };
let tf = d.item_mut(fid).unwrap().text_frame_mut().unwrap();
tf.options.inset = [6.0, 0.0, 0.0, 0.0];
tf.options.baseline_grid = relative_to.map(|r| FrameBaselineGrid { relative_to: r, ..FrameBaselineGrid::new(10.0, 13.0) });
// A document grid that matches none of the frame grids.
d.settings.baseline_grid.start = 0.0;
d.settings.baseline_grid.increment = 0.0;
compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0].baseline
};
// Without a grid the first baseline sits an ascent below the top inset.
let natural = first_baseline(None);
assert!(natural > 106.0 && natural < 120.0, "{natural}");
for (r, origin) in [(R::TopOfPage, 10.0), (R::TopMargin, 46.0), (R::TopOfFrame, 110.0), (R::TopInset, 116.0)] {
let want = (0..).map(|n| origin + 13.0 * f64::from(n)).find(|y| *y >= natural - 1e-9).unwrap();
let got = first_baseline(Some(r));
assert!((got - want).abs() < 1e-6, "{r:?}: first baseline {got}, want {want} (natural {natural})");
}
}
/// A group's transform moves a frame from the left page of a spread to the right one: the grid
/// starts at the right page's top margin.
#[test]
fn baseline_grid_follows_a_grouped_frame_to_its_page() {
use designcraft_doc::{Content, GridRelative, Item, ItemId, Shape};
let mut d = Document::new(&NewDocument { pages: 3, facing_pages: true, margins: designcraft_doc::Margins::uniform(36.0), ..Default::default() });
Arc::make_mut(&mut d.spreads[1]).pages[1].margins.top = 48.0;
d.settings.baseline_grid.start = 0.0;
d.settings.baseline_grid.increment = 13.0;
d.settings.baseline_grid.relative_to = GridRelative::TopMargin;
let lid = d.default_layer();
let para = ParaFormat { para: ParaAttrs { grid_align: Some(GridAlign::AllLines), ..Default::default() }, ..Default::default() };
let (fid, sid) = d.add_text_frame(SpreadRef::Doc(1), Rect::new(36.0, 36.0, 336.0, 582.0), lid, LOREM, para).unwrap();
let sp = Arc::make_mut(&mut d.spreads[1]);
let at = sp.items.iter().position(|it| it.id == fid).unwrap();
let frame = sp.items.remove(at);
let right = sp.pages[1].x;
let mut g = Item::new(ItemId(d.alloc()), lid, Shape::Group, designcraft_geom::PathData::default());
g.content = Content::Group { items: vec![frame] };
g.xf = designcraft_geom::Affine::translate((right, 0.0));
Arc::make_mut(&mut d.spreads[1]).items.push(Arc::new(g));
let cs = compose_story(&d, sid, &ComposeOptions::default());
let first = cs.frames[0].lines[0].baseline;
assert!((first - 61.0).abs() < 1e-6, "{first}");
}
#[test]
fn cache_recomposes_when_the_grid_reference_or_a_top_margin_changes() {
use designcraft_doc::GridRelative;
let (mut d, [p1, _]) = grid_margin_doc(GridRelative::TopOfPage);
let cache = Cache::new();
let a = cache.get(&d, p1, None);
d.settings.baseline_grid.relative_to = GridRelative::TopMargin;
let b = cache.get(&d, p1, None);
assert!(!Arc::ptr_eq(&a, &b));
assert!((a.frames[0].lines[0].baseline - b.frames[0].lines[0].baseline).abs() > 1.0);
Arc::make_mut(&mut d.spreads[0]).pages[0].margins.top = 40.0;
let c = cache.get(&d, p1, None);
assert!(!Arc::ptr_eq(&b, &c));
assert!((b.frames[0].lines[0].baseline - c.frames[0].lines[0].baseline).abs() > 1.0);
}
/// Pages trading places under a frame: the frame is on the other page, with its top margin.
#[test]
fn cache_recomposes_when_the_page_under_a_frame_changes() {
use designcraft_doc::GridRelative;
let mut d = Document::new(&NewDocument { pages: 3, facing_pages: true, margins: designcraft_doc::Margins::uniform(36.0), ..Default::default() });
Arc::make_mut(&mut d.spreads[1]).pages[1].margins.top = 48.0;
d.settings.baseline_grid.start = 0.0;
d.settings.baseline_grid.increment = 13.0;
d.settings.baseline_grid.relative_to = GridRelative::TopMargin;
let lid = d.default_layer();
let para = ParaFormat { para: ParaAttrs { grid_align: Some(GridAlign::AllLines), ..Default::default() }, ..Default::default() };
let x = d.spreads[1].pages[0].x;
let (_, sid) = d.add_text_frame(SpreadRef::Doc(1), Rect::new(x + 36.0, 36.0, x + 336.0, 582.0), lid, LOREM, para).unwrap();
let cache = Cache::new();
let a = cache.get(&d, sid, None);
let sp = Arc::make_mut(&mut d.spreads[1]);
let (x0, x1) = (sp.pages[0].x, sp.pages[1].x);
(sp.pages[0].x, sp.pages[1].x) = (x1, x0);
let b = cache.get(&d, sid, None);
assert!(!Arc::ptr_eq(&a, &b));
assert_eq!(b.frames[0].lines[0].baseline, compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0].baseline);
assert!((a.frames[0].lines[0].baseline - b.frames[0].lines[0].baseline).abs() > 1.0);
}
#[test]
fn cache_recomposes_when_a_wrap_layer_is_hidden() {
let (mut d, sid, _) = doc_with(&[LOREM; 2].join(" "), Rect::new(0.0, 0.0, 300.0, 800.0), ParaAttrs::default());
@@ -7324,6 +7679,25 @@ fn parent_page_frames_know_which_side_of_the_spine_they_are_on() {
assert!(!frame_specs(&d, right_story)[0].left_page);
}
#[test]
fn a_grouped_frame_takes_the_side_of_the_page_its_group_moves_it_to() {
let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() });
let lid = d.default_layer();
let (l, r) = (d.spreads[1].pages[0].clone(), d.spreads[1].pages[1].clone());
let rect = Rect::new(l.x + 36.0, 36.0, l.x + l.width - 36.0, 300.0);
let (fid, sid) = d.add_text_frame(SpreadRef::Doc(1), rect, lid, "Text", ParaFormat::default()).unwrap();
// The frame's own coordinates are on the left page; its group's transform puts it on the right one.
let mut g = designcraft_doc::Item::new(ItemId(d.alloc()), lid, designcraft_doc::Shape::Group, designcraft_geom::PathData::default());
g.xf = designcraft_geom::Affine::translate((r.x - l.x, 0.0));
let sp = Arc::make_mut(&mut d.spreads[1]);
let at = sp.items.iter().position(|it| it.id == fid).unwrap();
g.content = designcraft_doc::Content::Group { items: vec![sp.items.remove(at)] };
sp.items.push(Arc::new(g));
let spec = &frame_specs(&d, sid)[0];
assert!(spec.right_page && !spec.left_page);
assert_eq!(spec.page, Some(2));
}
/// A right-to-left paragraph's first-line indent belongs at its start side, the right. Applied on
/// the left it moved the first line the wrong way — out of the frame for the hanging indent a
/// numbered list uses (right indent 10 mm, first line −10 mm), while the wrapped lines sat where
@@ -7365,3 +7739,91 @@ fn an_rtl_first_line_indent_is_at_the_right_edge() {
assert!((left_edge(lines[0]) - rect.x0).abs() < 1.0, "left to right, first line at the edge: {}", left_edge(lines[0]));
assert!((left_edge(lines[1]) - (rect.x0 + 10.0 * MM)).abs() < 1.0, "left to right, wrapped line indented: {}", left_edge(lines[1]));
}
/// A marginal note (an anchored text frame, Only Align First Line to Grid, 9/10) hangs 15 pt
/// above its anchor's baseline on a right-hand page. It composes against the document grid where
/// it is placed: its first line moves down onto the grid line, the second follows 10 pt below,
/// and its page is the host's. Composed where it was stored, it snapped to a grid measured from
/// its own top edge and sat on no page.
#[test]
fn anchored_text_frames_compose_against_the_grid_where_they_are_placed() {
use designcraft_doc::anchored::AnchorRelative as R;
let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() });
d.settings.baseline_grid.start = 36.0;
d.settings.baseline_grid.increment = 10.0;
let lid = d.default_layer();
let page = d.spreads[1].pages[1].clone();
assert_eq!(page.side, designcraft_doc::PageSide::Right);
// Body lines at 46, 56, 66, … (on the grid) while the frame top is at 36.
let body = "One.\nTwo.\nThree.\nFour.";
let rect = Rect::new(page.x + 108.0, 36.0, page.x + page.width - 36.0, 400.0);
let (bf, sid) = d.add_text_frame(SpreadRef::Doc(1), rect, lid, body, ParaFormat::default()).unwrap();
d.item_mut(bf).unwrap().text_frame_mut().unwrap().options.first_baseline = FirstBaseline::Leading;
let nine_ten = |f: &mut designcraft_doc::story::CharFormat| {
f.over.size = Some(9.0);
f.over.leading = Some(designcraft_doc::Leading::Points(10.0));
};
d.story_mut(sid).unwrap().format_chars(0..body.len(), nine_ten);
// The note: its own story in a 60 × 40 frame anchored in the third line.
let (nid, nsid) = (ItemId(d.alloc()), StoryId(d.alloc()));
let text = "Note one\u{2028}note two";
let para = ParaFormat { para: ParaAttrs { grid_align: Some(GridAlign::FirstLineOnly), ..Default::default() }, ..Default::default() };
let mut story = Story::with_text(nsid, text, para);
story.format_chars(0..text.len(), nine_ten);
story.frames.push(nid);
d.stories.insert(nsid, Arc::new(story));
let mut note =
designcraft_doc::Item::new(nid, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 60.0, 40.0)));
note.content = Content::Text(designcraft_doc::TextFrame { story: nsid, options: Default::default() });
let position = designcraft_doc::AnchorPosition::Custom {
x_relative: R::TextFrame,
y_relative: R::Anchor,
x_offset: -72.0,
y_offset: -15.0,
object_point: 0,
ref_point: 0,
keep_within_column: false,
spine_relative: false,
};
let at = body.find("Three").unwrap();
d.story_mut(sid).unwrap().insert_object(at, designcraft_doc::AnchoredObject::new(note, position));
let cache = Cache::new();
let on_grid = |y: f64| on_grid(y, (36.0, 10.0));
// (anchor line baseline, note baselines), in spread space.
let baselines = |d: &Document, cs: &ComposedStory| {
let host = compose_story(d, sid, &ComposeOptions::default());
let ft = host.frame(bf).unwrap();
let o = &ft.objects[0];
let fxf = d.item(bf).unwrap().xf;
let anchor = (fxf * Point::new(0.0, ft.lines[o.line].baseline)).y;
let nxf = fxf * Affine::translate(o.origin.to_vec2()) * d.anchored_item(nid).unwrap().xf;
(anchor, all_lines(cs).iter().map(|l| (nxf * Point::new(0.0, l.baseline)).y).collect::<Vec<_>>())
};
let near = |a: f64, b: f64| (a - b).abs() < 1e-6;
let (anchor, lines) = baselines(&d, &cache.get(&d, nsid, None));
assert!(near(anchor, 66.0), "anchor baseline {anchor}");
assert_eq!(lines.len(), 2);
assert!(near(lines[0], anchor) && near(lines[1], anchor + 10.0), "note baselines {lines:?}, anchor {anchor}");
assert_eq!(baselines(&d, &compose_story(&d, nsid, &ComposeOptions::default())).1, lines);
// The note is on the host's right-hand page, with that page's rectangle.
let placed = placed::placements(&d, nsid, &mut |h| Arc::new(compose_story(&d, h, &ComposeOptions::default())));
let spec = &frame_specs_placed(&d, nsid, &placed)[0];
assert_eq!((spec.page, spec.right_page, spec.left_page), (Some(2), true, false));
assert!(spec.page_rect.is_some());
// Host moved down 5 pt: the anchor is off the grid, and the note's first line moves down from
// 10 pt above it onto the grid line 5 pt above it. The cache composes the note again.
let xf = d.item(bf).unwrap().xf;
d.item_mut(bf).unwrap().xf = Affine::translate((0.0, 5.0)) * xf;
let (anchor, lines) = baselines(&d, &cache.get(&d, nsid, None));
assert!(near(anchor, 71.0), "anchor baseline {anchor}");
assert!(on_grid(lines[0]) && near(lines[0], anchor - 5.0) && near(lines[1], lines[0] + 10.0), "note baselines {lines:?}, anchor {anchor}");
assert_eq!(baselines(&d, &compose_story(&d, nsid, &ComposeOptions::default())).1, lines);
// The host's page turns into a left-hand page: the note is composed again for that side.
let before = cache.get(&d, nsid, None);
Arc::make_mut(&mut d.spreads[1]).pages[1].side = designcraft_doc::PageSide::Left;
assert!(!Arc::ptr_eq(&before, &cache.get(&d, nsid, None)));
}
+38 -14
View File
@@ -93,6 +93,21 @@ fn line_count(len: usize, want: usize, before: usize, after: usize) -> usize {
1
}
/// The per-line counts recovered from the split table, or `None` when they don't cover all `m`.
fn trace_counts(take: &[Vec<usize>], n: usize, m: usize) -> Option<Vec<usize>> {
let mut counts = Vec::with_capacity(n);
let mut s = 0_usize;
for lines in (1..=n).rev() {
let t = take[lines][s];
if t == 0 || s + t > m {
return None;
}
counts.push(t);
s += t;
}
(s == m).then_some(counts)
}
/// Glyph counts for `n` lines. Minimises the widest line, and the first and last lines keep their
/// break minimums. One line when no split satisfies them.
fn split_even(advs: &[f64], n: usize, before: usize, after: usize) -> Vec<usize> {
@@ -142,17 +157,7 @@ fn split_even(advs: &[f64], n: usize, before: usize, after: usize) -> Vec<usize>
if !best[n][0].is_finite() {
return vec![m];
}
let mut counts = Vec::with_capacity(n);
let mut s = 0_usize;
for lines in (1..=n).rev() {
let t = take[lines][s];
if t == 0 || s + t > m {
return vec![m];
}
counts.push(t);
s += t;
}
if s != m { vec![m] } else { counts }
trace_counts(&take, n, m).unwrap_or_else(|| vec![m])
}
fn restore(line: &mut [PlacedGlyph], members: &[usize], saved: &[(f64, f64, f64, f64)]) {
@@ -217,13 +222,31 @@ fn layout(styles: &[RunStyle], line: &mut [PlacedGlyph], members: &[usize]) -> f
restore(line, members, &saved);
return 0.0;
}
let rows = counts.len().max(1);
let small = st.size * scale;
// 0 is the default (one small em). Any other value, including a negative one, is added to
// that em and then clamped so the rows cannot swap order.
// Bounded, so a hostile value can't move a row to an infinite or huge coordinate.
let spacing = if st.warichu_line_spacing.is_finite() { st.warichu_line_spacing.clamp(-16.0 * st.size, 16.0 * st.size) } else { 0.0 };
let gap = if spacing == 0.0 { small } else { (small + spacing).max(0.0) };
position_warichu_rows(line, members, &counts, &widths, RowLayout { block, x0: old_x0, gap }, st);
let delta = block - old_span;
shift_non_member_glyphs(line, members, &old_xs, old_x0, old_right, delta, scale);
delta
}
#[derive(Clone, Copy)]
struct RowLayout {
block: f64,
x0: f64,
gap: f64,
}
fn position_warichu_rows(line: &mut [PlacedGlyph], members: &[usize], counts: &[usize], widths: &[f64], layout: RowLayout, st: &RunStyle) {
let block = layout.block;
let old_x0 = layout.x0;
let gap = layout.gap;
let rows = counts.len().max(1);
let mut off = 0_usize;
for (li, &c) in counts.iter().enumerate() {
let w = widths[li];
@@ -242,7 +265,9 @@ fn layout(styles: &[RunStyle], line: &mut [PlacedGlyph], members: &[usize]) -> f
}
off += c;
}
let delta = block - old_span;
}
fn shift_non_member_glyphs(line: &mut [PlacedGlyph], members: &[usize], old_xs: &[f64], old_x0: f64, old_right: f64, delta: f64, scale: f64) {
let mut placed: Vec<(f64, f64)> = old_xs.iter().enumerate().map(|(i, ox)| (*ox, line[members[i]].x)).collect();
placed.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut member_at = vec![false; line.len()];
@@ -265,7 +290,6 @@ fn layout(styles: &[RunStyle], line: &mut [PlacedGlyph], members: &[usize]) -> f
g.x = anchor.1 + (g.x - anchor.0) * scale;
}
}
delta
}
#[cfg(test)]
+1 -1
View File
@@ -182,7 +182,7 @@ fn settled(a: &[Vec<Exclusion>], b: &[Vec<Exclusion>]) -> bool {
/// [`neighbours`]) and of its own.
pub(crate) fn compose_among(doc: &Document, sid: StoryId, opts: &ComposeOptions, others: &[&ComposedStory]) -> ComposedStory {
let Some(st) = doc.story(sid) else { return ComposedStory { story: sid, ..Default::default() } };
let mut specs = crate::frame_specs(doc, sid);
let mut specs = crate::frame_specs_placed(doc, sid, &opts.placed);
if !others.is_empty() {
let mut obs = Vec::new();
for cs in others {
+5
View File
@@ -60,6 +60,11 @@ pub enum AnchorPosition {
/// Keep Within Top/Bottom Column Boundaries.
#[serde(default)]
keep_within_column: bool,
/// Relative to Spine: the settings describe a left-hand page, and on a right-hand page
/// the reference point, the object point and the X offset mirror horizontally, so an
/// object in the outside margin stays in the outside margin.
#[serde(default)]
spine_relative: bool,
},
}
+38 -1
View File
@@ -371,7 +371,7 @@ fn locale_code(words: &[&str]) -> Option<String> {
/// from documents, so at most [`MAX_TAGS`] are kept; past that a new tag is unknown (`None`).
fn intern_tag(tag: String) -> Option<&'static str> {
const MAX_TAGS: usize = 512;
static TAGS: std::sync::Mutex<Vec<&'static str>> = std::sync::Mutex::new(Vec::new());
static TAGS: std::sync::Mutex<Vec<&str>> = std::sync::Mutex::new(Vec::new());
let mut tags = TAGS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(t) = tags.iter().find(|t| **t == tag) {
return Some(t);
@@ -650,6 +650,34 @@ pub const MAX_TAB_LEADER: usize = 8;
/// Stops closer than this share a position.
pub const SAME_TAB_POSITION: f64 = 0.01;
/// Smallest type size the engine accepts, in points.
pub const MIN_TYPE_SIZE: f64 = 0.1;
/// Largest type size the engine accepts, in points, as InDesign's own field allows.
pub const MAX_TYPE_SIZE: f64 = 1296.0;
/// Largest measure an imported indent, spacing or offset can carry, in points: the furthest tab
/// stop, which is the widest page.
pub const MAX_MEASURE: f64 = MAX_TAB_POSITION;
/// A type size read from a file, or `None` when it is not a size the engine can mean.
///
/// A file can hold any number, including `NaN`, `inf`, `0`, a negative or `1e300`. Every importer
/// had its own spelling of this test, or only checked that the value was finite -- so tagged text
/// took `<cSize:1e300>` and set a 1e300 pt size on the text (#1593). A size outside the range is
/// dropped rather than clamped, so the text keeps the size its style gives it.
pub fn imported_type_size(pt: f64) -> Option<f64> {
(pt.is_finite() && (MIN_TYPE_SIZE..=MAX_TYPE_SIZE).contains(&pt)).then_some(pt)
}
/// An indent, spacing or offset read from a file, clamped to ±[`MAX_MEASURE`]; `None` when the
/// value is not finite.
///
/// Unlike a type size this is clamped rather than dropped: a file that says "indent everything"
/// still indents to the widest page the engine has, where dropping the value would quietly
/// restore the style's own indent and hide what the file asked for (#1593).
pub fn imported_measure(pt: f64) -> Option<f64> {
pt.is_finite().then(|| pt.clamp(-MAX_MEASURE, MAX_MEASURE))
}
impl TabStop {
/// A finite position clamped to `0..=MAX_TAB_POSITION`.
pub fn clamp_position(v: f64) -> Result<f64, String> {
@@ -1270,8 +1298,17 @@ attr_set! {
number_expression: String = "^#.^t".into(),
/// Text after the bullet (metacharacters expand as in [`expand_list_text`]).
list_separator: String = "\t".into(),
/// The hanging indent was set by the Bulleted or Numbered List button, which takes it away
/// again with the list (an indent the paragraph had before stays).
list_hang: bool = false,
balance_ragged: bool = false,
/// Hang punctuation outside the measure in this paragraph, at each character's own size
/// (DesignCraft's own per-paragraph switch; a story with Optical Margin Alignment on
/// overrides it with the story's size).
optical_margin: bool = false,
/// Ignore Optical Margin: no hang in this paragraph, whatever the story or
/// `optical_margin` say.
ignore_optical_margin: bool = false,
/// None uses the legacy built-in rules; an empty set explicitly disables kinsoku.
kinsoku: Option<crate::cjk::Kinsoku> = None => "deserialize_explicit_value",
/// Preserved spacing-table reference; compatibility audit reports unsupported composition.
+12 -8
View File
@@ -318,15 +318,16 @@ impl Document {
let pos_to = other.frames.iter().position(|f| *f == to).unwrap_or(0);
let after = other.frames.split_off(pos_to);
let before = std::mem::take(&mut other.frames);
let (vertical, direction) = (other.vertical, other.direction);
let (vertical, direction, optical_margin) = (other.vertical, other.direction, other.optical_margin);
let offset;
{
let st = self.story_mut(fs).ok_or(DocError::NoStory(fs))?;
offset = if st.text.is_empty() { 0 } else { st.text.len().saturating_add(1) };
// The story keeps its own direction, even when it was empty and takes the other's text.
let own = (st.vertical, st.direction);
// The story keeps its own direction and options, even when it was empty and takes the
// other's text.
let own = (st.vertical, st.direction, st.optical_margin);
st.append_story(other);
(st.vertical, st.direction) = own;
(st.vertical, st.direction, st.optical_margin) = own;
let at = st.frames.iter().position(|f| *f == from).map_or(st.frames.len(), |p| p + 1);
for (k, f) in after.iter().enumerate() {
st.frames.insert(at + k, *f);
@@ -345,6 +346,7 @@ impl Document {
ns.frames = before.clone();
ns.vertical = vertical;
ns.direction = direction;
ns.optical_margin = optical_margin;
for f in &before {
if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) {
t.story = nid;
@@ -526,12 +528,12 @@ impl Document {
/// stays with the first part and may become overset).
pub fn unthread_after(&mut self, frame: ItemId) -> Result<()> {
let sid = self.item(frame).and_then(|i| i.text_frame()).map(|t| t.story).ok_or(DocError::NoItem(frame))?;
let (tail, vertical, direction) = {
let (tail, vertical, direction, optical_margin) = {
let st = self.story_mut(sid).ok_or(DocError::NoStory(sid))?;
let p = st.frames.iter().position(|f| *f == frame).ok_or(DocError::NoItem(frame))?;
let tail = st.frames.split_off(p + 1);
st.rev = st.rev.wrapping_add(1);
(tail, st.vertical, st.direction)
(tail, st.vertical, st.direction, st.optical_margin)
};
if tail.is_empty() {
return Ok(());
@@ -541,6 +543,7 @@ impl Document {
ns.frames = tail.clone();
ns.vertical = vertical;
ns.direction = direction;
ns.optical_margin = optical_margin;
self.stories.insert(nid, Arc::new(ns));
for f in tail {
if let Some(t) = self.item_mut(f).and_then(Item::text_frame_mut) {
@@ -554,20 +557,21 @@ impl Document {
/// `frame` stays on the page with a new empty story.
pub fn remove_from_thread(&mut self, frame: ItemId) -> Result<()> {
let sid = self.item(frame).and_then(|i| i.text_frame()).map(|t| t.story).ok_or(DocError::NoItem(frame))?;
let (vertical, direction) = {
let (vertical, direction, optical_margin) = {
let st = self.story_mut(sid).ok_or(DocError::NoStory(sid))?;
if st.frames.len() < 2 {
return Err(DocError::Invalid(format!("frame {frame} is not threaded to another frame")));
}
st.frames.retain(|f| *f != frame);
st.rev = st.rev.wrapping_add(1);
(st.vertical, st.direction)
(st.vertical, st.direction, st.optical_margin)
};
let nid = StoryId(self.alloc());
let mut ns = Story::new(nid);
ns.frames = vec![frame];
ns.vertical = vertical;
ns.direction = direction;
ns.optical_margin = optical_margin;
self.stories.insert(nid, Arc::new(ns));
let t = self.item_mut(frame).and_then(Item::text_frame_mut).ok_or(DocError::NoItem(frame))?;
t.story = nid;
+2 -2
View File
@@ -147,7 +147,7 @@ impl crate::Document {
if st.id == sid {
break;
}
n += st.endnotes.len() as u32;
n = n.saturating_add(st.endnotes.len() as u32);
}
n
}
@@ -173,7 +173,7 @@ impl crate::Document {
let at = out.len();
out.insert_with(at, &note.text.text[r].replace('\n', " "), f.clone());
}
n += 1;
n = n.saturating_add(1);
}
}
out
+20 -15
View File
@@ -100,6 +100,25 @@ fn sort_key(s: &str) -> String {
if k.is_empty() { s.to_lowercase() } else { k }
}
/// Record one reference's see-target or page on its topic node.
fn record_ref(n: &mut Node, res: &Resolved) {
match &res.r.range {
IndexRange::See(t) => n.see.push(t.clone()),
IndexRange::SeeAlso(t) => {
n.see_also.push(t.clone());
if let Some(p) = res.pages {
n.pages.push(p);
}
}
IndexRange::SuppressPageRange => {}
_ => {
if let Some(p) = res.pages {
n.pages.push(p);
}
}
}
}
/// The index as paragraphs `(style, text)`; `page_name` formats an absolute page.
pub fn build(spec: &IndexSpec, refs: &[Resolved], page_name: &dyn Fn(usize) -> String) -> Vec<(String, String)> {
let mut root: BTreeMap<String, Node> = BTreeMap::new();
@@ -114,21 +133,7 @@ pub fn build(spec: &IndexSpec, refs: &[Resolved], page_name: &dyn Fn(usize) -> S
.map(|(i, t)| (format!("{}\u{0}{}", sort_key(res.r.sort.get(i).filter(|s| !s.is_empty()).unwrap_or(t)), t.trim()), t.trim().to_string()))
.collect();
let n = descend(&mut root, &keys);
match &res.r.range {
IndexRange::See(t) => n.see.push(t.clone()),
IndexRange::SeeAlso(t) => {
n.see_also.push(t.clone());
if let Some(p) = res.pages {
n.pages.push(p);
}
}
IndexRange::SuppressPageRange => {}
_ => {
if let Some(p) = res.pages {
n.pages.push(p);
}
}
}
record_ref(n, res);
}
let mut out = Vec::new();
if !spec.title.is_empty() {
+107 -1
View File
@@ -452,12 +452,96 @@ pub struct TextFrameOptions {
pub column_rule_bottom_inset: f64,
/// Use a custom baseline grid for this frame.
#[serde(skip_serializing_if = "Option::is_none")]
pub baseline_grid: Option<(f64, f64)>,
pub baseline_grid: Option<FrameBaselineGrid>,
/// Type on a Path: the text runs along the item's path instead of filling it.
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<PathType>,
}
/// A text frame's custom baseline grid. Saved as `[start, increment]` when it starts from the
/// top of the page, otherwise as `[start, increment, relativeTo]`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FrameBaselineGrid {
/// The first grid line, down from the line `relative_to` names.
pub start: f64,
/// The distance between grid lines; 0 or less turns the grid off.
pub increment: f64,
pub relative_to: FrameGridRelative,
}
impl FrameBaselineGrid {
/// A grid measured from the top of the page.
pub fn new(start: f64, increment: f64) -> Self {
FrameBaselineGrid { start, increment, relative_to: FrameGridRelative::TopOfPage }
}
}
/// What a frame's custom baseline grid's start is measured from.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum FrameGridRelative {
#[default]
#[serde(rename = "page")]
TopOfPage,
#[serde(rename = "margin")]
TopMargin,
#[serde(rename = "frame")]
TopOfFrame,
#[serde(rename = "inset")]
TopInset,
}
impl FrameGridRelative {
pub const ALL: [FrameGridRelative; 4] =
[FrameGridRelative::TopOfPage, FrameGridRelative::TopMargin, FrameGridRelative::TopOfFrame, FrameGridRelative::TopInset];
/// The name commands and native files use: page, margin, frame or inset.
pub fn name(self) -> &'static str {
match self {
FrameGridRelative::TopOfPage => "page",
FrameGridRelative::TopMargin => "margin",
FrameGridRelative::TopOfFrame => "frame",
FrameGridRelative::TopInset => "inset",
}
}
pub fn from_name(name: &str) -> Option<Self> {
Self::ALL.into_iter().find(|r| r.name() == name)
}
}
impl Serialize for FrameBaselineGrid {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
// Files saved before Relative To existed hold two elements; a page-relative grid keeps that form.
match self.relative_to {
FrameGridRelative::TopOfPage => (self.start, self.increment).serialize(s),
r => (self.start, self.increment, r).serialize(s),
}
}
}
impl<'de> Deserialize<'de> for FrameBaselineGrid {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct Grid;
impl<'de> serde::de::Visitor<'de> for Grid {
type Value = FrameBaselineGrid;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("[start, increment] or [start, increment, relativeTo]")
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut a: A) -> Result<Self::Value, A::Error> {
use serde::de::Error;
let start = a.next_element()?.ok_or_else(|| A::Error::invalid_length(0, &self))?;
let increment = a.next_element()?.ok_or_else(|| A::Error::invalid_length(1, &self))?;
let relative_to = a.next_element()?.unwrap_or_default();
if a.next_element::<serde::de::IgnoredAny>()?.is_some() {
return Err(A::Error::invalid_length(4, &self));
}
Ok(FrameBaselineGrid { start, increment, relative_to })
}
}
d.deserialize_seq(Grid)
}
}
/// Type on a Path Options.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
@@ -1662,6 +1746,28 @@ mod tests {
assert_eq!(*c, ClippingPath::new(ClippingKind::AlphaChannel, PathData::default()));
}
#[test]
fn frame_baseline_grid_saves_as_start_then_increment() {
// A file saved before Relative To existed: the grid starts from the top of the page and
// saves back unchanged.
let v = serde_json::json!({"baselineGrid": [6.0, 14.0]});
let o = serde_json::from_value::<TextFrameOptions>(v).unwrap();
assert_eq!(o.baseline_grid, Some(FrameBaselineGrid { start: 6.0, increment: 14.0, relative_to: FrameGridRelative::TopOfPage }));
assert_eq!(serde_json::to_value(&o).unwrap()["baselineGrid"], serde_json::json!([6.0, 14.0]));
for r in [FrameGridRelative::TopMargin, FrameGridRelative::TopOfFrame, FrameGridRelative::TopInset] {
let o = TextFrameOptions {
baseline_grid: Some(FrameBaselineGrid { relative_to: r, ..FrameBaselineGrid::new(6.0, 14.0) }),
..Default::default()
};
let v = serde_json::to_value(&o).unwrap();
assert_eq!(v["baselineGrid"], serde_json::json!([6.0, 14.0, r.name()]));
assert_eq!(serde_json::from_value::<TextFrameOptions>(v).unwrap().baseline_grid, o.baseline_grid);
}
for bad in [serde_json::json!([6.0]), serde_json::json!([6.0, 14.0, "frame", 1]), serde_json::json!([6.0, 14.0, "spine"])] {
assert!(serde_json::from_value::<TextFrameOptions>(serde_json::json!({"baselineGrid": bad})).is_err());
}
}
#[test]
fn bounds_and_text_area() {
let mut it = Item::new(ItemId(1), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)));
+11
View File
@@ -147,6 +147,17 @@ impl Default for BaselineGrid {
}
}
impl BaselineGrid {
/// The first grid line on `page`, measured down from the page top: `start` from the top of
/// the page or from its top margin. The other lines follow every `increment` below it.
pub fn origin(&self, page: &page::Page) -> f64 {
match self.relative_to {
GridRelative::TopOfPage => self.start,
GridRelative::TopMargin => self.start + page.margins.top,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct DocumentGrid {
+1 -1
View File
@@ -262,7 +262,7 @@ impl crate::Document {
let Some(me) = self.story(sid) else { return o.start_at };
let mine = key(me);
let before: usize = self.stories.values().filter(|st| st.id != sid && !st.notes.is_empty() && key(st) < mine).map(|st| st.notes.len()).sum();
o.start_at + before as u32
o.start_at.saturating_add(before as u32)
}
/// Absolute page an item sits on (its center), None on parent pages / pasteboard-only spreads.
+1 -1
View File
@@ -53,7 +53,7 @@ pub fn figures(list: &[String]) -> &'static str {
pub fn set_figures(list: &mut Vec<String>, id: &str) -> bool {
let Some((_, _, tags, _)) = FIGURES.iter().find(|f| f.0 == id) else { return false };
list.retain(|t| !matches!(t.as_str(), "tnum" | "pnum" | "lnum" | "onum"));
list.extend(tags.iter().map(|t| t.to_string()));
list.extend(tags.iter().map(ToString::to_string));
true
}
+59
View File
@@ -125,6 +125,9 @@ pub struct Story {
/// right to left, in every frame of the thread.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub vertical: bool,
/// Story ▸ Optical Margin Alignment.
#[serde(default, skip_serializing_if = "OpticalMargin::is_default")]
pub optical_margin: OpticalMargin,
/// Bumped on every edit (composition cache key).
#[serde(default)]
pub rev: u64,
@@ -160,6 +163,45 @@ pub struct Story {
pub file_link: Option<TextFileLink>,
}
/// Story ▸ Optical Margin Alignment: punctuation and the edges of some letters hang outside the
/// frame's edges, in every paragraph of the story except those with Ignore Optical Margin.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct OpticalMargin {
pub on: bool,
/// The type size (points) the hang amounts are set for: text of this size hangs by the
/// built-in proportions of each character's advance, and the hang grows with this size.
pub size: f64,
}
impl Default for OpticalMargin {
fn default() -> Self {
OpticalMargin { on: false, size: Self::DEFAULT_SIZE }
}
}
impl OpticalMargin {
pub const DEFAULT_SIZE: f64 = 12.0;
/// InDesign's range for the size.
pub const MIN_SIZE: f64 = 0.1;
pub const MAX_SIZE: f64 = 1296.0;
/// `v` clamped to the size range, or `None` when it isn't a finite number.
pub fn clamp_size(v: f64) -> Option<f64> {
v.is_finite().then(|| v.clamp(Self::MIN_SIZE, Self::MAX_SIZE))
}
/// The size the story's text hangs for, when the setting is on (a stored size outside the
/// range is clamped, a non-finite one reads as the default).
pub fn hang_size(&self) -> Option<f64> {
self.on.then(|| Self::clamp_size(self.size).unwrap_or(Self::DEFAULT_SIZE))
}
pub fn is_default(&self) -> bool {
*self == Self::default()
}
}
/// A linked text file: where a placed story came from, what the file held, and the Word Import
/// Options that brought it in, so Update Link can import it again the same way.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
@@ -201,6 +243,7 @@ impl Story {
frames: vec![],
direction: crate::TextDirection::LeftToRight,
vertical: false,
optical_margin: OpticalMargin::default(),
rev: 0,
tables: BTreeMap::new(),
notes: Vec::new(),
@@ -596,6 +639,22 @@ mod tests {
Story::with_text(StoryId(1), t, ParaFormat::default())
}
#[test]
fn optical_margin_is_optional_in_saved_stories() {
let plain = serde_json::to_value(st("Text")).unwrap();
assert!(plain.get("opticalMargin").is_none());
let old: Story = serde_json::from_value(plain).unwrap();
assert_eq!(old.optical_margin, OpticalMargin { on: false, size: 12.0 });
let mut s = st("Text");
s.optical_margin = OpticalMargin { on: true, size: 18.0 };
let back: Story = serde_json::from_value(serde_json::to_value(&s).unwrap()).unwrap();
assert_eq!(back.optical_margin, s.optical_margin);
// A hostile stored size is clamped where it is used.
assert_eq!(OpticalMargin { on: true, size: 1e9 }.hang_size(), Some(OpticalMargin::MAX_SIZE));
assert_eq!(OpticalMargin { on: true, size: -3.0 }.hang_size(), Some(OpticalMargin::MIN_SIZE));
assert_eq!(OpticalMargin { on: false, size: 18.0 }.hang_size(), None);
}
#[test]
fn paragraphs_track_newlines() {
let mut s = st("one\ntwo");
+9 -3
View File
@@ -1040,6 +1040,15 @@ impl Table {
if self.rows[h..self.nrows() - f].iter().any(|r| r.kind != RowKind::Body) {
return Err(format!("table {}: header/footer rows out of order", self.id));
}
self.check_spans()?;
for cell in &self.cells {
cell.text.check()?;
}
Ok(())
}
/// Every merged cell stays inside the grid and no two merged cells overlap.
fn check_spans(&self) -> Result<(), String> {
let mut seen = vec![false; self.cells.len()];
for r in 0..self.nrows() {
for c in 0..self.ncols() {
@@ -1064,9 +1073,6 @@ impl Table {
}
}
}
for cell in &self.cells {
cell.text.check()?;
}
Ok(())
}
}
+22 -7
View File
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Insert Anchored Object…",
["Object", "Anchored Object"],
None,
"{ids, story?, pos? (default: the text insertion point), position?: inline|aboveLine|custom, yOffset?, align?: left|center|right, spaceBefore?, spaceAfter?, xRelative? | yRelative?: anchor|textFrame|columnEdge|pageMargin|pageEdge, xOffset?, objectPoint? | refPoint?: 0..8, keepWithinColumn?} — moves the items into the text",
"{ids, story?, pos? (default: the text insertion point), position?: inline|aboveLine|custom, yOffset?, align?: left|center|right, spaceBefore?, spaceAfter?, xRelative? | yRelative?: anchor|textFrame|columnEdge|pageMargin|pageEdge, xOffset?, objectPoint? | refPoint?: 0..8, keepWithinColumn?, spineRelative? (custom: the settings describe a left-hand page and mirror on a right-hand page)} — moves the items into the text",
has_doc,
insert
),
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Anchored Object Options…",
["Object", "Anchored Object"],
None,
"{story?, index? (default: the object at the insertion point), position?: inline|aboveLine|custom, yOffset?, align?: left|center|right, spaceBefore?, spaceAfter?, xRelative? | yRelative?: anchor|textFrame|columnEdge|pageMargin|pageEdge, xOffset?, objectPoint? | refPoint?: 0..8, keepWithinColumn?}",
"{story?, index? (default: the object at the insertion point), position?: inline|aboveLine|custom, yOffset?, align?: left|center|right, spaceBefore?, spaceAfter?, xRelative? | yRelative?: anchor|textFrame|columnEdge|pageMargin|pageEdge, xOffset?, objectPoint? | refPoint?: 0..8, keepWithinColumn?, spineRelative? (custom: the settings describe a left-hand page and mirror on a right-hand page)}",
has_doc,
options
),
@@ -80,11 +80,18 @@ fn position_param(p: &Value, base: &AnchorPosition) -> Result<AnchorPosition> {
}
("custom", b) => {
use designcraft_doc::anchored::AnchorRelative as R;
let (xr0, yr0, xo0, yo0, op0, rp0, k0) = match b {
AnchorPosition::Custom { x_relative, y_relative, x_offset, y_offset, object_point, ref_point, keep_within_column } => {
(*x_relative, *y_relative, *x_offset, *y_offset, *object_point, *ref_point, *keep_within_column)
}
_ => (R::TextFrame, R::Anchor, 0.0, 0.0, 0, 0, false),
let (xr0, yr0, xo0, yo0, op0, rp0, k0, sr0) = match b {
AnchorPosition::Custom {
x_relative,
y_relative,
x_offset,
y_offset,
object_point,
ref_point,
keep_within_column,
spine_relative,
} => (*x_relative, *y_relative, *x_offset, *y_offset, *object_point, *ref_point, *keep_within_column, *spine_relative),
_ => (R::TextFrame, R::Anchor, 0.0, 0.0, 0, 0, false, false),
};
let rel = |k: &str, d: R| -> Result<R> {
match p.get(k) {
@@ -102,6 +109,7 @@ fn position_param(p: &Value, base: &AnchorPosition) -> Result<AnchorPosition> {
object_point: pt("objectPoint", op0),
ref_point: pt("refPoint", rp0),
keep_within_column: p.get("keepWithinColumn").and_then(Value::as_bool).unwrap_or(k0),
spine_relative: p.get("spineRelative").and_then(Value::as_bool).unwrap_or(sr0),
}
}
(o, _) => return Err(bad("anchored", format!("unknown position `{o}`"))),
@@ -316,5 +324,12 @@ mod custom_tests {
matches!(obj.position, designcraft_doc::AnchorPosition::Custom { x_offset, object_point: 2, .. } if x_offset == -6.0),
"IDML Anchored position"
);
// Relative to Spine keeps the other custom settings.
s.execute("anchored.options", &json!({"story": sid.0, "index": 0, "spineRelative": true})).unwrap();
let st = s.doc().unwrap().doc.story(sid).unwrap().clone();
assert!(matches!(
st.objects[0].position,
designcraft_doc::AnchorPosition::Custom { spine_relative: true, x_offset, object_point: 2, .. } if x_offset == -6.0
));
}
}
+1 -1
View File
@@ -130,7 +130,7 @@ impl ChapterBatch {
fn writable_chapter(s: &Session, path: &str, command: &'static str) -> Result<()> {
use std::path::Path;
if Path::new(path).extension().and_then(|ext| ext.to_str()).is_some_and(|ext| ext.eq_ignore_ascii_case("idml")) {
if Path::new(path).extension().and_then(std::ffi::OsStr::to_str).is_some_and(|ext| ext.eq_ignore_ascii_case("idml")) {
return Err(bad(command, format!("cannot change imported IDML chapter {path}; convert it to a native DesignCraft document first")));
}
let target = crate::io::canonicalize(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
+1 -1
View File
@@ -254,7 +254,7 @@ fn find_markers(text: &str) -> Vec<(usize, usize, String)> {
continue;
}
}
i += text[i..].chars().next().map(|c| c.len_utf8()).unwrap_or(1);
i += text[i..].chars().next().map(char::len_utf8).unwrap_or(1);
}
out
}
+1 -1
View File
@@ -617,7 +617,7 @@ fn marker_ranges(text: &str, marker: &str) -> Vec<(usize, usize)> {
i = end;
continue;
}
i = i.saturating_add(text[i..].chars().next().map(|c| c.len_utf8()).unwrap_or(1));
i = i.saturating_add(text[i..].chars().next().map(char::len_utf8).unwrap_or(1));
}
out
}
+1 -1
View File
@@ -379,7 +379,7 @@ fn sniff(name: &str, text: &str) -> Delimiter {
}
pub fn extension(name: &str) -> String {
Path::new(name).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase()
Path::new(name).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase()
}
struct HeaderDelimiterCandidate {
+25 -1
View File
@@ -2,7 +2,7 @@
use std::path::{Path, PathBuf};
use designcraft_doc::{Content, DataField, DataFieldKind, DataSource, Delimiter, HyperlinkDest, SourceStatus};
use designcraft_doc::{Content, DataField, DataFieldKind, DataSource, Delimiter, HyperlinkDest, Selection, SourceStatus, StoryId, TextSel};
use serde_json::{Value, json};
use super::parse::{self, parse_csv};
@@ -790,6 +790,30 @@ fn tiling_rows_first_bottom_inset_and_refusals() {
assert_eq!(s.documents().len(), before);
}
/// An IME delete-surrounding while a record is previewed edits the template, not the filled
/// record, and its undo step restores the template.
#[test]
fn an_ime_delete_ends_a_preview_and_edits_the_template() {
let mut s = Session::new();
new_doc(&mut s, 1);
let f = text_frame(&mut s, [36.0, 36.0, 240.0, 80.0], "", None);
let sid = StoryId(f["story"].as_u64().unwrap());
select_csv(&mut s, "Name\nAda\nGrace\n");
exec(&mut s, "data.placeholder.add", json!({"field": "Name"}));
assert_eq!(texts(&s), vec!["<<Name>>".to_string()]);
exec(&mut s, "data.preview", json!({"record": 2}));
assert_eq!(texts(&s), vec!["Grace".to_string()], "the preview shows the record");
// The caret was placed in the preview, at the end of the filled text.
s.doc_mut().unwrap().selection = Selection::text(TextSel { story: sid, anchor: 5, focus: 5, frame: None, cell: None });
let m = s.ime_delete_surrounding(1, 0, false).unwrap().expect("the delete changed the template");
assert!(m.inserted.is_empty());
assert!(s.doc().unwrap().preview_stash.is_none(), "the preview ended before the edit");
assert_eq!(texts(&s), vec!["<<Nae>>".to_string()], "the template lost the character, not the filled record");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(texts(&s), vec!["<<Name>>".to_string()], "Undo restores the template, never the preview record");
assert!(s.doc().unwrap().preview_stash.is_none());
}
#[test]
fn update_remove_preview_inline_rows_and_old_files() {
let dir = scratch("update");
+220 -2
View File
@@ -2,11 +2,11 @@
use std::sync::Arc;
use designcraft_doc::{EditorialNote, NOTE_MARK, StoryId};
use designcraft_doc::{ANCHOR_MARK, ENDNOTE_REF, EditorialNote, FOOTNOTE_REF, INDEX_MARK, NOTE_MARK, OBJECT_MARK, StoryId, TABLE_ANCHOR, XREF_MARK};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, has_text, ok, str_param};
use crate::Result;
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -37,6 +37,15 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"story": ts.story.0, "id": id}))
})
}),
cmd!(
"note.convertFromText",
"Convert to Note",
["Type", "Notes"],
None,
"{author?} → {story, id}. The selected text moves into a new note anchored where it was.",
has_text_range,
convert_from_text
),
cmd!("note.edit", "Edit Note", [], None, "{story, id, text?, author?}", has_doc, |s, p| {
let (sid, id) = target(p)?;
let (text, author) = (str_param(p, "text").map(|_| super::text_param(p, "text")), str_param(p, "author").map(str::to_string));
@@ -93,9 +102,76 @@ pub fn specs() -> Vec<CommandSpec> {
}
Ok(Value::Array(out))
}),
cmd!(
noundo "note.previous",
"Previous Note",
["Type", "Notes"],
None,
"{} → {story, id, pos}. The caret moves after the anchor of the story's previous note.",
has_previous,
|s, _| go_to(s, false)
),
cmd!(
noundo "note.next",
"Next Note",
["Type", "Notes"],
None,
"{} → {story, id, pos}. The caret moves after the anchor of the story's next note.",
has_next,
|s, _| go_to(s, true)
),
]
}
/// Characters whose payload lives outside the story text: a note made from text that holds one
/// would lose it.
const MARKS: [char; 8] = [NOTE_MARK, ENDNOTE_REF, FOOTNOTE_REF, OBJECT_MARK, INDEX_MARK, ANCHOR_MARK, XREF_MARK, TABLE_ANCHOR];
fn has_text_range(s: &Session) -> std::result::Result<(), String> {
has_text(s)?;
match s.active().and_then(|d| d.selection.text) {
Some(t) if t.cell.is_some() => Err("notes go in story text".into()),
Some(t) if t.is_caret() => Err("select the text to convert".into()),
_ => Ok(()),
}
}
/// Type › Notes › Convert to Note: the selected text leaves the story and becomes the text of a
/// new note, whose anchor takes the text's place.
fn convert_from_text(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "note.convertFromText";
let ts = s.doc()?.selection.text.ok_or_else(|| bad(ID, "select the text to convert"))?;
if ts.cell.is_some() {
return Err(bad(ID, "notes go in story text"));
}
let r = ts.range();
if r.is_empty() {
return Err(bad(ID, "select the text to convert"));
}
let author = str_param(p, "author").unwrap_or("").to_string();
s.edit(|d, sel| {
let story = d.story_mut(ts.story).ok_or_else(|| bad(ID, "no such story"))?;
let text = story.text.get(r.clone()).ok_or_else(|| bad(ID, "the selection is outside the story"))?.to_string();
if text.contains(MARKS) {
return Err(bad(ID, "the selection holds a table, an anchored object, a note, a footnote, an endnote or a marker"));
}
let fmt = story.format_after(r.start).clone();
story.delete(r.clone());
story.insert_with(r.start, &NOTE_MARK.to_string(), fmt);
story.fix_marks();
let k = story.text[..r.start].matches(NOTE_MARK).count();
let n = story.editorial.get_mut(k).ok_or_else(|| bad(ID, "the story's notes don't match their anchors"))?;
let n = Arc::make_mut(n);
(n.text, n.author) = (text, author);
let id = n.id;
if let Some(t) = &mut sel.text {
let c = r.start + NOTE_MARK.len_utf8();
(t.anchor, t.focus) = (c, c);
}
Ok(json!({"story": ts.story.0, "id": id}))
})
}
fn target(p: &Value) -> Result<(StoryId, u64)> {
let sid = p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("note", "`story` required"))?;
let id = p.get("id").and_then(Value::as_u64).ok_or_else(|| bad("note", "`id` required"))?;
@@ -108,6 +184,53 @@ fn anchor(st: &designcraft_doc::Story, id: u64) -> Option<usize> {
st.text.match_indices(NOTE_MARK).nth(k).map(|(i, _)| i)
}
/// The anchor offset and id of the note before or after the selection `t` in its story. Going
/// back skips the note whose anchor the caret directly follows, so repeated steps reach every note.
fn neighbor(st: &designcraft_doc::Story, t: &designcraft_doc::TextSel, forward: bool) -> Option<(usize, u64)> {
let r = t.range();
let mut anchors = st.text.match_indices(NOTE_MARK).enumerate().map(|(k, (i, _))| (k, i));
let (k, at) = if forward { anchors.find(|&(_, i)| i >= r.end) } else { anchors.filter(|&(_, i)| i + NOTE_MARK.len_utf8() < r.start).last() }?;
Some((at, st.editorial.get(k)?.id))
}
fn has_note(s: &crate::Session, forward: bool) -> std::result::Result<(), String> {
has_text(s)?;
match s.active().and_then(|d| Some((d, d.selection.text?))) {
Some((_, t)) if t.cell.is_some() => Err("notes go in story text".into()),
Some((d, t)) if d.doc.story(t.story).and_then(|st| neighbor(st, &t, forward)).is_some() => Ok(()),
_ => Err(no_note(forward).into()),
}
}
fn has_previous(s: &crate::Session) -> std::result::Result<(), String> {
has_note(s, false)
}
fn has_next(s: &crate::Session) -> std::result::Result<(), String> {
has_note(s, true)
}
fn no_note(forward: bool) -> &'static str {
if forward { "no later note in this story" } else { "no earlier note in this story" }
}
/// Type › Notes › Previous Note and Next Note: from the insertion point, the caret moves after
/// the anchor of the story's previous or next note.
fn go_to(s: &mut crate::Session, forward: bool) -> Result<Value> {
let id = if forward { "note.next" } else { "note.previous" };
let st = s.doc_mut()?;
let t = st.selection.text.ok_or_else(|| bad(id, "place the insertion point in text"))?;
if t.cell.is_some() {
return Err(bad(id, "notes go in story text"));
}
let (at, note) = st.doc.story(t.story).and_then(|x| neighbor(x, &t, forward)).ok_or_else(|| bad(id, no_note(forward)))?;
let pos = at + NOTE_MARK.len_utf8();
st.selection.text = Some(designcraft_doc::TextSel::caret(t.story, pos));
st.bump_revision();
s.typing_format = None;
Ok(json!({"story": t.story.0, "id": note, "pos": pos}))
}
#[cfg(test)]
mod tests {
use serde_json::json;
@@ -136,6 +259,46 @@ mod tests {
assert!(s.doc().unwrap().doc.stories[&sid].editorial.is_empty());
}
#[test]
fn previous_and_next_note_step_through_the_story_s_notes() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "One two three."})).unwrap();
let len = designcraft_doc::NOTE_MARK.len_utf8();
let caret = |s: &mut Session, at: usize| s.execute("text.select", &json!({"story": r["story"], "anchor": at, "focus": at})).unwrap();
caret(&mut s, 3);
let first = s.execute("note.new", &json!({"text": "a"})).unwrap();
caret(&mut s, 7 + len);
let second = s.execute("note.new", &json!({"text": "b"})).unwrap();
caret(&mut s, 0);
let disabled = |s: &mut Session, id: &str| matches!(s.execute(id, &json!({})), Err(crate::EngineError::Disabled(..)));
assert!(disabled(&mut s, "note.previous"), "no note before the start");
let n = s.execute("note.next", &json!({})).unwrap();
assert_eq!((n["id"].clone(), n["pos"].clone()), (first["id"].clone(), json!(3 + len)));
let n = s.execute("note.next", &json!({})).unwrap();
assert_eq!((n["id"].clone(), n["pos"].clone()), (second["id"].clone(), json!(7 + 2 * len)));
let t = s.doc().unwrap().selection.text.unwrap();
assert_eq!((t.anchor, t.focus), (7 + 2 * len, 7 + 2 * len), "the caret follows the anchor");
assert!(disabled(&mut s, "note.next"), "no note after the last");
// From just after the second anchor, Previous Note goes to the first one.
let n = s.execute("note.previous", &json!({})).unwrap();
assert_eq!(n["id"], first["id"]);
assert!(disabled(&mut s, "note.previous"));
// From the middle of the story, each direction finds its neighbor.
caret(&mut s, 5 + len);
assert_eq!(s.execute("note.previous", &json!({})).unwrap()["id"], first["id"]);
caret(&mut s, 5 + len);
assert_eq!(s.execute("note.next", &json!({})).unwrap()["id"], second["id"]);
// Formatting set at the insertion point does not survive the move.
s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap();
assert!(s.typing_format.is_some());
s.execute("note.previous", &json!({})).unwrap();
assert!(s.typing_format.is_none(), "note.previous");
s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap();
s.execute("note.next", &json!({})).unwrap();
assert!(s.typing_format.is_none(), "note.next");
}
#[test]
fn notes_insert_into_a_story_whose_markers_lack_payloads() {
let mut s = Session::new();
@@ -159,4 +322,59 @@ mod tests {
assert_eq!(st.endnotes[1].text.text, "An endnote.");
assert_eq!(st.notes[1].text.text, "A footnote.");
}
#[test]
fn selected_text_converts_to_a_note_at_its_place() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "One two\nthree four."})).unwrap();
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
let mark = designcraft_doc::NOTE_MARK.len_utf8();
s.execute("text.select", &json!({"story": r["story"], "anchor": 3, "focus": 3})).unwrap();
s.execute("note.new", &json!({"text": "earlier"})).unwrap();
// "two\nthree " across the paragraph return, selected backwards.
let (a, b) = (3 + mark + 1, 3 + mark + 1 + "two\nthree ".len());
s.execute("text.select", &json!({"story": r["story"], "anchor": b, "focus": a})).unwrap();
let n = s.execute("note.convertFromText", &json!({"author": "Ed"})).unwrap();
let st = &s.doc().unwrap().doc.stories[&sid];
st.check().unwrap();
assert_eq!(st.text, format!("One{m} {m}four.", m = designcraft_doc::NOTE_MARK));
assert_eq!(st.paras.len(), 1, "the paragraph return went into the note");
let list = s.execute("note.list", &json!({})).unwrap();
assert_eq!(list[0]["text"], "earlier");
assert_eq!((list[1]["id"].clone(), list[1]["at"].clone()), (n["id"].clone(), json!(a)));
assert_eq!((list[1]["text"].as_str(), list[1]["author"].as_str()), (Some("two\nthree "), Some("Ed")));
let caret = s.doc().unwrap().selection.text.map(|t| (t.anchor, t.focus));
assert_eq!(caret, Some((a + mark, a + mark)), "the insertion point follows the anchor");
s.execute("note.convertToText", &json!({"story": n["story"], "id": n["id"]})).unwrap();
assert_eq!(s.doc().unwrap().doc.stories[&sid].text, format!("One{} two\nthree four.", designcraft_doc::NOTE_MARK));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
let st = &s.doc().unwrap().doc.stories[&sid];
assert_eq!((st.text.as_str(), st.editorial.len()), (format!("One{} two\nthree four.", designcraft_doc::NOTE_MARK).as_str(), 1));
}
#[test]
fn convert_to_note_needs_selected_story_text_without_objects() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Draft copy."})).unwrap();
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
s.execute("text.select", &json!({"story": r["story"], "anchor": 5, "focus": 5})).unwrap();
let disabled = |r| matches!(r, Err(crate::EngineError::Disabled(..)));
assert!(disabled(s.execute("note.convertFromText", &json!({}))), "an insertion point selects no text");
s.execute("note.new", &json!({"text": "keep"})).unwrap();
let before = s.doc().unwrap().doc.stories[&sid].clone();
let all = before.len();
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": all})).unwrap();
let refused = s.execute("note.convertFromText", &json!({}));
assert!(matches!(refused, Err(crate::EngineError::BadParams { .. })), "a note inside the selection would be lost");
assert_eq!(s.doc().unwrap().doc.stories[&sid], before);
s.execute("text.select", &json!({"story": r["story"], "anchor": all, "focus": all})).unwrap();
s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap();
s.execute("text.insert", &json!({"text": "cell", "raw": true})).unwrap();
s.execute("text.move", &json!({"dir": "left", "extend": true})).unwrap();
assert!(s.doc().unwrap().selection.text.is_some_and(|t| t.cell.is_some() && !t.is_caret()));
assert!(disabled(s.execute("note.convertFromText", &json!({}))), "notes go in story text");
}
}
+1 -1
View File
@@ -50,7 +50,7 @@ pub fn specs() -> Vec<CommandSpec> {
for st in d.stories.values().filter(|st| !st.endnotes.is_empty()) {
let first = d.endnote_start(st.id);
for (k, n) in st.endnotes.iter().enumerate() {
out.push(json!({"story": st.id.0, "id": n.id, "number": first + k as u32, "text": n.text.text}));
out.push(json!({"story": st.id.0, "id": n.id, "number": first.saturating_add(k as u32), "text": n.text.text}));
}
}
out.sort_by_key(|v| v["number"].as_u64());
+49 -4
View File
@@ -166,7 +166,7 @@ fn save_bytes(d: &Document, preview: Option<&[u8]>) -> Result<Vec<u8>> {
/// the file opens from the file manager. A path without a file name is left for the write to fail.
pub fn with_document_extension(path: &str) -> String {
let p = std::path::Path::new(path);
let ext = p.extension().and_then(|e| e.to_str());
let ext = p.extension().and_then(std::ffi::OsStr::to_str);
if p.file_name().is_none() || ext.is_some_and(|e| e.eq_ignore_ascii_case(designcraft_format::EXTENSION)) {
return path.to_string();
}
@@ -231,11 +231,24 @@ fn new_document_settings(s: &Session, p: &Value) -> Result<NewDocument> {
Ok(nd)
}
/// A document as `file.new` makes it. Its Basic Paragraph doesn't hyphenate capitalized words, a
/// paragraph's last word or across a column. Opened and imported files keep their own values,
/// which is why `ParaProps` keeps the IDML defaults (on).
fn new_document(nd: &NewDocument) -> Document {
let mut d = Document::new(nd);
if let Some(st) = Arc::make_mut(&mut d.styles).para_mut(designcraft_doc::BASIC_PARAGRAPH) {
st.para.hyph_capitalized = Some(false);
st.para.hyph_last_word = Some(false);
st.para.hyph_across_column = Some(false);
}
d
}
fn file_new(s: &mut Session, p: &Value) -> Result<Value> {
let mut nd = new_document_settings(s, p)?;
s.untitled += 1;
nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| format!("Untitled-{}", s.untitled));
let d = Document::new(&nd);
let d = new_document(&nd);
let i = s.add_document(DocState::new(d, None));
Ok(json!({"index": i}))
}
@@ -259,7 +272,7 @@ impl Session {
self.untitled += 1;
}
nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| self.next_untitled_title());
let st = DocState::new(Document::new(&nd), None);
let st = DocState::new(new_document(&nd), None);
let uid = st.uid;
match replace.and_then(|i| self.docs.get_mut(i).map(|d| (i, d))) {
Some((i, d)) => {
@@ -469,7 +482,7 @@ fn open_path(s: &mut Session, p: &Value) -> Result<Value> {
let bytes = crate::io::read_file(path).map_err(EngineError::Other)?;
// The content decides first: a file's extension may be missing or wrong. The extension
// then routes files whose content isn't recognised to the reader that explains why.
let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or_default().to_ascii_lowercase();
let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or_default().to_ascii_lowercase();
let native = designcraft_format::is_designcraft(&bytes) || bytes.trim_ascii_start().starts_with(b"{");
if designcraft_idml::is_idml(&bytes) || (!native && (super::interchange::is_indd(path, &bytes) || ext == "idml")) {
return super::interchange::open_idml(s, p);
@@ -2124,3 +2137,35 @@ mod recent_tests {
assert!(old.recent_files.is_empty(), "older prefs.json without the list still load");
}
}
#[cfg(test)]
mod hyphenation_default_tests {
use std::sync::Arc;
use designcraft_doc::BASIC_PARAGRAPH;
use designcraft_doc::build::NewDocument;
use serde_json::json;
use crate::Session;
fn hyph_flags(d: &designcraft_doc::Document) -> (bool, bool, bool) {
let (pp, _) = d.styles.resolve_para(&designcraft_doc::ParaFormat::default());
(pp.hyph_capitalized, pp.hyph_last_word, pp.hyph_across_column)
}
#[test]
fn new_documents_hyphenate_no_capitalized_last_or_column_end_words_and_imports_keep_theirs() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
assert_eq!(hyph_flags(&s.doc().unwrap().doc), (false, false, false));
let mut d = designcraft_doc::Document::new(&NewDocument::default());
let st = Arc::make_mut(&mut d.styles).para_mut(BASIC_PARAGRAPH).unwrap();
st.para.hyph_capitalized = Some(true);
st.para.hyph_last_word = Some(true);
st.para.hyph_across_column = Some(true);
let b64 = super::base64_encode(&designcraft_idml::export_idml(&d));
s.execute("file.openIdml", &json!({"base64": b64, "name": "in.idml"})).unwrap();
assert_eq!(hyph_flags(&s.doc().unwrap().doc), (true, true, true));
}
}
+35 -30
View File
@@ -82,44 +82,49 @@ pub(crate) fn sync(d: &mut Document) {
}
}
/// InDesign's detection when objects become a container: objects in a row (or column) keep it,
/// with Auto size and Space Between at their own spacing; others wrap in a row of their width.
fn detect(bounds: &[Rect]) -> FlexLayout {
let tol = 0.5;
let in_line = |row: bool| {
let mut v: Vec<&Rect> = bounds.iter().collect();
v.sort_by(|a, b| if row { a.x0.total_cmp(&b.x0) } else { a.y0.total_cmp(&b.y0) });
let apart = v.windows(2).all(|w| if row { w[1].x0 >= w[0].x1 - tol } else { w[1].y0 >= w[0].y1 - tol });
let gaps: Vec<f64> = v.windows(2).map(|w| if row { w[1].x0 - w[0].x1 } else { w[1].y0 - w[0].y1 }).collect();
(apart && bounds.len() > 1).then(|| gaps.iter().sum::<f64>() / gaps.len().max(1) as f64)
};
fn in_line_gap(bounds: &[Rect], row: bool, tol: f64) -> Option<f64> {
let mut v: Vec<&Rect> = bounds.iter().collect();
v.sort_by(|a, b| if row { a.x0.total_cmp(&b.x0) } else { a.y0.total_cmp(&b.y0) });
let apart = v.windows(2).all(|w| if row { w[1].x0 >= w[0].x1 - tol } else { w[1].y0 >= w[0].y1 - tol });
let gaps: Vec<f64> = v.windows(2).map(|w| if row { w[1].x0 - w[0].x1 } else { w[1].y0 - w[0].y1 }).collect();
(apart && bounds.len() > 1).then(|| gaps.iter().sum::<f64>() / gaps.len().max(1) as f64)
}
fn detect_align(bounds: &[Rect], row: bool, tol: f64) -> FlexAlign {
let same = |f: &dyn Fn(&Rect) -> f64| bounds.windows(2).all(|w| (f(&w[0]) - f(&w[1])).abs() <= tol);
let align = |row: bool| {
let (a, c, e): (&dyn Fn(&Rect) -> f64, &dyn Fn(&Rect) -> f64, &dyn Fn(&Rect) -> f64) =
if row { (&|r| r.y0, &|r| r.center().y, &|r| r.y1) } else { (&|r| r.x0, &|r| r.center().x, &|r| r.x1) };
if same(a) {
FlexAlign::Start
} else if same(c) {
FlexAlign::Center
} else if same(e) {
FlexAlign::End
} else {
FlexAlign::Start
}
};
let auto = |gap: f64, row: bool| FlexLayout {
let (a, c, e): (&dyn Fn(&Rect) -> f64, &dyn Fn(&Rect) -> f64, &dyn Fn(&Rect) -> f64) =
if row { (&|r| r.y0, &|r| r.center().y, &|r| r.y1) } else { (&|r| r.x0, &|r| r.center().x, &|r| r.x1) };
if same(a) {
FlexAlign::Start
} else if same(c) {
FlexAlign::Center
} else if same(e) {
FlexAlign::End
} else {
FlexAlign::Start
}
}
fn auto_flex(gap: f64, row: bool, align: FlexAlign) -> FlexLayout {
FlexLayout {
direction: if row { FlexDirection::Row } else { FlexDirection::Column },
justify: FlexJustify::SpaceBetween,
align: align(row),
align,
width: FlexSize::Auto,
height: FlexSize::Auto,
gap_h: if row { gap.max(0.0) } else { 0.0 },
gap_v: if row { 0.0 } else { gap.max(0.0) },
..Default::default()
};
match (in_line(true), in_line(false)) {
(Some(gap), _) => auto(gap, true),
(None, Some(gap)) => auto(gap, false),
}
}
/// InDesign's detection when objects become a container: objects in a row (or column) keep it,
/// with Auto size and Space Between at their own spacing; others wrap in a row of their width.
fn detect(bounds: &[Rect]) -> FlexLayout {
let tol = 0.5;
match (in_line_gap(bounds, true, tol), in_line_gap(bounds, false, tol)) {
(Some(gap), _) => auto_flex(gap, true, detect_align(bounds, true, tol)),
(None, Some(gap)) => auto_flex(gap, false, detect_align(bounds, false, tol)),
_ => FlexLayout { wrap: true, height: FlexSize::Auto, ..Default::default() },
}
}
+4 -1
View File
@@ -512,7 +512,10 @@ mod tests {
let (idml, _) = linked_idml(link, false).unwrap();
let (doc, seen) = crate::io::touched(|| import(&idml, Some(&package)).unwrap());
// Beside the package, and the link itself where it is on this computer.
assert!(seen.iter().all(|p| p.starts_with(&dir) || p == std::path::Path::new(link)), "{link}: {seen:?}");
// A rooted local path such as /dev/zero acquires the package's drive on Windows.
let local = std::path::Path::new(link);
let external = (local.has_root() && !link.starts_with(r"\\")).then(|| std::path::absolute(dir.join(local)).unwrap());
assert!(seen.iter().all(|p| p.starts_with(&dir) || Some(p) == external.as_ref()), "{link}: {seen:?}");
let asset = doc.assets.values().next().unwrap();
assert!(asset.data.is_empty(), "{link}");
assert_eq!(super::super::links::status(asset, None), "missing", "{link}");
+56 -6
View File
@@ -144,7 +144,7 @@ pub fn link_missing(a: &designcraft_doc::Asset, document: Option<&Path>) -> bool
let Some(link) = &a.link else { return false };
#[cfg(not(target_arch = "wasm32"))]
{
crate::io::document_path(link, document).map_or(true, |path| !crate::io::exists(path))
!crate::io::document_path(link, document).is_ok_and(crate::io::exists)
}
#[cfg(target_arch = "wasm32")]
{
@@ -396,8 +396,23 @@ fn reimport(d: &mut Document, sid: StoryId, path: &str, bytes: &[u8], before: Fi
#[cfg(not(target_arch = "wasm32"))]
fn reload(path: impl AsRef<Path>) -> Result<(Vec<u8>, (u32, u32))> {
reload_capped(path, super::interchange::MAX_LINKED_FILE_BYTES)
}
/// A linked graphic read again from disk, and its pixel size, reading at most `cap` bytes.
///
/// How large a linked file is belongs to whoever wrote the document, not to whoever opens it: the
/// path comes out of the document, and Update, Relink and Relink Folder all follow it. An
/// unbounded `read` of a link pointing at a multi-GB file exhausted memory (#1596). The cap is
/// the one packaging already applies to a linked file, and a file over it is refused by name
/// rather than read.
#[cfg(not(target_arch = "wasm32"))]
fn reload_capped(path: impl AsRef<Path>, cap: u64) -> Result<(Vec<u8>, (u32, u32))> {
let path = path.as_ref();
let b = crate::io::read_file(path).map_err(EngineError::Other)?;
let b = crate::io::read_capped(path, cap).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?;
if b.len() as u64 > cap {
return Err(bad("links", format!("{}: the linked file is larger than {} MB", path.display(), cap / (1024 * 1024))));
}
if designcraft_render::is_pdf(&b) {
designcraft_render::check_pdf_input_size(b.len()).map_err(|e| bad("links", format!("{}: {e}", path.display())))?;
}
@@ -480,7 +495,7 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result<Value> {
continue;
}
let old = std::path::PathBuf::from(a.link.clone().unwrap_or_else(|| a.name.clone()));
let mut name = old.file_name().map(|n| n.to_os_string()).unwrap_or_else(|| a.name.clone().into());
let mut name = old.file_name().map(std::ffi::OsStr::to_os_string).unwrap_or_else(|| a.name.clone().into());
if let Some(e) = &ext {
name = std::path::Path::new(&name).with_extension(e).into_os_string();
}
@@ -490,8 +505,18 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result<Value> {
Some(dir) => Some(dir.clone()),
None => a.link.as_deref().and_then(|l| crate::io::document_path(l, document).ok()).and_then(|p| p.parent().map(Path::to_path_buf)),
};
let plain_name = name.to_str().is_some_and(|n| !n.contains(['/', '\\']) && craft_docpath::is_local(n));
let cand = folder.filter(|_| plain_name).map(|f| f.join(&name));
let plain_name = name.to_str().filter(|n| !n.contains(['/', '\\']));
// A name that resolves to another file name (a `file:` URL such as `file:%2Ftmp%2Fx.png`,
// a drive-relative `C:x.png`) or a folder the stored-path rules read differently (`a\b`
// on Unix, `alias/..` through a symlink) could lead outside the folder, so only the name
// itself, directly in the folder as the file system resolves it, counts. The link keeps
// the folder as given.
let cand = folder.zip(plain_name).and_then(|(f, n)| {
let c = crate::io::folder_path(n, &f).ok()?;
let real = |p: &Path| crate::io::canonicalize(p).ok();
let same_folder = c.parent().and_then(real).is_some_and(|p| real(&f) == Some(p));
(c.file_name().is_some_and(|c| c == n) && same_folder).then_some(c)
});
if let Some(cand) = cand.filter(|c| crate::io::exists(c)) {
todo.push((a.id, cand));
} else {
@@ -574,6 +599,31 @@ mod tests {
designcraft_render::Rendered { width: w, height: h, pixels: vec![200u8; (w * h * 4) as usize] }.to_png()
}
/// A linked file's size is the document's choice, so a link is read under a cap (#1596).
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_linked_file_over_the_cap_is_refused_instead_of_read() {
let dir = std::env::temp_dir().join(format!("dc-link-cap-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("big.png");
let bytes = png(40, 40);
std::fs::write(&path, &bytes).unwrap();
// Under the cap the file is read and measured as before.
let (read, px) = reload_capped(&path, bytes.len() as u64).expect("a file within the cap is read");
assert_eq!((read.len(), px), (bytes.len(), (40, 40)));
// Over it the file is refused by name, and the error says what the limit is.
let err = reload_capped(&path, (bytes.len() - 1) as u64).expect_err("a file over the cap is refused");
let msg = err.to_string();
assert!(msg.contains("big.png") && msg.contains("larger than"), "the error names the file and the limit: {msg}");
// The real cap is the one packaging uses, so an ordinary graphic still loads.
assert!(reload(&path).is_ok(), "a normal linked graphic is unaffected");
let _ = std::fs::remove_dir_all(&dir);
}
fn pdf(sizes: &[(u32, u32)]) -> Vec<u8> {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": sizes.len(), "facingPages": false})).unwrap();
@@ -808,7 +858,7 @@ mod tests {
assert_eq!(a.pixels, Some((60, 20)));
assert_eq!(a.mime, "application/postscript");
assert_eq!(a.data.as_slice(), eps(30).as_bytes());
let moved = dir.join("new/replacement.eps");
let moved = dir.join("new").join("replacement.eps");
std::fs::rename(&replacement, &moved).unwrap();
assert_eq!(s.execute("links.relinkFolder", &json!({"dir": dir.join("new").to_string_lossy()})).unwrap()["relinked"], 1);
assert_eq!(s.doc().unwrap().doc.assets[&aid].link.as_deref(), moved.to_str());
+265 -2
View File
@@ -1,13 +1,37 @@
//! Type › Bulleted & Numbered Lists › Define Lists: named lists whose numbers can continue from
//! story to story. Paragraphs join one with `listName` (and restart with `startAt`).
use designcraft_doc::NumberedList;
use designcraft_doc::{ListType, NumberedList};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, str_param};
use super::{CommandSpec, bad, cmd, has_doc, has_text_or_frames, str_param};
use crate::{Result, Session};
/// The hanging indent a list gets from the Bulleted and Numbered List buttons, as InDesign's
/// default (Left Indent 0.25 in, First Line Indent −0.25 in): the bullet or number at the margin,
/// the text after its tab at the left indent.
pub const LIST_HANG: f64 = 18.0;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"list.bullets",
"Apply Bullets",
["Type", "Bulleted & Numbered Lists"],
None,
"{on?: bool (default: off when every selected paragraph has bullets, else on)} — the Bulleted List button: paragraphs with no indents get the default hanging indent (left 18 pt, first line −18 pt); taking bullets off removes that indent if it is still as applied",
has_text_or_frames,
|s, p| apply_list(s, p, ListType::Bullets)
),
cmd!(
"list.numbers",
"Apply Numbers",
["Type", "Bulleted & Numbered Lists"],
None,
"{on?: bool (default: off when every selected paragraph is numbered, else on)} — the Numbered List button, as list.bullets",
has_text_or_frames,
|s, p| apply_list(s, p, ListType::Numbers)
),
cmd!(
"list.define",
"Define Lists…",
@@ -48,12 +72,181 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// Bullets or numbers on (or off) for the selected paragraphs, as InDesign's Bulleted and
/// Numbered List buttons: one undo step. Values equal to the paragraph style's are left to the
/// style rather than set locally, so a paragraph back to its style shows no override.
fn apply_list(s: &mut Session, p: &Value, kind: ListType) -> Result<Value> {
let name = if kind == ListType::Bullets { "list.bullets" } else { "list.numbers" };
let on = match p.get("on") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_bool().ok_or_else(|| bad(name, "`on` must be true or false"))?),
};
let targets = super::text::format_targets_pub(s);
if targets.is_empty() {
return Err(bad(name, "select text or a text frame"));
}
// Off when every selected paragraph already has this list, as the button shows it pressed.
let on = on.unwrap_or_else(|| !s.doc().is_ok_and(|st| all_have(&st.doc, &targets, kind)));
s.edit(|d, _| {
let styles = d.styles.clone();
for t in &targets {
let Some(st) = d.text_story_mut(t.story, t.cell) else { continue };
let span = para_span(&st.text, &t.range);
st.format_paras(span, |pf| {
let (now, _) = styles.resolve_para(pf);
// Off takes away this list only: a paragraph with the other list keeps it.
if !on && now.list_type != kind {
return;
}
let (style, _) = styles.resolve_para_style(&pf.style);
// The hanging indent this button set (and nothing has changed since).
let own_hang = pf.para.list_hang == Some(true) && now.left_indent == LIST_HANG && now.first_line_indent == -LIST_HANG;
// A value: left to the style when it gives the same, else set here.
let mut set = |key: &str, v: Value, same: bool| {
let _ = pf.para.set_json(key, if same { &Value::Null } else { &v });
};
let list = if on { kind } else { ListType::None };
set("listType", serde_json::to_value(list).unwrap_or(Value::Null), style.list_type == list);
if on && now.left_indent == 0.0 && now.first_line_indent == 0.0 {
set("leftIndent", json!(LIST_HANG), style.left_indent == LIST_HANG);
set("firstLineIndent", json!(-LIST_HANG), style.first_line_indent == -LIST_HANG);
set("listHang", json!(true), false);
} else if !on && now.list_type == kind {
// Only the hanging indent set here: one the paragraph had before, or its style
// gives, stays.
if own_hang {
set("leftIndent", json!(0.0), style.left_indent == 0.0);
set("firstLineIndent", json!(0.0), style.first_line_indent == 0.0);
}
set("listHang", Value::Null, true);
}
});
}
Ok(json!({"on": on}))
})
}
/// The range whose paragraphs a list applies to: a selection ending where a paragraph starts (after
/// a return) doesn't reach into that paragraph. The story's last paragraph, empty after a final
/// return, stays in when the range runs to the end (a whole frame).
fn para_span(text: &str, range: &std::ops::Range<usize>) -> std::ops::Range<usize> {
let end = range.end.max(range.start);
if end > range.start && end < text.len() && text.as_bytes().get(end - 1) == Some(&b'\n') { range.start..end - 1 } else { range.start..end }
}
/// Whether every paragraph of `targets` has list `kind`.
fn all_have(d: &designcraft_doc::Document, targets: &[super::text::Target], kind: ListType) -> bool {
targets.iter().all(|t| {
d.text_story(t.story, t.cell).is_some_and(|st| {
let span = para_span(&st.text, &t.range);
let (a, b) = (st.para_at(span.start), st.para_at(span.end));
st.paras.get(a..=b).is_some_and(|ps| ps.iter().all(|pf| d.styles.resolve_para(pf).0.list_type == kind))
})
})
}
impl Session {
/// Whether the Bulleted or Numbered List button (and Type › Apply Bullets or Numbers) shows
/// pressed: every paragraph the list commands apply to has list `kind`, through the selected
/// text, table cells or text frames.
pub fn selection_has_list(&self, kind: ListType) -> bool {
let targets = super::text::format_targets_pub(self);
!targets.is_empty() && self.doc().is_ok_and(|st| all_have(&st.doc, &targets, kind))
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::Session;
/// The Bulleted List button, as InDesign's: bullets with the default hanging indent (the
/// bullet at the margin, the text 18 pt in); again, bullets and that indent go, back to the
/// style. Numbers swap in keeping the indent; paragraphs with their own indents keep them.
#[test]
fn list_buttons_apply_the_default_hanging_indent() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let f = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Eggs\nMilk\nFlour"})).unwrap();
let sid = f["story"].as_u64().unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 9})).unwrap();
let para = |s: &Session, i: usize| {
let d = &s.doc().unwrap().doc;
let st = d.story(designcraft_doc::StoryId(sid)).unwrap();
(d.styles.resolve_para(&st.paras[i]).0, st.paras[i].para.clone())
};
let undo = s.doc().unwrap().history.undo.len();
assert_eq!(s.execute("list.bullets", &json!({})).unwrap()["on"], true);
for i in 0..2 {
let (p, _) = para(&s, i);
assert_eq!((p.list_type, p.left_indent, p.first_line_indent), (designcraft_doc::ListType::Bullets, 18.0, -18.0));
}
assert_eq!(para(&s, 2).0.list_type, designcraft_doc::ListType::None, "the paragraph outside the selection");
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
// The number of a bullet's tab: the text starts at the left indent.
let d = &s.doc().unwrap().doc;
let cs = designcraft_compose::compose_story(d, designcraft_doc::StoryId(sid), &Default::default());
let line = &cs.frames[0].lines[0];
let text_x = line.glyphs.iter().filter(|g| g.len > 0).map(|g| g.x).next().unwrap();
assert!((text_x - line.x0 - 18.0).abs() < 0.5, "the text at the hanging indent: {} from {}", text_x, line.x0);
// Numbers instead: the indent stays.
s.execute("list.numbers", &json!({})).unwrap();
let (p, _) = para(&s, 0);
assert_eq!((p.list_type, p.left_indent), (designcraft_doc::ListType::Numbers, 18.0));
// Numbers off: list and hanging indent go, and nothing is left set on the paragraph.
assert_eq!(s.execute("list.numbers", &json!({})).unwrap()["on"], false);
let (p, own) = para(&s, 0);
assert_eq!((p.list_type, p.left_indent, p.first_line_indent), (designcraft_doc::ListType::None, 0.0, 0.0));
assert_eq!(own, designcraft_doc::ParaAttrs::default(), "back to the style");
// A paragraph with its own indents keeps them.
s.execute("type.para", &json!({"attrs": {"leftIndent": 36}})).unwrap();
s.execute("list.bullets", &json!({"on": true})).unwrap();
let (p, _) = para(&s, 0);
assert_eq!((p.left_indent, p.first_line_indent), (36.0, 0.0));
s.execute("list.bullets", &json!({"on": false})).unwrap();
assert_eq!(para(&s, 0).0.left_indent, 36.0);
assert!(s.execute("list.bullets", &json!({"on": "yes"})).is_err());
}
/// Pressed means every targeted paragraph has the list: a selection reaching an unbulleted
/// paragraph, or a frame with one, isn't. Explicitly off, a list button takes away its own
/// list only: a bulleted paragraph keeps bullets and indent when Numbers go off.
#[test]
fn a_list_is_pressed_and_turned_off_for_its_own_paragraphs() {
use designcraft_doc::ListType;
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let f = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Eggs\nMilk\nFlour"})).unwrap();
let sid = f["story"].as_u64().unwrap();
let select = |s: &mut Session, a: usize, b: usize| {
s.execute("text.select", &json!({"story": sid, "anchor": a, "focus": b})).unwrap();
};
select(&mut s, 0, 3);
s.execute("list.bullets", &json!({})).unwrap();
assert!(s.selection_has_list(ListType::Bullets));
assert!(!s.selection_has_list(ListType::Numbers));
// From the bulleted Eggs into the plain Milk: not pressed, and a press bullets both.
select(&mut s, 0, 7);
assert!(!s.selection_has_list(ListType::Bullets));
// The frame selected: its whole story, Flour unbulleted.
s.execute("selection.set", &json!({"ids": [f["id"]]})).unwrap();
assert!(!s.selection_has_list(ListType::Bullets));
// Eggs bulleted, Milk numbered; Numbers off over both leaves Eggs' bullets and indent.
select(&mut s, 5, 7);
s.execute("list.numbers", &json!({"on": true})).unwrap();
select(&mut s, 0, 7);
s.execute("list.numbers", &json!({"on": false})).unwrap();
let d = &s.doc().unwrap().doc;
let st = d.story(designcraft_doc::StoryId(sid)).unwrap();
let p = |i: usize| d.styles.resolve_para(&st.paras[i]).0;
assert_eq!((p(0).list_type, p(0).left_indent, p(0).first_line_indent), (ListType::Bullets, 18.0, -18.0));
assert_eq!((p(1).list_type, p(1).left_indent), (ListType::None, 0.0));
// With no text or frame selected, nothing is pressed.
s.execute("selection.set", &json!({"ids": []})).unwrap();
assert!(!s.selection_has_list(ListType::Bullets));
}
/// The digit the story's first line starts with (its list number).
fn digit(g: &designcraft_compose::PlacedGlyph) -> char {
let face = g.face.get();
@@ -99,4 +292,74 @@ mod tests {
s.execute("type.para", &json!({"attrs": {"startAt": null}})).unwrap();
assert_eq!(first_char(&s, stories[1]), '4');
}
/// A selection ending right after a return (the whole first paragraph, as a triple click selects
/// it) applies the list to that paragraph only, and the button shows it pressed.
#[test]
fn a_selection_ending_at_a_paragraph_start_leaves_that_paragraph_out() {
use designcraft_doc::ListType;
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let f = s
.execute(
"frame.create",
&json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Eggs
Milk
Flour
"}),
)
.unwrap();
let sid = f["story"].as_u64().unwrap();
let kinds = |s: &Session| {
let d = &s.doc().unwrap().doc;
d.story(designcraft_doc::StoryId(sid)).unwrap().paras.iter().map(|p| d.styles.resolve_para(p).0.list_type).collect::<Vec<_>>()
};
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 5})).unwrap();
assert!(!s.selection_has_list(ListType::Bullets));
assert_eq!(s.execute("list.bullets", &json!({})).unwrap()["on"], true);
assert_eq!(kinds(&s), [ListType::Bullets, ListType::None, ListType::None, ListType::None]);
assert!(s.selection_has_list(ListType::Bullets), "pressed for the paragraph selected");
// The whole frame still takes in its last, empty paragraph.
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 16})).unwrap();
s.execute("list.numbers", &json!({"on": true})).unwrap();
assert_eq!(kinds(&s), [ListType::Numbers; 4]);
}
/// A paragraph's own hanging indent, the same as the button's, stays when the list goes off:
/// the button takes away only the indent it set.
#[test]
fn a_paragraphs_own_hanging_indent_stays_when_the_list_goes_off() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let f = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Eggs"})).unwrap();
let sid = f["story"].as_u64().unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 4})).unwrap();
s.execute("type.para", &json!({"attrs": {"leftIndent": 18, "firstLineIndent": -18}})).unwrap();
s.execute("list.bullets", &json!({"on": true})).unwrap();
s.execute("list.bullets", &json!({"on": false})).unwrap();
let d = &s.doc().unwrap().doc;
let pf = &d.story(designcraft_doc::StoryId(sid)).unwrap().paras[0];
let p = d.styles.resolve_para(pf).0;
assert_eq!((p.list_type, p.left_indent, p.first_line_indent), (designcraft_doc::ListType::None, 18.0, -18.0));
assert_eq!(pf.para.list_hang, None, "nothing of the list left");
}
/// A hanging indent the paragraph style gives stays when the list goes off.
#[test]
fn a_style_hanging_indent_stays_when_the_list_goes_off() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let f = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Eggs"})).unwrap();
let sid = f["story"].as_u64().unwrap();
s.execute("style.paragraph.create", &json!({"name": "Hang", "para": {"leftIndent": 18, "firstLineIndent": -18}})).unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 4})).unwrap();
s.execute("style.paragraph.apply", &json!({"name": "Hang"})).unwrap();
s.execute("list.bullets", &json!({"on": true})).unwrap();
s.execute("list.bullets", &json!({"on": false})).unwrap();
let d = &s.doc().unwrap().doc;
let pf = &d.story(designcraft_doc::StoryId(sid)).unwrap().paras[0];
let p = d.styles.resolve_para(pf).0;
assert_eq!((p.list_type, p.left_indent, p.first_line_indent), (designcraft_doc::ListType::None, 18.0, -18.0));
assert_eq!(pf.para, designcraft_doc::ParaAttrs::default(), "nothing left set on the paragraph");
}
}
+36 -25
View File
@@ -201,9 +201,9 @@ fn plan(doc: &Document, src: &Document, paragraph: Option<&[String]>, character:
Ok((para, chars))
}
fn load(s: &mut Session, p: &Value) -> Result<Value> {
let src = source(p, ID)?;
let conflict = match p.get("conflicts") {
/// The `conflicts` parameter: how a style that differs under the same name is handled.
fn conflict_param(p: &Value) -> Result<Conflict> {
Ok(match p.get("conflicts") {
None | Some(Value::Null) => Conflict::Redefine,
Some(v) => match v.as_str() {
Some("redefine") => Conflict::Redefine,
@@ -211,7 +211,37 @@ fn load(s: &mut Session, p: &Value) -> Result<Value> {
Some("autoRename") => Conflict::AutoRename,
_ => return Err(bad(ID, "`conflicts` must be redefine, useExisting or autoRename")),
},
})
}
/// Give the loaded style `ours` the export tag the source had for `theirs` (`kind` is `p` or `c`).
fn carry_export_tag(src: &Styles, dst: &mut Styles, kind: char, theirs: &str, ours: &str) {
let tag = format!("{kind}:{ours}");
match src.export_tags.get(&format!("{kind}:{theirs}")) {
Some(t) => dst.export_tags.insert(tag, t.clone()),
None => dst.export_tags.remove(&tag),
};
}
/// A loaded style and one the document keeps can name each other as their base.
fn check_based_on_cycles(styles: &Styles, loaded: &[(bool, String)]) -> Result<()> {
for (is_para, name) in loaded {
let looped = if *is_para {
styles.para(name).and_then(|s| s.based_on.as_deref()).is_some_and(|b| styles.para_based_on_cycles(name, b))
} else {
styles.char_style(name).and_then(|s| s.based_on.as_deref()).is_some_and(|b| styles.char_based_on_cycles(name, b))
};
if looped {
let kind = if *is_para { "paragraph" } else { "character" };
return Err(bad(ID, format!("{kind} style `{name}` would be based on itself; load the style it is based on as well")));
}
}
Ok(())
}
fn load(s: &mut Session, p: &Value) -> Result<Value> {
let src = source(p, ID)?;
let conflict = conflict_param(p)?;
let (paragraph, character) = (names(p, "paragraph")?, names(p, "character")?);
let doc = s.doc()?.doc.clone();
let (mut para, mut chars) = plan(&doc, &src, paragraph.as_deref(), character.as_deref(), conflict)?;
@@ -231,11 +261,7 @@ fn load(s: &mut Session, p: &Value) -> Result<Value> {
p.nested_line_styles.iter_mut().flatten().for_each(|l| chars.follow(&mut l.style));
swatches.extend(char_swatches(&style.chars).chain(para_swatches(&style.para)).cloned());
lists.extend(style.para.list_name.iter().cloned());
let tag = format!("p:{}", style.name);
match src.styles.export_tags.get(&format!("p:{}", theirs.name)) {
Some(t) => styles.export_tags.insert(tag, t.clone()),
None => styles.export_tags.remove(&tag),
};
carry_export_tag(&src.styles, &mut styles, 'p', &theirs.name, &style.name);
loaded.push((true, style.name.clone()));
match styles.para_mut(&style.name) {
Some(m) if action == Action::Replace => {
@@ -256,11 +282,7 @@ fn load(s: &mut Session, p: &Value) -> Result<Value> {
chars.follow(&mut style.name);
style.based_on.iter_mut().for_each(|n| chars.follow(n));
swatches.extend(char_swatches(&style.chars).cloned());
let tag = format!("c:{}", style.name);
match src.styles.export_tags.get(&format!("c:{}", theirs.name)) {
Some(t) => styles.export_tags.insert(tag, t.clone()),
None => styles.export_tags.remove(&tag),
};
carry_export_tag(&src.styles, &mut styles, 'c', &theirs.name, &style.name);
loaded.push((false, style.name.clone()));
match styles.char_style_mut(&style.name) {
Some(m) if action == Action::Replace => {
@@ -275,18 +297,7 @@ fn load(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
// A loaded style and one the document keeps can name each other as their base.
for (is_para, name) in &loaded {
let looped = if *is_para {
styles.para(name).and_then(|s| s.based_on.as_deref()).is_some_and(|b| styles.para_based_on_cycles(name, b))
} else {
styles.char_style(name).and_then(|s| s.based_on.as_deref()).is_some_and(|b| styles.char_based_on_cycles(name, b))
};
if looped {
let kind = if *is_para { "paragraph" } else { "character" };
return Err(bad(ID, format!("{kind} style `{name}` would be based on itself; load the style it is based on as well")));
}
}
check_based_on_cycles(&styles, &loaded)?;
let swatches = missing_swatches(&doc, &src, swatches);
let lists: Vec<NumberedList> = {
+1 -1
View File
@@ -6,7 +6,7 @@ mod articles;
pub mod book;
mod buttons;
pub(crate) mod captions;
mod changes;
pub(crate) mod changes;
mod color_theme;
mod colorsettings;
mod conditions;
+11
View File
@@ -221,4 +221,15 @@ mod tests {
// Caret is now in footnote text: no nested footnotes.
assert!(s.execute("footnote.insert", &json!({})).is_err());
}
#[test]
fn huge_start_at_does_not_overflow() {
let (mut s, sid) = session_with_text();
s.execute("text.select", &json!({"story": sid.0, "anchor": 16, "focus": 16})).unwrap();
s.execute("footnote.insert", &json!({"text": "x"})).unwrap();
s.execute("footnote.options", &json!({"startAt": 4294967295u32})).unwrap();
let d = s.doc().unwrap().doc.clone();
let cs = s.cache.get(&d, sid, None);
assert_eq!(cs.frames[0].notes.len(), 1);
}
}
+106 -2
View File
@@ -584,7 +584,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Effects",
[],
None,
"{effects?: {dropShadow?: {on?, color?, opacity?, angle?, globalLight?, distance?, size?, spread?}, innerShadow?, outerGlow?, innerGlow? {on?, color?, opacity?, size?, choke?, center?, mode? (blend mode: Screen, Multiply, …; Screen by default), technique?: softer|precise}, bevel?, satin?, feather? (width), featherOptions? {choke? (%), corners?: sharp|rounded|diffused}, directionalFeather?, gradientFeather?} (the fields given replace the object's, as the Effects dialog sets them), opacity?: 0..1, blend?, ids?} — every effect at once, one undo step",
"{effects?: {dropShadow?: {on?, color?, opacity?, angle?, globalLight?, distance?, size?, spread?}, innerShadow?, outerGlow?, innerGlow? {on?, color?, opacity?, size?, choke?, center?, mode? (blend mode: Screen, Multiply, …; Screen by default), technique?: softer|precise}, bevel?, satin?, feather? (width), featherOptions? {choke? (%), corners?: sharp|rounded|diffused}, directionalFeather?, gradientFeather?} (the fields given replace the object's, as the Effects dialog sets them), opacity?: 0..1, blend?, globalLight? (the document's global light angle, as an Angle field with Use Global Light sets it), ids?} — every effect at once, one undo step",
has_selection,
effects_all
),
@@ -598,7 +598,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Text Frame Options…",
["Object"],
Some("Cmd+B"),
"{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, columnsKind?: fixedNumber|fixedWidth, columnWidth? (pt, Fixed Width), firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin? (pt), autoSize?: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference?: topLeft|topCenter|topRight|leftCenter|center|rightCenter|bottomLeft|bottomCenter|bottomRight (the point that stays put), useMinimumHeight?: bool, minimumHeight? (pt), useMinimumWidth?: bool, minimumWidth? (pt), noLineBreaks?: bool, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}",
"{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, columnsKind?: fixedNumber|fixedWidth, columnWidth? (pt, Fixed Width), firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin? (pt), autoSize?: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference?: topLeft|topCenter|topRight|leftCenter|center|rightCenter|bottomLeft|bottomCenter|bottomRight (the point that stays put), useMinimumHeight?: bool, minimumHeight? (pt), useMinimumWidth?: bool, minimumWidth? (pt), noLineBreaks?: bool, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), baselineGrid?: {start? (pt), increment? (pt), relativeTo?: page|margin|frame|inset} (the frame's custom baseline grid; fields not given keep the frame's, or come from the document grid; null turns it off), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}",
has_selection,
text_frame_options
),
@@ -2293,8 +2293,16 @@ fn effects_all(s: &mut Session, p: &Value) -> Result<Value> {
Some(BlendMode::parse(raw).ok_or_else(|| bad(ID, format!("unknown blend mode `{raw}`")))?)
}
};
// The document's global light angle, changed from an Angle field with Use Global Light on.
let global_light = match p.get("globalLight") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_f64().filter(|a| a.is_finite()).ok_or_else(|| bad(ID, "globalLight must be a number"))?),
};
let ids = targets(s, p)?;
s.edit(|d, _| {
if let Some(a) = global_light {
d.settings.global_light = (a + 180.0).rem_euclid(360.0) - 180.0;
}
let mut changed = 0;
for id in &ids {
let Some(it) = d.item_mut(*id) else { continue };
@@ -2506,6 +2514,41 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result<Value> {
as u8,
),
};
// `Some(None)` turns the frame's custom baseline grid off.
let custom_grid = match p.get("baselineGrid") {
None => None,
Some(Value::Null) => Some(None),
Some(Value::Object(g)) => {
let num = |key: &str, lo: f64, hi: f64| match g.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => match v.as_f64() {
Some(x) if x.is_finite() && (lo..=hi).contains(&x) => Ok(Some(x)),
_ => Err(bad(ID, format!("baselineGrid.{key} must be a number of points from {lo} to {hi}, not {v}"))),
},
};
let start = num("start", -15552.0, 15552.0)?;
let increment = num("increment", 0.0, 15552.0)?;
let relative_to = match g.get("relativeTo") {
None | Some(Value::Null) => None,
Some(v) => Some(
v.as_str()
.and_then(designcraft_doc::FrameGridRelative::from_name)
.ok_or_else(|| bad(ID, format!("baselineGrid.relativeTo must be page|margin|frame|inset, not {v}")))?,
),
};
Some(Some((start, increment, relative_to)))
}
Some(v) => return Err(bad(ID, format!("baselineGrid must be an object or null, not {v}"))),
};
// A frame without a custom grid starts from the document's.
let doc_grid = {
let g = &st.doc.settings.baseline_grid;
let relative_to = match g.relative_to {
designcraft_doc::GridRelative::TopOfPage => designcraft_doc::FrameGridRelative::TopOfPage,
designcraft_doc::GridRelative::TopMargin => designcraft_doc::FrameGridRelative::TopMargin,
};
designcraft_doc::FrameBaselineGrid { start: g.start, increment: g.increment, relative_to }
};
let rule_color = match p.get("columnRuleColor") {
None | Some(Value::Null) => None,
Some(Value::String(c)) if st.doc.swatch(c).is_some() => Some(c.clone()),
@@ -2596,6 +2639,17 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = rule_bottom {
o.column_rule_bottom_inset = v;
}
match custom_grid {
Some(Some((start, increment, relative_to))) => {
let mut g = o.baseline_grid.unwrap_or(doc_grid);
g.start = start.unwrap_or(g.start);
g.increment = increment.unwrap_or(g.increment);
g.relative_to = relative_to.unwrap_or(g.relative_to);
o.baseline_grid = Some(g);
}
Some(None) => o.baseline_grid = None,
None => {}
}
// Fixed Width columns set the frame's width: n columns, the gutters between them and
// the left and right insets.
let fixed_width = o.columns_kind == designcraft_doc::ColumnsKind::FixedWidth && o.column_width > 0.0;
@@ -4099,6 +4153,26 @@ mod light_tests {
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(angle(&s), 45.0);
}
/// The Effects dialog's Angle with Use Global Light on changes the global light, with the
/// effects, in one undo step.
#[test]
fn effects_sets_the_global_light_with_the_effects() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = s.execute("frame.create", &json!({"rect": [10, 10, 50, 50]})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("frame.create", &json!({"rect": [60, 10, 90, 50]})).unwrap()["id"].as_u64().unwrap();
s.execute("object.dropShadow", &json!({"on": true, "globalLight": true, "ids": [b]})).unwrap();
let before = s.doc().unwrap().doc.settings.global_light;
s.execute("object.effects", &json!({"ids": [a], "effects": {"dropShadow": {"on": true, "globalLight": true}}, "globalLight": 30})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.settings.global_light, 30.0);
let other = &d.item(ItemId(b)).unwrap().effects.drop_shadow;
assert_eq!(d.light_angle(other.angle, other.global_light), 30.0, "every shadow on Global Light follows");
assert!(s.execute("object.effects", &json!({"ids": [a], "globalLight": "up"})).is_err());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.settings.global_light, before, "one undo step");
}
}
#[cfg(test)]
@@ -5609,3 +5683,33 @@ mod fill_tint_tests {
assert!(stroke_of(&s, b).is_none());
}
}
#[cfg(test)]
mod frame_baseline_grid_tests {
use super::*;
use designcraft_doc::{FrameBaselineGrid, FrameGridRelative};
#[test]
fn text_frame_options_set_a_custom_baseline_grid_relative_to() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Hello"})).unwrap();
let id = ItemId(r["id"].as_u64().unwrap());
let grid = |s: &Session| s.doc().unwrap().doc.item(id).and_then(|i| i.text_frame()).and_then(|t| t.options.baseline_grid);
// Turning it on with only Relative To takes the document grid's start and increment.
let doc = s.doc().unwrap().doc.settings.baseline_grid.clone();
s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": {"relativeTo": "frame"}})).unwrap();
assert_eq!(grid(&s), Some(FrameBaselineGrid { start: doc.start, increment: doc.increment, relative_to: FrameGridRelative::TopOfFrame }));
s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": {"start": 10, "increment": 13, "relativeTo": "inset"}})).unwrap();
assert_eq!(grid(&s), Some(FrameBaselineGrid { start: 10.0, increment: 13.0, relative_to: FrameGridRelative::TopInset }));
// Fields not given keep the frame's.
s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": {"relativeTo": "margin"}})).unwrap();
assert_eq!(grid(&s), Some(FrameBaselineGrid { start: 10.0, increment: 13.0, relative_to: FrameGridRelative::TopMargin }));
for bad in [json!({"relativeTo": "spine"}), json!({"start": f64::MAX}), json!({"increment": -1}), json!(3)] {
assert!(s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": bad})).is_err(), "{bad}");
}
assert_eq!(grid(&s).map(|g| g.relative_to), Some(FrameGridRelative::TopMargin));
s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": null})).unwrap();
assert_eq!(grid(&s), None);
}
}
+19 -7
View File
@@ -907,13 +907,12 @@ mod tests {
let outside = base.join("separate");
std::fs::create_dir_all(&outside).unwrap();
let redirected = base.join("Package");
match symlink_dir(&outside, &redirected) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
let _ = std::fs::remove_dir_all(&base);
return;
}
Err(e) => panic!("could not create directory redirect: {e}"),
// A symbolic link needs Developer Mode or elevation (os error 1314 without them); a
// directory junction needs neither, and `is_redirect` treats both as redirects.
if symlink_dir(&outside, &redirected).is_err() && !make_junction(&outside, &redirected) {
eprintln!("skipped: directory redirects can't be made here");
let _ = std::fs::remove_dir_all(&base);
return;
}
let mut s = Session::new();
s.execute("file.new", &json!({"title": "Annual"})).unwrap();
@@ -922,4 +921,17 @@ mod tests {
std::fs::remove_dir(&redirected).unwrap();
let _ = std::fs::remove_dir_all(&base);
}
/// Makes `link` a directory junction to `target` with `mklink /J`, and reports whether it was made.
#[cfg(windows)]
fn make_junction(target: &Path, link: &Path) -> bool {
use std::os::windows::process::CommandExt;
// Windows paths can't hold `"`, so quoting each one keeps `&` and spaces in it literal for cmd.
std::process::Command::new("cmd")
.args(["/D", "/C"])
.raw_arg(format!("mklink /J \"{}\" \"{}\"", link.display(), target.display()))
.output()
.is_ok_and(|output| output.status.success())
}
}
+6 -3
View File
@@ -27,7 +27,7 @@ pub fn is_svg_markup(text: &str) -> bool {
designcraft_images::is_svg(text.as_bytes())
}
/// Nesting deeper than this is flattened into its parent (hostile input can't exhaust the stack).
/// Cap conversion nesting too; the shared image parser bounds XML before usvg runs.
const MAX_DEPTH: usize = 64;
/// A converted SVG node, in points.
@@ -426,13 +426,16 @@ mod tests {
#[test]
fn deeply_nested_groups_do_not_exhaust_the_stack() {
let mut s = session();
let before = serde_json::to_value(&s.doc().unwrap().doc).unwrap();
let undo = s.doc().unwrap().history.undo.len();
let depth = 2000;
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10">{}<rect width="10" height="10"/>{}</svg>"#,
"<g opacity=\"0.99\">".repeat(depth),
"</g>".repeat(depth)
);
// usvg itself may refuse a document this deep; either way, no crash.
let _ = s.execute("edit.pasteSvg", &json!({"svg": svg}));
assert!(s.execute("edit.pasteSvg", &json!({"svg": svg})).is_err());
assert_eq!(serde_json::to_value(&s.doc().unwrap().doc).unwrap(), before);
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
}
}
+266 -4
View File
@@ -42,6 +42,22 @@ pub fn specs() -> Vec<CommandSpec> {
if ids.iter().all(|id| placed_pdf(s, *id).is_none()) {
return Err(bad("object.layerOptions", "select a placed PDF"));
}
// Validate every selected PDF before editing any item or recording undo.
// Repeated placements of the same source need only one rewrite check.
if !hidden.is_empty() {
let mut checked = std::collections::HashSet::new();
for id in &ids {
if let Some(data) = placed_pdf(s, *id)
&& checked.insert(std::sync::Arc::as_ptr(&data))
&& designcraft_render::pdf_hide_layers(&data, &hidden).is_none()
{
return Err(bad(
"object.layerOptions",
"the selected PDF layers could not be hidden; use the file defaults or replace the unsupported PDF",
));
}
}
}
s.edit(|d, _| {
for id in &ids {
if let Some(it) = d.item_mut(*id) {
@@ -63,31 +79,277 @@ pub(crate) mod tests {
/// A one-page PDF (100×100 pt) with two layers: "Blue" (left half) and "Red" (right half).
pub(crate) fn two_layer_pdf() -> Vec<u8> {
two_layer_pdf_generation(0)
}
fn two_layer_pdf_generation(generation: i32) -> Vec<u8> {
two_layer_pdf_options(generation, "", "")
}
fn two_layer_pdf_options(generation: i32, config: &str, trailer: &str) -> Vec<u8> {
let content = "/OC /a BDC 0 0 1 rg 0 0 50 100 re f EMC /OC /b BDC 1 0 0 rg 50 0 50 100 re f EMC";
let objs = [
"<</Type/Catalog/Pages 2 0 R/OCProperties<</OCGs[5 0 R 6 0 R]/D<</Order[5 0 R 6 0 R]>>>>>>".to_string(),
format!(
"<</Type/Catalog/Lang(en-GB)/Metadata 7 0 R/OutputIntents[8 0 R]/Pages 2 0 R/OCProperties<</OCGs[5 0 R 6 0 R]/D<</Order[5 0 R 6 0 R]/Name(Default)/Locked[5 0 R]{config}>>>>>>"
),
"<</Type/Pages/Kids[3 0 R]/Count 1>>".to_string(),
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Resources<</Properties<</a 5 0 R/b 6 0 R>>>>/Contents 4 0 R>>".to_string(),
format!("<</Length {}>>\nstream\n{content}\nendstream", content.len()),
"<</Type/OCG/Name(Blue)>>".to_string(),
"<</Type/OCG/Name(Red)>>".to_string(),
"<</Type/Metadata/Subtype/XML/Length 4>>\nstream\n<x/>\nendstream".to_string(),
"<</Type/OutputIntent/S/GTS_PDFX/OutputConditionIdentifier(Test)>>".to_string(),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
out.extend_from_slice(format!("{} {generation} obj\n{}\nendobj\n", i + 1, o.replace(" 0 R", &format!(" {generation} R"))).as_bytes());
}
let x = out.len();
let mut t = format!("xref\n0 {}\n0000000000 65535 f\r\n", objs.len() + 1);
for o in offs {
t.push_str(&format!("{o:010} 00000 n\r\n"));
t.push_str(&format!("{o:010} {generation:05} n\r\n"));
}
t.push_str(&format!("trailer\n<</Size {}/Root 1 0 R>>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1));
t.push_str(&format!("trailer\n<</Size {}/Root 1 {generation} R{trailer}>>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1));
out.extend_from_slice(t.as_bytes());
out
}
#[test]
fn pdf_layer_update_preserves_catalog_configuration_and_generations() {
for generation in [0, 1] {
let original = two_layer_pdf_generation(generation);
let updated = designcraft_render::pdf_hide_layers(&original, &["Red".into()]).unwrap();
assert_eq!(designcraft_render::pdf_layers(&updated), vec![("Blue".into(), true), ("Red".into(), false)]);
let appended = std::str::from_utf8(&updated[original.len()..]).unwrap();
for token in [
"/Lang(en-GB)".to_string(),
format!("/Metadata 7 {generation} R"),
format!("/OutputIntents[8 {generation} R]"),
format!("/Locked[5 {generation} R]"),
"/Name(Default)".into(),
format!("/Root 1 {generation} R"),
] {
assert!(appended.contains(&token), "missing {token}: {appended}");
}
assert!(!appended.contains("9999000"));
let all_on = designcraft_render::pdf_hide_layers(&updated, &[]).unwrap();
assert!(designcraft_render::pdf_layers(&all_on).iter().all(|(_, visible)| *visible));
}
}
#[test]
fn pdf_layer_update_accepts_xref_streams_and_rejects_unsupported_updates() {
let original = two_layer_pdf();
let text = std::str::from_utf8(&original).unwrap();
let offset: usize = text.rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse().unwrap();
let mut stream_pdf = original[..offset].to_vec();
let mut entries = vec![0, 0, 0, 0, 0, 255, 255];
for number in 1..=9 {
let at = if number == 9 { offset } else { text.find(&format!("{number} 0 obj")).unwrap() };
entries.push(1);
entries.extend_from_slice(&u32::try_from(at).unwrap().to_be_bytes());
entries.extend_from_slice(&[0, 0]);
}
stream_pdf.extend_from_slice(format!("9 0 obj\n<</Type/XRef/Size 10/Root 1 0 R/W[1 4 2]/Length {}>>\nstream\n", entries.len()).as_bytes());
stream_pdf.extend_from_slice(&entries);
stream_pdf.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{offset}\n%%EOF\n").as_bytes());
let updated = designcraft_render::pdf_hide_layers(&stream_pdf, &["Red".into()]).unwrap();
assert_eq!(designcraft_render::pdf_layers(&updated), vec![("Blue".into(), true), ("Red".into(), false)]);
for unsupported in ["/Encrypt 7 0 R", "/Encrypt << /Filter /Standard >>", "/XRefStm 0"] {
let altered = text.replace("/Size 9", &format!("{unsupported}/Size 9"));
assert!(designcraft_render::pdf_hide_layers(altered.as_bytes(), &["Red".into()]).is_none());
}
}
#[test]
fn pdf_layer_update_follows_forward_prev_and_rejects_cycles() {
let original = two_layer_pdf();
let text = std::str::from_utf8(&original).unwrap();
let first = original.len();
let link = |prev: usize| format!("xref\n0 1\n0000000000 65535 f\ntrailer<</Size 9/Root 1 0 R/Prev {prev:010}>>\n");
let main = first + link(0).len();
let mut forward = original.clone();
forward.extend_from_slice(link(main).as_bytes());
let old: usize = text.rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse().unwrap();
forward.extend_from_slice(text[old..].split("startxref").next().unwrap().as_bytes());
forward.extend_from_slice(format!("startxref\n{first}\n%%EOF\n").as_bytes());
let updated = designcraft_render::pdf_hide_layers(&forward, &["Red".into()]).unwrap();
assert_eq!(designcraft_render::pdf_layers(&updated), vec![("Blue".into(), true), ("Red".into(), false)]);
let mut cyclic = original;
cyclic.extend_from_slice(link(first).as_bytes());
cyclic.extend_from_slice(format!("startxref\n{first}\n%%EOF\n").as_bytes());
assert!(designcraft_render::pdf_hide_layers(&cyclic, &[]).is_none());
}
fn add_pdf_revision(bytes: &mut Vec<u8>) {
let text = std::str::from_utf8(bytes).unwrap();
let prev: usize = text.rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse().unwrap();
let xref = bytes.len();
bytes.extend_from_slice(
format!("xref\n0 1\n0000000000 65535 f\r\ntrailer\n<</Size 9/Root 1 0 R/Prev {prev}>>\nstartxref\n{xref}\n%%EOF\n").as_bytes(),
);
}
fn place(s: &mut Session, bytes: &[u8], x: f64) -> serde_json::Value {
s.execute("file.place", &json!({"base64": super::super::file::base64_encode(bytes), "name": "layers.pdf", "x": x, "y": 0, "width": 100}))
.unwrap()["id"]
.clone()
}
fn pixel(s: &Session, x: u32) -> [u8; 4] {
let mut renderer = designcraft_render::Renderer::new();
renderer.threads = 0;
renderer.render_page(&s.doc().unwrap().doc, &s.cache, 0, 1.0, false, &Default::default()).unwrap().pixel(x, 50)
}
#[test]
fn null_encryption_entry_keeps_layer_editing_rendering_and_export_working() {
for generation in [0, 1] {
let bytes = two_layer_pdf_options(generation, "", "/Encrypt null");
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap();
let id = place(&mut s, &bytes, 0.0);
s.execute("object.layerOptions", &json!({"ids": [id], "hidden": ["Red"]})).unwrap();
assert_eq!(s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["visible"], false);
assert_eq!(pixel(&s, 25), [0, 0, 255, 255]);
assert_eq!(pixel(&s, 75), [255, 255, 255, 255]);
s.execute("file.exportPdf", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(pixel(&s, 75), [255, 0, 0, 255]);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(pixel(&s, 75), [255, 255, 255, 255]);
}
}
#[test]
fn unrewritable_layer_choices_are_rejected_atomically_without_an_undo_step() {
let mut unsupported = two_layer_pdf();
for _ in 0..32 {
add_pdf_revision(&mut unsupported);
}
assert_eq!(designcraft_render::pdf_layers(&unsupported).len(), 2);
assert!(designcraft_render::pdf_hide_layers(&unsupported, &["Red".into()]).is_none());
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100, "pages": 1})).unwrap();
let good = place(&mut s, &two_layer_pdf(), 0.0);
let bad = place(&mut s, &unsupported, 100.0);
s.execute("object.layerOptions", &json!({"ids": [good], "hidden": ["Blue"]})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert!(!s.doc().unwrap().history.redo.is_empty());
let before = s.execute("file.serialize", &json!({})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
let redo = s.doc().unwrap().history.redo.len();
for ids in [json!([good, bad]), json!([bad, good])] {
let err = s.execute("object.layerOptions", &json!({"ids": ids, "hidden": ["Red"]})).unwrap_err();
assert!(err.to_string().contains("could not be hidden"));
assert_eq!(s.execute("file.serialize", &json!({})).unwrap(), before);
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
assert_eq!(s.doc().unwrap().history.redo.len(), redo);
for id in [&good, &bad] {
assert_eq!(s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["visible"], true);
}
}
assert_eq!(pixel(&s, 75), [255, 0, 0, 255]);
assert_eq!(pixel(&s, 175), [255, 0, 0, 255]);
// A persisted/relinked choice can be unrewritable. Reset must still work.
let bad_id = serde_json::from_value(bad.clone()).unwrap();
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).item_mut(bad_id).unwrap().pdf_hidden_layers = vec!["Red".into()];
assert_eq!(pixel(&s, 175), [255, 255, 255, 255], "stale hidden content must not reappear on canvas");
assert!(s.execute("file.exportPdf", &json!({})).is_err());
s.execute("object.layerOptions", &json!({"ids": [good, bad], "hidden": []})).unwrap();
assert_eq!(s.execute("object.pdfLayers", &json!({"id": bad})).unwrap()[1]["visible"], true);
s.execute("file.exportPdf", &json!({})).unwrap();
}
#[test]
fn unreadable_non_null_prev_entries_refuse_layer_rewrites() {
for prev in ["7 0 R", "-1", "false", "<<>>", "[]", "/Unknown", "(unknown)"] {
let bytes = two_layer_pdf_options(0, "", &format!("/Prev {prev}"));
assert_eq!(designcraft_render::pdf_layers(&bytes).len(), 2, "reader control: {prev}");
assert!(designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).is_none(), "must not truncate unknown history: {prev}");
}
for trailer in ["", "/Prev null"] {
let bytes = two_layer_pdf_options(0, "", trailer);
assert!(designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).is_some(), "end-of-chain control: {trailer}");
}
let mut revised = two_layer_pdf();
let prev =
std::str::from_utf8(&revised).unwrap().rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse::<usize>().unwrap();
add_pdf_revision(&mut revised);
let real_prev = std::str::from_utf8(&revised).unwrap().replace(&format!("/Prev {prev}>>"), &format!("/Prev {prev}.0>>"));
assert!(real_prev.contains(&format!("/Prev {prev}.0>>")));
for bytes in [revised, real_prev.into_bytes()] {
assert!(designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).is_some(), "readable integer/real history must still work");
}
}
#[test]
fn fake_null_trailer_cannot_bypass_hybrid_rejection() {
for real_guard in ["", "/XRefStm 0"] {
let bytes = two_layer_pdf_options(0, "", real_guard);
let text = std::str::from_utf8(&bytes).unwrap();
for fake in ["/Encrypt null", ""] {
let comment = format!("% trailer <</Size 9/Root 1 0 R{fake}>>\n");
let inside = text.replacen("xref\n", &format!("xref\n{comment}"), 1);
assert!(inside.contains(&format!("xref\n{comment}")));
// Repair-only comments inside a table are always refused, guard or not.
assert_eq!(designcraft_render::pdf_layers(inside.as_bytes()).len(), 2);
assert!(designcraft_render::pdf_hide_layers(inside.as_bytes(), &["Red".into()]).is_none());
// Before xref or after the dictionary, the real trailer decides.
let leading = text.replacen("xref\n", &format!("{comment}xref\n"), 1);
let trailing = text.replacen("startxref\n", &format!("{comment}startxref\n"), 1);
for source in [leading, trailing] {
assert_eq!(designcraft_render::pdf_layers(source.as_bytes()).len(), 2);
assert_eq!(designcraft_render::pdf_hide_layers(source.as_bytes(), &["Red".into()]).is_some(), real_guard.is_empty());
}
}
}
}
#[test]
fn content_all_still_measures_layerless_and_stub_properties() {
use designcraft_doc::PdfCrop;
for props in ["", "/OCProperties null", "/OCProperties<<>>", "/OCProperties<</OCGs[]/D<<>>>>"] {
let content = "0 0 1 rg 0 0 50 100 re f";
let objs = [
format!("<</Type/Catalog/Pages 2 0 R{props}>>"),
"<</Type/Pages/Kids[3 0 R]/Count 1>>".into(),
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Contents 4 0 R>>".into(),
format!("<</Length {}>>\nstream\n{content}\nendstream", content.len()),
];
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, body) in objs.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes());
}
let xref = bytes.len();
bytes.extend_from_slice(b"xref\n0 5\n0000000000 65535 f\r\n");
for at in offsets {
bytes.extend_from_slice(format!("{at:010} 00000 n\r\n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<</Size 5/Root 1 0 R>>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
assert!(designcraft_render::pdf_layers(&bytes).is_empty());
assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentAll).unwrap().1, (50.0, 100.0), "{props}");
}
}
#[test]
fn content_all_keeps_the_whole_page_when_layer_rewriting_is_unsupported() {
use designcraft_doc::PdfCrop;
let mut bytes = two_layer_pdf_options(0, "/BaseState/OFF/ON[5 0 R]", "");
assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentVisible).unwrap().1, (50.0, 100.0));
assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentAll).unwrap().1, (100.0, 100.0));
for _ in 0..32 {
add_pdf_revision(&mut bytes);
}
assert!(designcraft_render::pdf_hide_layers(&bytes, &[]).is_none());
assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentVisible).unwrap().1, (50.0, 100.0));
assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentAll).unwrap(), ([0.0, 0.0, 1.0, 1.0], (100.0, 100.0)));
}
#[test]
fn placed_pdf_layers_hide() {
let mut s = Session::new();
+4 -2
View File
@@ -348,17 +348,19 @@ mod placed_vector_tests {
/// file is still missing.
#[test]
fn a_missing_link_shown_by_its_preview_is_still_reported() {
let dir = tempfile::tempdir().unwrap();
let missing_path = dir.path().join("missing dir").join("photo.jpg");
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png();
s.execute("file.place", &json!({"base64": base64_encode(&png), "name": "photo.jpg", "x": 72, "y": 72, "width": 20})).unwrap();
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let asset = doc.assets.values_mut().next().unwrap();
std::sync::Arc::make_mut(asset).link = Some("/definitely/missing dir/photo.jpg".into());
std::sync::Arc::make_mut(asset).link = Some(missing_path.to_str().unwrap().into());
let r = s.execute("preflight.run", &json!({})).unwrap();
let missing: Vec<&str> =
r["issues"].as_array().unwrap().iter().filter(|i| i["kind"] == "missingLink").map(|i| i["message"].as_str().unwrap()).collect();
assert_eq!(missing, ["Missing link: /definitely/missing dir/photo.jpg"]);
assert_eq!(missing, [format!("Missing link: {}", missing_path.display())]);
}
}
+4 -1
View File
@@ -84,7 +84,10 @@ mod tests {
assert!(command.contains(&"print-scaling=auto-fit"));
assert!(!command.iter().any(|a| a.starts_with("sides=")));
} else {
assert_eq!(command[0], "powershell");
assert_eq!(
command,
["xps-print", "printer=Office", "copies=2", "collate=true", "duplex=(queue default)", "scaling=shrink-to-fit", "color=color"]
);
}
assert!(r["bytes"].as_u64().unwrap() > 500);
assert!(s.execute("file.print", &json!({"pages": "9", "dryRun": true})).is_err());
+2 -2
View File
@@ -302,7 +302,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Paragraph Style…",
[],
None,
"{name, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, firstLineIndent, spaceBefore, spaceAfter, …; startAt?: null = continue numbering; grepStyles?: [{style, pattern}]: a pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}, fromSelection?: bool}",
"{name, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, firstLineIndent, spaceBefore, spaceAfter, ignoreOpticalMargin, …; startAt?: null = continue numbering; grepStyles?: [{style, pattern}]: a pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}, fromSelection?: bool}",
has_doc,
create_para
),
@@ -311,7 +311,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Paragraph Style Options…",
[],
None,
"{name, rename?, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, spaceBefore, …; ruleAbove?/ruleBelow?: only the named rule fields change; startAt?: null = continue numbering; kinsoku?: set | null (default rules); a grepStyles pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}}",
"{name, rename?, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, spaceBefore, ignoreOpticalMargin, …; ruleAbove?/ruleBelow?: only the named rule fields change; startAt?: null = continue numbering; kinsoku?: set | null (default rules); a grepStyles pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}}",
has_doc,
edit_para
),
+51 -18
View File
@@ -1273,6 +1273,35 @@ fn resize_track(sizes: &[f64], index: usize, delta: f64, keep: bool) -> Option<V
out.iter().all(|v| v.is_finite()).then_some(out)
}
/// The frame a border drag names must hold the table's story and be editable.
fn check_border_frame(st: &crate::DocState, p: &Value, story: designcraft_doc::StoryId) -> Result<()> {
const ID: &str = "table.resizeBorder";
if let Some(fid) = super::id_param(p, "frame") {
let it = st.doc.item(fid).ok_or_else(|| bad(ID, format!("no frame {}", fid.0)))?;
if it.text_frame().map(|f| f.story) != Some(story) {
return Err(bad(ID, "the table is not in that frame's story"));
}
if it.locked || st.doc.layer(it.layer).is_some_and(|l| l.locked) {
return Err(bad(ID, "the frame or its layer is locked"));
}
}
Ok(())
}
/// The change in track size: `delta`, or `size` measured from the current size `cur`.
fn border_delta(p: &Value, cur: f64) -> Result<f64> {
const ID: &str = "table.resizeBorder";
let delta = match (p.get("delta").and_then(Value::as_f64), p.get("size").and_then(Value::as_f64)) {
(Some(d), _) => d,
(None, Some(sz)) => sz - cur,
(None, None) => return Err(bad(ID, "`delta` or `size` required")),
};
if !delta.is_finite() {
return Err(bad(ID, "`delta`/`size` must be a finite number"));
}
Ok(delta)
}
fn resize_border(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "table.resizeBorder";
let column = match str_param(p, "axis") {
@@ -1284,15 +1313,7 @@ fn resize_border(s: &mut Session, p: &Value) -> Result<Value> {
let keep = p.get("keepTableSize").and_then(Value::as_bool).unwrap_or(false);
let g = target(s, p, ID)?;
let st = s.doc()?;
if let Some(fid) = super::id_param(p, "frame") {
let it = st.doc.item(fid).ok_or_else(|| bad(ID, format!("no frame {}", fid.0)))?;
if it.text_frame().map(|f| f.story) != Some(g.story) {
return Err(bad(ID, "the table is not in that frame's story"));
}
if it.locked || st.doc.layer(it.layer).is_some_and(|l| l.locked) {
return Err(bad(ID, "the frame or its layer is locked"));
}
}
check_border_frame(st, p, g.story)?;
let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad(ID, "no table"))?;
let sizes: Vec<f64> = if column {
t.columns.iter().map(|c| c.width).collect()
@@ -1301,14 +1322,7 @@ fn resize_border(s: &mut Session, p: &Value) -> Result<Value> {
(0..t.nrows()).map(|r| composed_row_height(&cs, t, r).unwrap_or(MIN_TRACK)).collect()
};
let cur = *sizes.get(index).ok_or_else(|| bad(ID, format!("index {index} out of range")))?;
let delta = match (p.get("delta").and_then(Value::as_f64), p.get("size").and_then(Value::as_f64)) {
(Some(d), _) => d,
(None, Some(sz)) => sz - cur,
(None, None) => return Err(bad(ID, "`delta` or `size` required")),
};
if !delta.is_finite() {
return Err(bad(ID, "`delta`/`size` must be a finite number"));
}
let delta = border_delta(p, cur)?;
let new = resize_track(&sizes, index, delta, keep).ok_or_else(|| bad(ID, "invalid table geometry"))?;
let size = new.get(index).copied().unwrap_or(cur);
let scaled = keep && index + 1 == sizes.len();
@@ -1751,7 +1765,7 @@ fn table_style_fields(ts: &mut designcraft_doc::TableStyle, p: &Value, border: O
ts.borders = Default::default();
}
if let Some(a) = p.get("altRows").and_then(Value::as_object) {
let n = |k: &str, d: u32| a.get(k).and_then(Value::as_u64).map_or(d, |v| v as u32);
let n = |k: &str, d: u32| a.get(k).and_then(Value::as_u64).map_or(d, |v| u32::try_from(v).unwrap_or(u32::MAX));
let c = |k: &str| a.get(k).and_then(Value::as_str).unwrap_or(designcraft_color::swatch::NONE).to_string();
let t = |k: &str| a.get(k).and_then(Value::as_f64).map_or(1.0, |v| v.clamp(0.0, 1.0) as f32);
ts.alt_rows = Some(designcraft_doc::AltFills {
@@ -2466,3 +2480,22 @@ mod resize_border_tests {
assert!(s.execute("table.resizeBorder", &json!({"frame": f, "table": t, "axis": "column", "index": 0, "delta": 5})).is_err());
}
}
#[cfg(test)]
mod style_alt_rows_tests {
use serde_json::json;
use super::table_style_fields;
#[test]
fn style_alt_rows_counts_saturate() {
let mut ts = designcraft_doc::TableStyle::default();
let p = json!({"altRows": {"first": 4294967297u64, "next": 4294967296u64, "skipFirst": 4294967295u64, "skipLast": 3}});
table_style_fields(&mut ts, &p, None);
let a = ts.alt_rows.unwrap();
assert_eq!(a.first, u32::MAX, "first");
assert_eq!(a.next, u32::MAX, "next");
assert_eq!(a.skip_first, u32::MAX, "skip_first");
assert_eq!(a.skip_last, 3, "skip_last");
}
}
+239 -6
View File
@@ -72,6 +72,15 @@ pub fn specs() -> Vec<CommandSpec> {
ok()
}),
cmd!("text.insert", "Type", [], None, "{text, raw?: bool (no typographer's quotes)} — replaces the selected text", has_text, insert),
cmd!(
"text.deleteSurrounding",
"Delete Surrounding Text",
[],
None,
"{before?: int, after?: int, bytes?: bool, commit?: string} — removes the text around the selection (an input method's delete-surrounding), keeps the selection and its direction, and with `commit` types that text over it: one undo step",
has_text,
delete_surrounding
),
cmd!("text.delete", "Delete Text", [], None, "{forward?: bool, word?: bool}", has_text, delete),
cmd!(noundo "text.move", "Move Caret", [], None, "{dir: left|right|up|down|lineStart|lineEnd|storyStart|storyEnd, extend?, word?}", has_text, move_caret),
cmd!(noundo "text.exitToFrame", "Select Frame", [], None, "{}", has_doc, |s, _| {
@@ -104,6 +113,15 @@ pub fn specs() -> Vec<CommandSpec> {
})
}
),
cmd!(
"story.options",
"Story Options",
[],
None,
"{story?, frame?: a text frame of the story (default: the stories of the text selection or the selected frames), opticalMargin?: bool — Optical Margin Alignment: punctuation and letter edges hang outside the frame in every paragraph of the story except those with ignoreOpticalMargin, opticalMarginSize?: pt (0.1–1296, default 12) — the type size the hang amounts are set for} → {stories}",
has_doc,
story_options
),
cmd!("story.setText", "Set Story Text", [], None, "{story, text} — replace a story's whole text", has_doc, |s, p| {
let sid = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("story.setText", "missing story"))?);
let text = super::text_param(p, "text");
@@ -182,7 +200,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Paragraph Formatting",
[],
None,
"{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, dropCapChars?, dropCapStyle?, dropCapAlignLeft?, dropCapScaleDescenders?, hyphenate?: bool (default true), composer?, tabs?, ruleAbove?/ruleBelow?: {on?, weight?, color?, tint?, columnWidth?, offset?, leftIndent?, rightIndent?} (only the named rule fields change), …}}",
"{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, dropCapChars?, dropCapStyle?, dropCapAlignLeft?, dropCapScaleDescenders?, hyphenate?: bool (default true), composer?, opticalMargin?: bool (hang at each character's own size; a story with story.options opticalMargin overrides it), ignoreOpticalMargin?: bool (no hang in the paragraph), tabs?, ruleAbove?/ruleBelow?: {on?, weight?, color?, tint?, columnWidth?, offset?, leftIndent?, rightIndent?} (only the named rule fields change), …}}",
has_text_or_frames,
|s, p| format_paras(s, p.get("attrs").unwrap_or(p))
),
@@ -374,7 +392,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Selection Attributes",
[],
None,
"{mixed?: bool} → resolved character/paragraph attributes at the text selection; with mixed, paraMixed lists the paragraph attributes that differ across the selected paragraphs; the selection's start as `selectedText` (one line, up to 60 characters)",
"{mixed?: bool} → resolved character/paragraph attributes at the text selection; with mixed, paraMixed lists the paragraph attributes that differ across the selected paragraphs; the selection's start as `selectedText` (one line, up to 60 characters); `storyOptions` {opticalMargin, opticalMarginSize} of the stories story.options would change (null where they differ)",
has_doc,
selection_attrs
),
@@ -758,6 +776,71 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
result
}
/// The input method's delete-surrounding: remove `before`/`after` characters (or bytes) around
/// the selection, keeping the selection itself and its direction, then optionally type `commit`
/// over it. One undo step for the whole batch, through the normal command pipeline (preview
/// restoration, panic guard, undo, auto-size and the journal), unlike a private edit.
fn delete_surrounding(s: &mut Session, p: &Value) -> Result<Value> {
let count = |key: &str| -> Result<usize> {
match p.get(key).and_then(Value::as_u64) {
Some(v) => usize::try_from(v).map_err(|_| bad("text.deleteSurrounding", format!("{key} is out of range"))),
None => Ok(0),
}
};
let before = count("before")?;
let after = count("after")?;
let bytes = p.get("bytes").and_then(Value::as_bool).unwrap_or(false);
let commit = designcraft_tools::ime_commit_text(p.get("commit").and_then(Value::as_str).unwrap_or(""));
// What the request removes, from the committed text as it is now: the ranges exclude the
// selection and any pre-edit (a pre-edit is only a preview, never in the story).
let (before_range, after_range, tracking, length_before) = {
let st = s.doc()?;
let t = st.selection.text.ok_or_else(|| bad("text.deleteSurrounding", "no text insertion point"))?;
let story = st.doc.text_story(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
let (before_range, after_range) = crate::ime_delete_ranges(&story.text, t.anchor, t.focus, before, after, bytes)
.ok_or_else(|| bad("text.deleteSurrounding", "the IME text selection is not on character boundaries"))?;
(before_range, after_range, st.doc.settings.track_changes && t.cell.is_none(), story.text.len())
};
s.edit(|d, sel| {
let t = sel.text.ok_or_else(|| bad("text.deleteSurrounding", "no text insertion point"))?;
let story = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
if !after_range.is_empty() {
if tracking {
crate::cmd::changes::mark_deleted(story, after_range.clone());
} else {
story.delete(after_range.clone());
}
}
let mut shift = 0usize;
if !before_range.is_empty() {
if tracking {
// Tracked changes keep the text and mark it deleted: the selection does not move.
let new_end = crate::cmd::changes::mark_deleted(story, before_range.clone());
shift = before_range.end.saturating_sub(new_end);
} else {
shift = before_range.len();
story.delete(before_range.clone());
}
}
sel.text = Some(TextSel { anchor: t.anchor.saturating_sub(shift), focus: t.focus.saturating_sub(shift), ..t });
Ok(())
})?;
if !commit.is_empty() {
insert(s, &json!({"text": commit}))?;
}
let st = s.doc()?;
let (anchor, focus) = st.selection.text.map_or((0, 0), |t| (t.anchor, t.focus));
let length_after = st.selection.text.and_then(|t| st.doc.text_story(t.story, t.cell)).map_or(0, |story| story.text.len());
Ok(json!({
"deletedBefore": [before_range.start, before_range.end],
"deletedAfter": [after_range.start, after_range.end],
"lengthBefore": length_before,
"lengthAfter": length_after,
"anchor": anchor,
"focus": focus,
}))
}
fn smart_quotes(s: &Session, text: &str) -> String {
if !text.contains(['"', '\'']) {
return text.to_string();
@@ -991,11 +1074,11 @@ fn sentence_bounds(s: &str, i: usize) -> (usize, usize) {
(pa + start, pb)
}
/// Type ▸ Story Direction.
fn story_direction(s: &mut Session, p: &Value) -> Result<Value> {
let vertical = p.get("vertical").and_then(Value::as_bool).ok_or_else(|| bad("type.storyDirection", "missing vertical"))?;
/// The stories of the text selection, the selected cells and the selected text frames.
fn selected_stories(s: &Session) -> Result<Vec<StoryId>> {
let st = s.doc()?;
let mut stories: Vec<StoryId> = st.selection.text.map(|t| t.story).into_iter().chain(st.selection.cells.map(|c| c.story)).collect();
stories.dedup();
for id in &st.selection.items {
if let Some(tf) = st.doc.item(*id).and_then(|i| i.text_frame())
&& !stories.contains(&tf.story)
@@ -1003,6 +1086,54 @@ fn story_direction(s: &mut Session, p: &Value) -> Result<Value> {
stories.push(tf.story);
}
}
Ok(stories)
}
/// Type ▸ Story: the story-wide options (Optical Margin Alignment and its size).
fn story_options(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "story.options";
let on = match p.get("opticalMargin") {
None => None,
Some(v) => Some(v.as_bool().ok_or_else(|| bad(CMD, "`opticalMargin` must be a bool"))?),
};
let size = match p.get("opticalMarginSize") {
None => None,
Some(v) => Some(
v.as_f64()
.and_then(designcraft_doc::OpticalMargin::clamp_size)
.ok_or_else(|| bad(CMD, "`opticalMarginSize` must be a finite number of points"))?,
),
};
if on.is_none() && size.is_none() {
return Err(bad(CMD, "nothing to set: give opticalMargin or opticalMarginSize"));
}
let stories = if p.get("story").is_some() || p.get("frame").is_some() {
vec![story_of(s, p).ok_or_else(|| bad(CMD, "no such story"))?]
} else {
selected_stories(s)?
};
if stories.is_empty() {
return Err(bad(CMD, "no story: select text or a text frame, or give story or frame"));
}
s.edit(|d, _| {
for sid in &stories {
let st = d.story_mut(*sid).ok_or(designcraft_doc::DocError::NoStory(*sid))?;
let mut om = st.optical_margin;
om.on = on.unwrap_or(om.on);
om.size = size.unwrap_or(om.size);
if om != st.optical_margin {
st.optical_margin = om;
st.rev = st.rev.wrapping_add(1);
}
}
Ok(json!({"stories": stories.len()}))
})
}
/// Type ▸ Story Direction.
fn story_direction(s: &mut Session, p: &Value) -> Result<Value> {
let vertical = p.get("vertical").and_then(Value::as_bool).ok_or_else(|| bad("type.storyDirection", "missing vertical"))?;
let stories = selected_stories(s)?;
s.edit(|d, _| {
set_story_direction(d, &stories, vertical);
Ok(json!({"stories": stories.len()}))
@@ -1836,12 +1967,30 @@ fn selection_attrs(s: &mut Session, p: &Value) -> Result<Value> {
"paraOverrides": style_para.delta(&pp).count() + style_chars.delta(&base).count(),
"charOverrides": char_delta.count(), "characterStyleOverrides": character_style_overrides,
"selectedText": selected.trim()});
let mut stories = selected_stories(s)?;
if stories.is_empty() {
stories.push(sid);
}
out["storyOptions"] = story_options_of(&st.doc, &stories);
if bool_or(p, "mixed", false) {
out["paraMixed"] = json!(mixed_para_attrs(&st.doc, &targets));
}
Ok(out)
}
/// The story options of `stories`, as story.options names them; a value that differs between
/// the stories is null.
fn story_options_of(doc: &designcraft_doc::Document, stories: &[StoryId]) -> Value {
let mut values = stories.iter().filter_map(|sid| doc.story(*sid)).map(|st| st.optical_margin);
let Some(first) = values.next() else { return json!({"opticalMargin": null, "opticalMarginSize": null}) };
let (mut on, mut size) = (Some(first.on), Some(first.size));
for om in values {
on = on.filter(|v| *v == om.on);
size = size.filter(|v| *v == om.size);
}
json!({"opticalMargin": on, "opticalMarginSize": size})
}
/// The paragraph attributes whose resolved values differ across the paragraphs of `targets`.
fn mixed_para_attrs(doc: &designcraft_doc::Document, targets: &[Target]) -> Vec<String> {
let mut first: Option<serde_json::Map<String, Value>> = None;
@@ -2105,7 +2254,7 @@ fn open_type_features(s: &mut Session, _p: &Value) -> Result<Value> {
continue;
}
let names: std::collections::BTreeSet<&String> = fonts.values().filter_map(|f| f.stylistic_sets.get(&set)).collect();
let name = if names.len() == 1 { names.first().map(|n| n.to_string()) } else { None };
let name = if names.len() == 1 { names.first().map(ToString::to_string) } else { None };
let (applied, available) = measure(&tag);
sets.push(json!({"set": set, "tag": tag, "name": name, "applied": applied, "available": available}));
}
@@ -3280,6 +3429,90 @@ mod bidi_caret_tests {
}
}
#[cfg(test)]
mod story_options_tests {
use super::*;
fn first_x(s: &Session, sid: StoryId) -> f64 {
let cs = s.cache.get(&s.doc().unwrap().doc, sid, None);
cs.frames[0].lines[0].glyphs[0].x
}
fn story(s: &Session, sid: StoryId) -> designcraft_doc::OpticalMargin {
s.doc().unwrap().doc.story(sid).unwrap().optical_margin
}
#[test]
fn story_options_set_optical_margin_with_one_undo_step_each() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "“Hung” words."})).unwrap();
let sid = StoryId(r["story"].as_u64().unwrap());
let flush = first_x(&s, sid);
// The selected frame's story.
s.execute("story.options", &json!({"opticalMargin": true})).unwrap();
assert_eq!(story(&s, sid), designcraft_doc::OpticalMargin { on: true, size: 12.0 });
let hung = first_x(&s, sid);
assert!(hung < flush - 1.0, "{hung} {flush}");
s.execute("story.options", &json!({"story": sid.0, "opticalMarginSize": 24})).unwrap();
assert_eq!(story(&s, sid).size, 24.0);
assert!(first_x(&s, sid) < hung - 1.0);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(story(&s, sid), designcraft_doc::OpticalMargin { on: true, size: 12.0 });
s.execute("edit.undo", &json!({})).unwrap();
assert!(!story(&s, sid).on);
assert_eq!(first_x(&s, sid), flush);
// Hostile sizes.
s.execute("story.options", &json!({"story": sid.0, "opticalMarginSize": 1e9})).unwrap();
assert_eq!(story(&s, sid).size, designcraft_doc::OpticalMargin::MAX_SIZE);
for bad in [json!({"opticalMarginSize": "big"}), json!({"opticalMargin": 1}), json!({}), json!({"story": 999_999, "opticalMargin": true})] {
assert!(s.execute("story.options", &bad).is_err(), "{bad}");
}
}
#[test]
fn ignore_optical_margin_through_type_para_and_paragraph_styles() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "“Hung” words."})).unwrap();
let sid = StoryId(r["story"].as_u64().unwrap());
let flush = first_x(&s, sid);
s.execute("story.options", &json!({"opticalMargin": true})).unwrap();
assert!(first_x(&s, sid) < flush - 1.0);
let ignored = |s: &Session| s.doc().unwrap().doc.story(sid).unwrap().paras[0].para.ignore_optical_margin;
s.execute("type.para", &json!({"attrs": {"ignoreOpticalMargin": true}})).unwrap();
assert_eq!(ignored(&s), Some(true));
assert_eq!(first_x(&s, sid), flush);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(ignored(&s), None);
s.execute("style.paragraph.create", &json!({"name": "Flush", "para": {"ignoreOpticalMargin": true}})).unwrap();
let resolved = |s: &Session| s.doc().unwrap().doc.styles.resolve_para_style("Flush").0.ignore_optical_margin;
assert!(resolved(&s));
s.execute("style.paragraph.edit", &json!({"name": "Flush", "para": {"ignoreOpticalMargin": false}})).unwrap();
assert!(!resolved(&s));
s.execute("edit.undo", &json!({})).unwrap();
assert!(resolved(&s));
s.execute("style.paragraph.apply", &json!({"name": "Flush"})).unwrap();
assert_eq!(first_x(&s, sid), flush);
}
#[test]
fn selection_attrs_report_the_story_options_with_differences_as_null() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "One"})).unwrap();
let b = s.execute("frame.create", &json!({"rect": [0, 300, 300, 500], "content": "text", "text": "Two"})).unwrap();
let options = |s: &mut Session| s.execute("type.selectionAttrs", &json!({})).unwrap()["storyOptions"].clone();
assert_eq!(options(&mut s), json!({"opticalMargin": false, "opticalMarginSize": 12.0}));
s.execute("story.options", &json!({"story": b["story"], "opticalMargin": true, "opticalMarginSize": 18})).unwrap();
assert_eq!(options(&mut s), json!({"opticalMargin": true, "opticalMarginSize": 18.0}), "the selected frame's story");
s.execute("selection.set", &json!({"ids": [a["id"], b["id"]]})).unwrap();
assert_eq!(options(&mut s), json!({"opticalMargin": null, "opticalMarginSize": null}));
s.execute("story.options", &json!({"opticalMarginSize": 18})).unwrap();
assert_eq!(options(&mut s), json!({"opticalMargin": null, "opticalMarginSize": 18.0}));
}
}
#[cfg(test)]
mod arabic_tests {
use super::*;
+421
View File
@@ -73,9 +73,256 @@ pub fn specs() -> Vec<CommandSpec> {
s.execute("text.insert", &json!({"text": c.to_string()}))
}
),
cmd!(
"variables.convertToText",
"Convert Variable to Text",
["Type", "Text Variables"],
None,
"{} — the variable instances in the selected text become plain text of the value each shows on its page; the variables stay defined",
has_selected_instance,
convert_selection
),
cmd!(
"variables.convertAllToText",
"Convert to Text",
[],
None,
"{name} — every instance of the variable in the document becomes plain text of the value it shows on its page (Text Variables › Convert to Text); the variable stays defined",
has_doc,
convert_all
),
]
}
/// The selected text holds a variable instance (InDesign: select the variable, then Convert
/// Variable to Text). The endnote story is rebuilt from the stories' endnotes, so an edit there
/// would not last.
fn has_selected_instance(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
let st = s.active().ok_or("no document")?;
let t = st.selection.text.ok_or("no text selected")?;
let held = st.doc.endnote_story != Some(t.story)
&& st
.doc
.text_story(t.story, t.cell)
.and_then(|x| x.text.get(t.range()))
.is_some_and(|x| x.chars().any(|c| designcraft_doc::vars::var_index(c).is_some()));
if held { Ok(()) } else { Err("no text variable selected".into()) }
}
/// The page story byte `pos` is shown on, which a variable's value depends on: overset text takes
/// the page of the story's last frame; parent pages and the pasteboard have none.
fn shown_page(d: &designcraft_doc::Document, cs: &designcraft_compose::ComposedStory, pos: usize) -> Option<usize> {
designcraft_compose::vars::page_of(d, cs, pos).or_else(|| cs.frames.last().and_then(|f| d.page_of_item(f.frame)))
}
/// Values of every variable on the pages asked for, as composition shows them (running headers
/// included), computed once per page.
struct PageValues<'a> {
doc: &'a designcraft_doc::Document,
running: std::sync::Arc<designcraft_compose::vars::RunningIndex>,
pages: std::collections::HashMap<Option<usize>, Vec<String>>,
}
impl PageValues<'_> {
fn get(&mut self, i: usize, page: Option<usize>) -> String {
let (doc, running) = (self.doc, &self.running);
let v = self.pages.entry(page).or_insert_with(|| designcraft_compose::vars::values(doc, page, Some(running)));
v.get(i).cloned().unwrap_or_default()
}
}
/// Byte offsets of the variable instances in `range` of `text` (of variable `which`, or any),
/// with their variable index.
fn instances(text: &str, range: std::ops::Range<usize>, which: Option<usize>) -> Vec<(usize, usize)> {
let Some(t) = text.get(range.clone()) else { return Vec::new() };
t.char_indices()
.filter_map(|(k, c)| designcraft_doc::vars::var_index(c).filter(|i| which.is_none_or(|w| w == *i)).map(|i| (range.start + k, i)))
.collect()
}
/// Replaces the instances at `at` (byte, variable) in `st` with `value(byte)`, last first so the
/// earlier offsets hold. Returns the change in length before each replaced byte, in text order.
fn replace_instances(st: &mut designcraft_doc::Story, at: &[(usize, usize)], value: &dyn Fn(usize) -> String) -> Vec<(usize, isize)> {
let mut grown = Vec::new();
for &(pos, i) in at.iter().rev() {
let len = var_char(i).map_or(0, char::len_utf8);
let v = value(pos);
st.replace(pos..pos + len, &v);
grown.push((pos, v.len() as isize - len as isize));
}
grown.reverse();
grown
}
/// Moves byte `b` of a story by the growth of the instances replaced before it.
fn shifted(b: usize, grown: &[(usize, isize)]) -> usize {
let d: isize = grown.iter().filter(|(pos, _)| *pos < b).map(|(_, g)| g).sum();
b.saturating_add_signed(d)
}
/// Replaces every instance of variable `i` in `st`, its table cells and its notes with `value`.
/// Returns the growth of `st`'s own text, as [`replace_instances`].
fn replace_nested(st: &mut designcraft_doc::Story, i: usize, value: &str, depth: usize) -> Vec<(usize, isize)> {
if depth > MAX_DEPTH {
return Vec::new();
}
let at = instances(&st.text, 0..st.text.len(), Some(i));
let grown = replace_instances(st, &at, &|_| value.to_string());
let mut nested = false;
for t in st.tables.values_mut() {
if t.cells.iter().any(|c| holds(&c.text, i, depth + 1)) {
for c in &mut std::sync::Arc::make_mut(t).cells {
replace_nested(&mut c.text, i, value, depth + 1);
}
nested = true;
}
}
for note in st.notes.iter_mut().chain(st.endnotes.iter_mut()) {
if holds(&note.text, i, depth + 1) {
replace_nested(&mut std::sync::Arc::make_mut(note).text, i, value, depth + 1);
nested = true;
}
}
if nested {
st.rev = st.rev.wrapping_add(1);
}
grown
}
/// Whether `st` or text nested in it holds an instance of variable `i`.
fn holds(st: &designcraft_doc::Story, i: usize, depth: usize) -> bool {
depth <= MAX_DEPTH
&& (st.text.chars().any(|c| designcraft_doc::vars::var_index(c) == Some(i))
|| st.tables.values().any(|t| t.cells.iter().any(|c| holds(&c.text, i, depth + 1)))
|| st.notes.iter().chain(st.endnotes.iter()).any(|n| holds(&n.text, i, depth + 1)))
}
/// Byte offset in `st` of the anchor of table `id`.
fn table_anchor(st: &designcraft_doc::Story, id: u64) -> Option<usize> {
let (pi, _) = st.paras.iter().enumerate().find(|(_, p)| p.table == Some(id))?;
let r = st.para_ranges().into_iter().nth(pi)?;
Some(r.start + st.text.get(r)?.find(designcraft_doc::story::TABLE_ANCHOR)?)
}
fn convert_selection(s: &mut Session, _: &Value) -> Result<Value> {
const ID: &str = "variables.convertToText";
let t = s.doc()?.selection.text.ok_or_else(|| bad(ID, "no text selected"))?;
let range = t.range();
let (at, values) = {
let d = &s.doc()?.doc;
let st = d.story(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
let text = d.text_story(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
// Text in a cell or a footnote is on the page of its table's or note's anchor.
let anchor = match t.cell {
None => None,
Some(c) if c.table == designcraft_doc::FOOTNOTE_TABLE => st.note_anchor(c.row as u64),
Some(c) => table_anchor(st, c.table),
};
let cs = s.cache.get(d, t.story, None);
let mut pv = PageValues { doc: d, running: s.cache.running_index(d), pages: Default::default() };
let at = instances(&text.text, range.clone(), None);
let values: std::collections::HashMap<usize, String> =
at.iter().map(|&(pos, i)| (pos, pv.get(i, shown_page(d, &cs, anchor.unwrap_or(pos))))).collect();
(at, values)
};
if at.is_empty() {
return Err(bad(ID, "no text variable selected"));
}
s.edit(|d, sel| {
let st = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
let grown = replace_instances(st, &at, &|pos| values.get(&pos).cloned().unwrap_or_default());
// The converted text stays selected.
sel.text = Some(designcraft_doc::TextSel { anchor: range.start, focus: shifted(range.end, &grown), ..t });
Ok(json!({"converted": at.len()}))
})
}
/// What converting one story's instances of a variable writes: the value for each instance in
/// its own text, and for the text of each table (by id), footnote and endnote, the value on the
/// page of its anchor.
struct StoryPlan {
sid: designcraft_doc::StoryId,
own: Vec<(usize, String)>,
tables: Vec<(u64, String)>,
notes: Vec<String>,
endnotes: Vec<String>,
}
fn convert_all(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "variables.convertAllToText";
let name = str_param(p, "name").ok_or_else(|| bad(ID, "missing name"))?;
let (i, plans) = {
let d = &s.doc()?.doc;
let i = d.variable(name).ok_or_else(|| bad(ID, format!("no variable `{name}`")))?;
let mut pv = PageValues { doc: d, running: s.cache.running_index(d), pages: Default::default() };
let mut plans = Vec::new();
for (sid, st) in &d.stories {
if Some(*sid) == d.endnote_story || !holds(st, i, 0) {
continue;
}
let cs = s.cache.get(d, *sid, None);
let mut value = |pos: usize| pv.get(i, shown_page(d, &cs, pos));
let own = instances(&st.text, 0..st.text.len(), Some(i)).into_iter().map(|(pos, _)| (pos, value(pos))).collect();
let tables = st.tables.keys().map(|id| (*id, table_anchor(st, *id).map(&mut value).unwrap_or_default())).collect();
let notes = st.text.match_indices(designcraft_doc::FOOTNOTE_REF).map(|(k, _)| value(k)).collect();
let endnotes = st.text.match_indices(designcraft_doc::ENDNOTE_REF).map(|(k, _)| value(k)).collect();
plans.push(StoryPlan { sid: *sid, own, tables, notes, endnotes });
}
(i, plans)
};
s.edit(|d, sel| {
for plan in &plans {
// A selection in a table cell or a footnote keeps its place in that text: it is
// converted first, so the loop below finds nothing left there.
if let Some(t) = sel.text.as_mut().filter(|t| t.story == plan.sid)
&& let Some(c) = t.cell
{
let value = match c.footnote_id() {
Some(id) => d.story(plan.sid).and_then(|st| st.notes.iter().position(|n| n.id == id)).and_then(|k| plan.notes.get(k)),
None => plan.tables.iter().find(|(id, _)| *id == c.table).map(|(_, v)| v),
};
if let Some(v) = value
&& let Some(text) = d.text_story_mut(plan.sid, Some(c))
{
let grown = replace_nested(text, i, v, 1);
t.anchor = shifted(t.anchor, &grown);
t.focus = shifted(t.focus, &grown);
}
}
let st = d.story_mut(plan.sid).ok_or(designcraft_doc::DocError::NoStory(plan.sid))?;
let at: Vec<(usize, usize)> = plan.own.iter().map(|(pos, _)| (*pos, i)).collect();
let grown = replace_instances(st, &at, &|pos| plan.own.iter().find(|(k, _)| *k == pos).map(|(_, v)| v.clone()).unwrap_or_default());
let mut nested = false;
for (id, v) in &plan.tables {
if let Some(t) = st.tables.get_mut(id)
&& t.cells.iter().any(|c| holds(&c.text, i, 1))
{
for c in &mut std::sync::Arc::make_mut(t).cells {
replace_nested(&mut c.text, i, v, 1);
}
nested = true;
}
}
for (note, v) in st.notes.iter_mut().zip(&plan.notes).chain(st.endnotes.iter_mut().zip(&plan.endnotes)) {
if holds(&note.text, i, 1) {
replace_nested(&mut std::sync::Arc::make_mut(note).text, i, v, 1);
nested = true;
}
}
if nested {
st.rev = st.rev.wrapping_add(1);
}
// A selection in the story keeps its place in the text.
if let Some(t) = sel.text.as_mut().filter(|t| t.story == plan.sid && t.cell.is_none()) {
t.anchor = shifted(t.anchor, &grown);
t.focus = shifted(t.focus, &grown);
}
}
Ok(Value::Null)
})
}
/// Nesting limit for tables in cells and notes (hostile documents).
const MAX_DEPTH: usize = 32;
@@ -265,6 +512,180 @@ mod tests {
assert_eq!(shown(3), "Epsilon".len(), "the fourth page (named 2) carries it");
}
/// Three pages with "Alpha" in the Heading style on page 1 and "Epsilon" on page 3, a
/// `Chapter Head` running header on Heading, and a text frame on pages 1 and 3 that each hold
/// "<head>|<head>" (two instances). Returns the two frames' stories.
fn running_heads() -> (Session, designcraft_doc::StoryId, designcraft_doc::StoryId) {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap();
s.execute("style.paragraph.create", &json!({"name": "Heading"})).ok();
let frame = |s: &mut Session, page: usize, y: f64, text: &str| {
let sp = s.doc().unwrap().doc.page_loc(page).unwrap().0;
let r = s.execute("frame.create", &json!({"spread": sp, "rect": [72, y, 400, y + 60.0], "content": "text", "text": text})).unwrap();
s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story
};
for (page, text) in [(0, "Alpha"), (2, "Epsilon")] {
let sid = frame(&mut s, page, 200.0, text);
s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 1})).unwrap();
s.execute("style.paragraph.apply", &json!({"name": "Heading"})).unwrap();
}
s.execute("variables.define", &json!({"name": "Chapter Head", "type": "runningHeader", "style": "Heading"})).unwrap();
let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap();
let both = format!("{c}|{c}");
let (p1, p3) = (frame(&mut s, 0, 400.0, &both), frame(&mut s, 2, 400.0, &both));
(s, p1, p3)
}
/// Convert Variable to Text turns the selected instance into the value it shows on its own
/// page (Epsilon on page 3, not page 1's Alpha); the other instance and the variable stay.
#[test]
fn convert_variable_to_text_uses_the_instances_page() {
let (mut s, p1, p3) = running_heads();
let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap();
let second = c.len_utf8() + 1;
s.execute("text.select", &json!({"story": p3.0, "anchor": second, "focus": second + c.len_utf8()})).unwrap();
s.execute("variables.convertToText", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.story(p3).unwrap().text, format!("{c}|Epsilon"), "only the selected instance");
assert_eq!(d.story(p1).unwrap().text, format!("{c}|{c}"));
assert!(d.variable("Chapter Head").is_some(), "the variable stays defined");
let t = s.doc().unwrap().selection.text.unwrap();
assert_eq!((t.anchor, t.focus), (second, second + "Epsilon".len()), "the converted text stays selected");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.story(p3).unwrap().text, format!("{c}|{c}"), "one undo step");
}
/// The menu item is enabled only for a selection that holds an instance: not a caret, not
/// text without one.
#[test]
fn convert_variable_to_text_needs_a_selected_instance() {
let (mut s, p1, _) = running_heads();
let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap();
let bar = c.len_utf8();
let enabled = |s: &Session| (crate::find_command("variables.convertToText").unwrap().enabled)(s).is_ok();
for (anchor, focus) in [(0, 0), (bar, bar + 1)] {
s.execute("text.select", &json!({"story": p1.0, "anchor": anchor, "focus": focus})).unwrap();
assert!(!enabled(&s), "{anchor}..{focus}");
}
s.execute("text.select", &json!({"story": p1.0, "anchor": 0, "focus": bar})).unwrap();
assert!(enabled(&s));
}
/// A story threaded from a frame on page 1, which holds only "Lead", to a frame on page 3,
/// where the caret is left at the end of "Body". A cell's or a note's own byte 0 read in this
/// story is on page 1, its anchor on page 3.
fn threaded(s: &mut Session) -> designcraft_doc::StoryId {
let sp1 = s.doc().unwrap().doc.page_loc(0).unwrap().0;
let sp3 = s.doc().unwrap().doc.page_loc(2).unwrap().0;
let r = s.execute("frame.create", &json!({"spread": sp1, "rect": [72, 500, 400, 520], "content": "text", "text": "Lead\nBody"})).unwrap();
let from = r["id"].as_u64().unwrap();
s.execute("text.thread", &json!({"from": from, "rect": [72, 500, 400, 700], "spread": sp3})).unwrap();
let d = s.doc().unwrap().doc.clone();
let sid = d.item(designcraft_doc::ItemId(from)).unwrap().text_frame().unwrap().story;
let cs = s.cache.get(&d, sid, None);
let page = |pos| designcraft_compose::vars::page_of(&d, &cs, pos);
assert_eq!((page(0), page("Lead\nB".len())), (Some(0), Some(2)), "Lead on page 1, Body on page 3");
s.execute("text.select", &json!({"story": sid.0, "anchor": "Lead\nBody".len(), "focus": "Lead\nBody".len()})).unwrap();
sid
}
/// An instance selected in a table cell becomes the value on the page of the table's anchor.
#[test]
fn convert_variable_to_text_in_a_table_cell() {
let (mut s, ..) = running_heads();
let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap();
let sid = threaded(&mut s);
s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap();
s.execute("table.setCell", &json!({"row": 0, "col": 0, "text": c.to_string()})).unwrap();
let table = *s.doc().unwrap().doc.story(sid).unwrap().tables.keys().next().unwrap();
let cell = designcraft_doc::CellAddr { table, row: 0, col: 0 };
s.doc_mut().unwrap().selection =
designcraft_doc::Selection::text(designcraft_doc::TextSel { story: sid, anchor: 0, focus: c.len_utf8(), frame: None, cell: Some(cell) });
s.execute("variables.convertToText", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.text_story(sid, Some(cell)).unwrap().text, "Epsilon");
}
/// An instance selected in a footnote becomes the value on the page of the note's reference.
#[test]
fn convert_variable_to_text_in_a_footnote() {
let (mut s, ..) = running_heads();
let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap();
let sid = threaded(&mut s);
s.execute("footnote.insert", &json!({"text": c.to_string()})).unwrap();
let id = s.doc().unwrap().doc.story(sid).unwrap().notes[0].id;
let note = designcraft_doc::CellAddr::footnote(id);
let at = s.doc().unwrap().doc.text_story(sid, Some(note)).unwrap().text.find(c).unwrap();
s.doc_mut().unwrap().selection = designcraft_doc::Selection::text(designcraft_doc::TextSel {
story: sid,
anchor: at,
focus: at + c.len_utf8(),
frame: None,
cell: Some(note),
});
s.execute("variables.convertToText", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
let text = &d.text_story(sid, Some(note)).unwrap().text;
assert!(!text.contains(c) && text.ends_with("Epsilon"), "{text:?}");
}
/// Text Variables › Convert to Text turns every instance of the variable into the value on
/// its page, in table cells, footnotes and endnotes on the page of their anchor, and keeps the
/// variable.
#[test]
fn convert_all_instances_to_text() {
let (mut s, p1, p3) = running_heads();
let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap();
let sid = threaded(&mut s);
let at_end = |s: &mut Session| {
let n = s.doc().unwrap().doc.story(sid).unwrap().len();
s.execute("text.select", &json!({"story": sid.0, "anchor": n, "focus": n})).unwrap();
};
s.execute("footnote.insert", &json!({"text": c.to_string()})).unwrap();
at_end(&mut s);
s.execute("endnote.insert", &json!({"text": c.to_string()})).unwrap();
at_end(&mut s);
s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap();
s.execute("table.setCell", &json!({"row": 0, "col": 0, "text": c.to_string()})).unwrap();
s.execute("variables.convertAllToText", &json!({"name": "Chapter Head"})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.story(p1).unwrap().text, "Alpha|Alpha");
assert_eq!(d.story(p3).unwrap().text, "Epsilon|Epsilon");
let st = d.story(sid).unwrap();
assert_eq!(st.tables.values().next().unwrap().cells[0].text.text, "Epsilon", "table cell");
assert!(st.notes[0].text.text.ends_with("Epsilon"), "footnote: {:?}", st.notes[0].text.text);
assert!(st.endnotes[0].text.text.ends_with("Epsilon"), "endnote: {:?}", st.endnotes[0].text.text);
assert!(d.variable("Chapter Head").is_some(), "the variable stays defined");
}
/// A caret in a table cell or a footnote after an instance stays after the value Convert to
/// Text writes there, so typing lands after it.
#[test]
fn convert_all_keeps_the_caret_in_a_cell_or_a_footnote() {
for footnote in [false, true] {
let (mut s, ..) = running_heads();
let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap();
let sid = threaded(&mut s);
let text = format!("{c}|");
let at = if footnote {
s.execute("footnote.insert", &json!({"text": text})).unwrap();
designcraft_doc::CellAddr::footnote(s.doc().unwrap().doc.story(sid).unwrap().notes[0].id)
} else {
s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap();
s.execute("table.setCell", &json!({"row": 0, "col": 0, "text": text})).unwrap();
let table = *s.doc().unwrap().doc.story(sid).unwrap().tables.keys().next().unwrap();
designcraft_doc::CellAddr { table, row: 0, col: 0 }
};
let n = s.doc().unwrap().doc.text_story(sid, Some(at)).unwrap().text.len();
s.doc_mut().unwrap().selection =
designcraft_doc::Selection::text(designcraft_doc::TextSel { story: sid, anchor: n, focus: n, frame: None, cell: Some(at) });
s.execute("variables.convertAllToText", &json!({"name": "Chapter Head"})).unwrap();
s.execute("text.insert", &json!({"text": "x"})).unwrap();
let text = &s.doc().unwrap().doc.text_story(sid, Some(at)).unwrap().text;
assert!(text.ends_with("Epsilon|x"), "footnote {footnote}: {text:?}");
}
}
/// Deleting a variable flattens its instances in table cells too and moves the instances of
/// later variables down, so cells keep showing the same text.
#[test]
+183 -41
View File
@@ -1,15 +1,17 @@
//! The folders automation may read and write.
//!
//! `designcraft-cli mcp --automation-read-root <dir> --automation-write-root <dir>` confines the
//! files an agent's commands reach. [`AutomationRoots`] holds the two folders. While they are in force, every path a command
//! reads goes through [`check_read`] and every path it writes through [`check_write`]:
//! files an agent's commands reach; each flag may be given more than once (#1552). [`AutomationRoots`]
//! holds the folders of each kind. While they are in force, every path a command reads goes through
//! [`check_read`] and every path it writes through [`check_write`]:
//!
//! - The path is made absolute against the working directory, as the file system reads it. Its
//! links are followed: for a file that doesn't exist yet, the links of its nearest existing
//! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so
//! is a link that leads nowhere).
//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside
//! `/work`), without regard to case on Windows.
//! - The result must lie inside one of the roots of its kind, compared by whole components
//! (`/work2` isn't inside `/work`), without regard to case on Windows. Two roots don't make their
//! common parent one.
//! - Read and write access are separate: a root given alone grants only its own kind of access.
//!
//! Roots are in force on a thread inside [`confine`], and in the whole process after
@@ -32,11 +34,11 @@ pub enum Access {
Write,
}
/// The folders automation may read and write; a root left out grants no access of its kind.
/// The folders automation may read and write; a kind with no root grants no access of its kind.
#[derive(Debug)]
pub struct AutomationRoots {
read: Option<Root>,
write: Option<Root>,
read: Vec<Root>,
write: Vec<Root>,
}
#[derive(Debug)]
@@ -62,47 +64,61 @@ impl Root {
}
impl AutomationRoots {
/// The roots `read` and `write`, which must be existing folders; `None` when neither is given
/// (nothing is confined then).
pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result<Option<Arc<Self>>, String> {
if read.is_none() && write.is_none() {
/// The roots `read` and `write`, which must be existing folders (one given twice counts once);
/// `None` when neither kind has any (nothing is confined then).
pub fn new(read: &[&Path], write: &[&Path]) -> Result<Option<Arc<Self>>, String> {
if read.is_empty() && write.is_empty() {
return Ok(None);
}
let read = read.map(|p| Root::open(p, Access::Read)).transpose()?;
let write = write.map(|p| Root::open(p, Access::Write)).transpose()?;
Ok(Some(Arc::new(Self { read, write })))
let open = |paths: &[&Path], access: Access| -> Result<Vec<Root>, String> {
let mut roots: Vec<Root> = Vec::with_capacity(paths.len());
for path in paths {
let root = Root::open(path, access)?;
if !roots.iter().any(|r| r.resolved == root.resolved) {
roots.push(root);
}
}
Ok(roots)
};
Ok(Some(Arc::new(Self { read: open(read, Access::Read)?, write: open(write, Access::Write)? })))
}
/// The read root, absolute with its links followed.
pub fn read_root(&self) -> Option<&Path> {
self.read.as_ref().map(|r| r.shown.as_path())
/// The roots for people: `read A, B; write C` (`none` for a kind without any), absolute with
/// their links followed.
pub fn describe(&self) -> String {
let list = |roots: &[Root]| {
if roots.is_empty() {
return "none".to_string();
}
roots.iter().map(|r| r.shown.display().to_string()).collect::<Vec<_>>().join(", ")
};
format!("read {}; write {}", list(&self.read), list(&self.write))
}
/// The write root, absolute with its links followed.
pub fn write_root(&self) -> Option<&Path> {
self.write.as_ref().map(|r| r.shown.as_path())
}
/// May the file or folder at `path` be read (or written)? `Err` says why not.
/// May the file or folder at `path` be read (or written)? It must lie inside one of the roots
/// of that kind. `Err` says why not.
pub fn check(&self, path: impl AsRef<Path>, access: Access) -> Result<(), String> {
let path = path.as_ref();
let root = match access {
Access::Read => self.read.as_ref(),
Access::Write => self.write.as_ref(),
let roots = match access {
Access::Read => &self.read,
Access::Write => &self.write,
};
let Some(Root { resolved: folder, shown }) = root else {
if roots.is_empty() {
return match access {
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {}", path.display())),
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {}", path.display())),
};
};
if inside(&resolve(path)?, folder) {
}
let resolved = resolve(path)?;
if roots.iter().any(|r| inside(&resolved, &r.resolved)) {
return Ok(());
}
let root = shown.display();
match access {
Access::Read => Err(format!("automation path rejected: outside the read root {root}: {}", path.display())),
Access::Write => Err(format!("automation path rejected: outside the write root {root}: {}", path.display())),
let shown: Vec<String> = roots.iter().map(|r| r.shown.display().to_string()).collect();
match (shown.as_slice(), access) {
([root], Access::Read) => Err(format!("automation path rejected: outside the read root {root}: {}", path.display())),
([root], Access::Write) => Err(format!("automation path rejected: outside the write root {root}: {}", path.display())),
(_, Access::Read) => Err(format!("automation path rejected: outside the read roots {}: {}", shown.join(", "), path.display())),
(_, Access::Write) => Err(format!("automation path rejected: outside the write roots {}: {}", shown.join(", "), path.display())),
}
}
}
@@ -279,12 +295,24 @@ mod tests {
}
std::fs::write(base.join("in/a.designcraft"), "x").unwrap();
std::fs::write(base.join("outside/secret.designcraft"), "x").unwrap();
let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap();
let roots = AutomationRoots::new(&[&base.join("in")], &[&base.join("out")]).unwrap().unwrap();
(base, roots)
}
fn s(p: PathBuf) -> String {
p.to_string_lossy().into_owned()
let text = p.to_string_lossy();
if !cfg!(windows) {
return text.into_owned();
}
// A canonical path on Windows has a `\\?\` prefix, under which `..` is not a name:
// send ordinary paths, as the roots show them.
if let Some(rest) = text.strip_prefix(r"\\?\UNC\") {
return format!(r"\\{rest}");
}
match text.strip_prefix(r"\\?\") {
Some(rest) => rest.to_owned(),
None => text.into_owned(),
}
}
#[test]
@@ -339,18 +367,18 @@ mod tests {
#[test]
fn a_missing_root_is_an_error_and_none_is_no_confinement() {
let (base, _) = folders("missing");
let e = AutomationRoots::new(Some(&base.join("nope")), None).unwrap_err();
let e = AutomationRoots::new(&[&base.join("nope")], &[]).unwrap_err();
assert!(e.starts_with("cannot open the automation read root"), "{e}");
let e = AutomationRoots::new(None, Some(&base.join("in/a.designcraft"))).unwrap_err();
let e = AutomationRoots::new(&[], &[&base.join("in/a.designcraft")]).unwrap_err();
assert!(
e.starts_with("cannot open the automation write root")
&& e.ends_with(&std::io::Error::from(std::io::ErrorKind::NotADirectory).to_string()),
"{e}"
);
assert!(AutomationRoots::new(Some(Path::new("")), None).unwrap_err().starts_with("cannot open the automation read root ``"));
assert!(AutomationRoots::new(None, None).unwrap().is_none());
assert!(AutomationRoots::new(&[Path::new("")], &[]).unwrap_err().starts_with("cannot open the automation read root ``"));
assert!(AutomationRoots::new(&[], &[]).unwrap().is_none());
// One root alone grants only its own access.
let read_only = AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap();
let read_only = AutomationRoots::new(&[&base.join("in")], &[]).unwrap().unwrap();
let e = read_only.check(s(base.join("in/x.designcraft")), Access::Write).unwrap_err();
assert!(e.contains("write authority is absent"), "{e}");
let _ = std::fs::remove_dir_all(base);
@@ -387,11 +415,125 @@ mod tests {
assert_eq!(roots.check(s(base.join("in/alias/x.designcraft")), Access::Read), Ok(()));
// A root given through a link is the folder it leads to.
assert!(made(&base.join("in"), &base.join("in-link")));
let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap();
let linked = AutomationRoots::new(&[&base.join("in-link")], &[]).unwrap().unwrap();
assert_eq!(linked.check(s(base.join("in/a.designcraft")), Access::Read), Ok(()));
let _ = std::fs::remove_dir_all(base);
}
/// `in` and `shared` (read roots), `out` and `scratch` (write roots) beside `outside`, the
/// siblings `in2` and `scratch2` whose names start like a root's, and a file in their parent.
/// The folder is returned resolved (`/var` is `/private/var` on macOS), as the roots show it, so
/// a path that passes only because it was resolved is the case being tested and nothing else.
fn several(name: &str) -> (PathBuf, Arc<AutomationRoots>) {
let (base, _) = folders(name);
let base = std::fs::canonicalize(base).unwrap();
for d in ["shared", "scratch", "scratch2"] {
std::fs::create_dir_all(base.join(d)).unwrap();
}
for f in ["shared/b.designcraft", "in2/c.designcraft", "parent.designcraft"] {
std::fs::write(base.join(f), "x").unwrap();
}
let roots = AutomationRoots::new(&[&base.join("in"), &base.join("shared")], &[&base.join("out"), &base.join("scratch")]).unwrap().unwrap();
(base, roots)
}
/// #1552: several roots of a kind: a path inside any of them passes, one inside none is refused
/// naming them all, and read and write stay separate.
#[test]
fn several_roots_of_a_kind_each_grant_their_access() {
let (base, roots) = several("several");
assert_eq!(roots.check(s(base.join("in/a.designcraft")), Access::Read), Ok(()));
assert_eq!(roots.check(s(base.join("shared/b.designcraft")), Access::Read), Ok(()));
assert_eq!(roots.check(s(base.join("out/x.png")), Access::Write), Ok(()));
assert_eq!(roots.check(s(base.join("scratch/new/x.png")), Access::Write), Ok(()));
let e = roots.check(s(base.join("outside/secret.designcraft")), Access::Read).unwrap_err();
let listed = format!("outside the read roots {}, {}: ", s(base.join("in")), s(base.join("shared")));
assert!(e.starts_with("automation path rejected: ") && e.contains(&listed), "{e}");
assert!(roots.check(s(base.join("outside/x.png")), Access::Write).unwrap_err().contains("outside the write roots"));
// Two sibling roots don't make their parent a root.
assert!(roots.check(s(base.join("parent.designcraft")), Access::Read).unwrap_err().contains("outside the read roots"));
assert!(roots.check(s(base.clone()), Access::Read).is_err());
assert!(roots.check(s(base.join("x.png")), Access::Write).unwrap_err().contains("outside the write roots"));
// Read and write stay separate: a read root is not a write root, nor the other way round.
assert!(roots.check(s(base.join("shared/b.designcraft")), Access::Write).unwrap_err().contains("outside the write roots"));
assert!(roots.check(s(base.join("in/x.png")), Access::Write).is_err());
assert!(roots.check(s(base.join("scratch/x.png")), Access::Read).unwrap_err().contains("outside the read roots"));
assert!(roots.check(s(base.join("out")), Access::Read).is_err());
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn several_roots_compare_whole_folder_names() {
let (base, roots) = several("several-prefix");
assert!(roots.check(s(base.join("in2/c.designcraft")), Access::Read).unwrap_err().contains("outside the read roots"), "in2 is not inside in");
assert!(roots.check(format!("{}2/x.designcraft", s(base.join("shared"))), Access::Read).is_err());
assert!(
roots.check(s(base.join("scratch2/x.png")), Access::Write).unwrap_err().contains("outside the write roots"),
"scratch2 is not inside scratch"
);
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn parent_traversal_between_several_roots() {
let (base, roots) = several("several-dotdot");
let sep = std::path::MAIN_SEPARATOR;
let up = |root: &str, rest: &str| format!("{}{sep}..{sep}{rest}", s(base.join(root)));
// `..` out of one root is outside, also when it lands next to another root.
assert!(roots.check(up("shared", &format!("outside{sep}secret.designcraft")), Access::Read).unwrap_err().contains("outside the read roots"));
assert!(roots.check(up("shared", "parent.designcraft"), Access::Read).is_err());
assert!(roots.check(up("scratch", &format!("outside{sep}x.png")), Access::Write).unwrap_err().contains("outside the write roots"));
// `..` from one root into another of the same kind stays inside.
assert_eq!(roots.check(up("shared", &format!("in{sep}a.designcraft")), Access::Read), Ok(()));
assert_eq!(roots.check(up("scratch", &format!("out{sep}x.png")), Access::Write), Ok(()));
// ...but not into a root of the other kind.
assert!(roots.check(up("scratch", &format!("in{sep}x.png")), Access::Write).unwrap_err().contains("outside the write roots"));
assert!(roots.check(up("shared", &format!("out{sep}x.png")), Access::Read).unwrap_err().contains("outside the read roots"));
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn several_roots_are_each_checked_and_counted_once() {
let (base, _) = folders("several-open");
// A missing folder among several is an error, wherever it stands.
for read in [[base.join("in"), base.join("nope")], [base.join("nope"), base.join("in")]] {
let e = AutomationRoots::new(&[&read[0], &read[1]], &[]).unwrap_err();
assert!(e.starts_with("cannot open the automation read root") && e.contains("nope"), "{e}");
}
let e = AutomationRoots::new(&[&base.join("in")], &[&base.join("out"), &base.join("in/a.designcraft")]).unwrap_err();
assert!(e.starts_with("cannot open the automation write root"), "{e}");
// A folder given twice (here once through `..`) counts once.
let twice = AutomationRoots::new(&[&base.join("in"), &base.join("in/sub/.."), &base.join("in")], &[]).unwrap().unwrap();
let real = std::fs::canonicalize(&base).unwrap();
assert_eq!(twice.describe(), format!("read {}; write none", s(real.join("in"))));
let e = twice.check(s(base.join("outside/x")), Access::Read).unwrap_err();
assert!(e.contains("outside the read root ") && !e.contains("roots"), "{e}");
let _ = std::fs::remove_dir_all(base);
}
/// A link inside one root that leads into another root of the same kind stays inside; one that
/// leads out of all of them, or into a root of the other kind, is outside.
#[cfg(unix)]
#[test]
fn links_between_several_roots() {
let (base, roots) = several("several-links");
let link = |target: &str, at: &str| std::os::unix::fs::symlink(base.join(target), base.join(at)).unwrap();
link("shared/b.designcraft", "in/to-shared.designcraft");
link("outside/secret.designcraft", "shared/leak.designcraft");
link("", "shared/up");
link("out", "in/to-out");
link("outside", "scratch/away");
link("in", "scratch/to-in");
assert_eq!(roots.check(s(base.join("in/to-shared.designcraft")), Access::Read), Ok(()));
assert!(roots.check(s(base.join("shared/leak.designcraft")), Access::Read).unwrap_err().contains("outside the read roots"));
assert!(roots.check(s(base.join("shared/up/outside/secret.designcraft")), Access::Read).unwrap_err().contains("outside the read roots"));
assert!(roots.check(s(base.join("shared/up/parent.designcraft")), Access::Read).is_err());
assert!(roots.check(s(base.join("in/to-out/x.png")), Access::Read).unwrap_err().contains("outside the read roots"));
assert!(roots.check(s(base.join("scratch/away/x.png")), Access::Write).unwrap_err().contains("outside the write roots"));
assert!(roots.check(s(base.join("scratch/to-in/x.png")), Access::Write).unwrap_err().contains("outside the write roots"));
let _ = std::fs::remove_dir_all(base);
}
#[cfg(windows)]
#[test]
fn windows_ignores_case_and_refuses_devices() {
+1 -1
View File
@@ -13,7 +13,7 @@ use crate::{EngineError, Result, Session};
pub fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
payload
.downcast_ref::<&str>()
.map(|s| s.to_string())
.map(ToString::to_string)
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "unknown panic".into())
}
+1 -2
View File
@@ -21,8 +21,7 @@ use std::path::{Path, PathBuf};
/// refused path is never handed to the file system; callers treat it as a missing file.
pub fn document_path(stored: &str, document: Option<&Path>) -> Result<PathBuf, Refused> {
let document = document.map(|d| std::path::absolute(d).unwrap_or_else(|_| d.to_path_buf()));
let path = craft_docpath::resolve(stored, document.as_deref())?;
if path.is_absolute() { Ok(path) } else { Err(Refused::Invalid) }
craft_docpath::resolve_absolute(stored, document.as_deref())
}
/// [`document_path`] for a path stored in a file inside `folder` (a data file's image names).
+37 -22
View File
@@ -33,7 +33,7 @@ pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
pub use designcraft_compose as compose;
pub use designcraft_doc as doc;
pub use designcraft_tools as tools;
pub use tooling::{SnapView, UiRequest, ViewInfo};
pub use tooling::{ImeMutation, ImeTextContext, SnapView, UiRequest, ViewInfo, ime_delete_ranges};
#[derive(Debug, thiserror::Error)]
pub enum EngineError {
@@ -101,6 +101,9 @@ pub struct DocState {
/// Editing parent spreads (Pages panel double-click on a parent).
pub editing_parents: bool,
pub uid: u64,
/// Changes whenever this document state is recreated, even if [`Self::uid`] is preserved
/// (File › Revert keeps the uid for recovery but replaces the document).
pub identity_epoch: u64,
/// Recovery entries this state actually opened or published, for repeated-open and cleanup.
/// Bounded, deduplicated checkpoint addresses; never inferred from uid alone.
pub(crate) recovery_entries: Vec<(std::path::PathBuf, u64)>,
@@ -145,6 +148,7 @@ impl DocState {
let active_layer = doc.default_layer();
let doc = Arc::new(doc);
let revision = next_revision();
let identity_epoch = NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
DocState {
saved_doc: doc.clone(),
doc,
@@ -156,7 +160,8 @@ impl DocState {
active_layer,
interaction: None,
editing_parents: false,
uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
uid: identity_epoch,
identity_epoch,
recovery_entries: Vec::new(),
preview_stash: None,
preview_record: None,
@@ -542,17 +547,7 @@ impl Session {
// passes that its command's description doesn't name fails the test.
#[cfg(test)]
check_params(id, params)?;
if let Err(e) = (spec.enabled)(self) {
// A command that takes `ids` acts on the objects the call names, so the selection
// doesn't matter: with a text caret, nothing selected, or a selection the command
// can't act on, `{"ids": [5]}` still runs.
let named = if cmd::selection_reason(&e) { cmd::named_targets(spec, params) } else { None };
let Some(ids) = named else { return Err(EngineError::Disabled(id.into(), e)) };
let st = self.doc()?;
if let Some(gone) = ids.iter().find(|i| st.doc.item(**i).is_none()) {
return Err(EngineError::Disabled(id.into(), format!("no object with id {}", gone.0)));
}
}
self.check_enabled(spec, id, params)?;
// Preview is temporary. Save, merge, and every other edit restore the template first.
// Queries stay read-only so the panel can poll fields during a preview. `data.preview`
// itself is the one command that fills the live document.
@@ -598,11 +593,38 @@ impl Session {
st.doc = Arc::new(d);
}
}
// Auto-size frames fit the text the command changed, in the same undo step.
self.autosize_changed(spec, before.as_ref());
self.keep_guide_selection_valid(before.as_ref(), id);
self.record_undo_step(spec, before, nested);
if spec.journal {
self.journal.push((id.to_string(), params.clone()));
}
self.discard_stale_find_scope();
Ok(r)
}
/// Fails with `Disabled` unless the command is enabled or names objects it can act on.
fn check_enabled(&self, spec: &CommandSpec, id: &str, params: &Value) -> Result<()> {
if let Err(e) = (spec.enabled)(self) {
// A command that takes `ids` acts on the objects the call names, so the selection
// doesn't matter: with a text caret, nothing selected, or a selection the command
// can't act on, `{"ids": [5]}` still runs.
let named = if cmd::selection_reason(&e) { cmd::named_targets(spec, params) } else { None };
let Some(ids) = named else { return Err(EngineError::Disabled(id.into(), e)) };
let st = self.doc()?;
if let Some(gone) = ids.iter().find(|i| st.doc.item(**i).is_none()) {
return Err(EngineError::Disabled(id.into(), format!("no object with id {}", gone.0)));
}
}
Ok(())
}
/// Auto-size frames fit the text the command changed, in the same undo step.
fn autosize_changed(&mut self, spec: &CommandSpec, before: Option<&(u64, Arc<Document>, Selection)>) {
if spec.undoable
&& let Some(st) = self.active_mut()
{
let prev = before.as_ref().filter(|(uid, _, _)| *uid == st.uid).map(|(_, d, _)| d.as_ref());
let prev = before.filter(|(uid, _, _)| *uid == st.uid).map(|(_, d, _)| d.as_ref());
if prev.is_none_or(|p| !std::ptr::eq(p, st.doc.as_ref())) {
let todo = autosize::candidates(&st.doc, prev);
if !todo.is_empty() {
@@ -614,13 +636,6 @@ impl Session {
}
}
}
self.keep_guide_selection_valid(before.as_ref(), id);
self.record_undo_step(spec, before, nested);
if spec.journal {
self.journal.push((id.to_string(), params.clone()));
}
self.discard_stale_find_scope();
Ok(r)
}
/// Drops selected guides the command `id` made stale, against the document as it will be
+912 -346
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -408,7 +408,7 @@ fn remove_emptied(dir: &Path) {
let path = e.path();
let name = e.file_name();
let name = name.to_string_lossy();
let numbered = path.file_stem().and_then(|s| s.to_str()).is_some_and(|s| s.parse::<u64>().is_ok());
let numbered = path.file_stem().and_then(std::ffi::OsStr::to_str).is_some_and(|s| s.parse::<u64>().is_ok());
// Metadata whose document has gone, and a file a crash left half written.
if (numbered && name.ends_with(".json")) || (name.starts_with(".designcraft-recovery-") && name.ends_with(".tmp")) {
left.push(path);
@@ -726,8 +726,8 @@ fn occupied_uids(dir: &Path) -> Result<std::collections::HashSet<u64>> {
for entry in entries {
let entry = entry.map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
let path = entry.path();
if matches!(path.extension().and_then(|s| s.to_str()), Some("json" | "designcraft"))
&& let Some(uid) = path.file_stem().and_then(|s| s.to_str()).and_then(|s| s.parse().ok())
if matches!(path.extension().and_then(std::ffi::OsStr::to_str), Some("json" | "designcraft"))
&& let Some(uid) = path.file_stem().and_then(std::ffi::OsStr::to_str).and_then(|s| s.parse().ok())
{
ids.insert(uid);
}
@@ -756,10 +756,10 @@ pub fn list(dir: &Path) -> Vec<(u64, Value)> {
let Ok(rd) = std::fs::read_dir(dir) else { return out };
for e in rd.flatten() {
let p = e.path();
if p.extension().and_then(|x| x.to_str()) != Some("designcraft") {
if p.extension().and_then(std::ffi::OsStr::to_str) != Some("designcraft") {
continue;
}
let Some(uid) = p.file_stem().and_then(|s| s.to_str()).and_then(|s| s.parse::<u64>().ok()) else { continue };
let Some(uid) = p.file_stem().and_then(std::ffi::OsStr::to_str).and_then(|s| s.parse::<u64>().ok()) else { continue };
// Metadata is a file on disk: anything but an object (corrupt, hand-edited) counts as none.
let meta = std::fs::read_to_string(p.with_extension("json"))
.ok()
+41
View File
@@ -30,6 +30,47 @@ fn steps(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
#[test]
fn ime_byte_deletion_rounds_inward_from_the_requested_count() {
// a: 0..1, é: 1..3, 😀: 3..7, b: 7..8.
let text = "aé😀b";
assert_eq!(ime_delete_ranges(text, 7, 7, 4, 0, true), Some((3..7, 7..7)), "the whole emoji");
assert_eq!(ime_delete_ranges(text, 7, 7, 6, 0, true), Some((1..7, 7..7)), "é and the emoji");
assert_eq!(ime_delete_ranges(text, 7, 7, 3, 0, true), Some((7..7, 7..7)), "a count starting inside the emoji leaves it");
assert_eq!(ime_delete_ranges(text, 7, 7, 100, 0, true), Some((0..7, 7..7)), "more bytes than the text takes it all");
// The scan starts at the requested count, not at the start of the story.
let long = format!("{}é", "x".repeat(20_000));
let end = long.len();
assert_eq!(ime_delete_ranges(&long, end, end, 3, 0, true), Some((end - 3..end, end..end)), "x and é");
assert_eq!(ime_delete_ranges(&long, end, end, 2, 0, true), Some((end - 2..end, end..end)), "the whole é");
assert_eq!(ime_delete_ranges(&long, end, end, 1, 0, true), Some((end..end, end..end)), "one byte inside é leaves it");
}
#[test]
fn ime_delete_reports_the_ranges_it_applied() {
let mut s = session();
let sid = typing_into(&mut s, "aé😀b");
s.execute("text.select", &json!({"story": sid, "anchor": 1, "focus": 3})).unwrap();
let m = s.ime_delete_surrounding(2, 4, true).unwrap().expect("the delete changed the text");
assert_eq!(m.deleted_before, 0..1, "the byte before the selection");
assert_eq!(m.deleted_after, 3..7, "the emoji after it");
assert_eq!((m.anchor_before, m.focus_before), (1, 3));
assert_eq!((m.anchor_after, m.focus_after), (0, 2), "the selection stays selected, shifted left");
assert_eq!((m.text_len_before, m.text_len_after), (8, 3));
assert!(m.inserted.is_empty());
assert_eq!(text(&mut s, sid), "éb");
// A request that removes nothing is reported as no mutation at all.
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap();
assert!(s.ime_delete_surrounding(0, 0, false).unwrap().is_none());
// A commit reports the text it typed and replaces the selection.
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 2})).unwrap();
let m = s.ime_commit("日", ViewInfo::default()).unwrap().expect("the commit changed the text");
assert_eq!(m.inserted, "日");
assert!(m.deleted_before.is_empty() && m.deleted_after.is_empty());
assert_eq!((m.anchor_after, m.focus_after), (3, 3));
assert_eq!(text(&mut s, sid), "日b");
}
#[test]
fn ime_composition_shows_in_place_and_commits_as_one_step() {
// Romaji → kana → conversion → commit, as the macOS Japanese IME sends it.
+70
View File
@@ -105,3 +105,73 @@ fn a_relative_link_resolves_beside_the_document() {
assert!(seen.iter().all(|p| p.starts_with(&dir)), "{seen:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_relative_link_in_an_unsaved_document_is_missing() {
// With no document path there is nothing to resolve against; the working directory is not used.
let mut s = document_with_links(Path::new("doc.designcraft"), &["logo.png", "img/logo.png"]);
s.doc_mut().unwrap().path = None;
let (rows, seen) = crate::io::touched(|| s.execute("links.list", &json!({})).unwrap());
assert!(seen.is_empty(), "{seen:?}");
for row in rows.as_array().unwrap() {
assert_eq!(row["status"], "missing", "{row}");
assert_eq!(row["refused"], "the path is not valid", "{row}");
}
}
#[cfg(unix)]
#[test]
fn relink_folder_never_leaves_the_chosen_folder() {
// A stored name with no separator can still be a `file:` URL that decodes to a path elsewhere.
let dir = folder("relink-outside");
let chosen = dir.join("chosen");
std::fs::create_dir_all(&chosen).unwrap();
let outside = dir.join("outside.png");
std::fs::write(&outside, png()).unwrap();
let encoded = format!("file:{}", outside.to_string_lossy().replace('/', "%2F"));
let mut s = document_with_links(&dir.join("doc.designcraft"), &[&encoded]);
let (out, seen) =
crate::io::touched(|| s.execute("links.relinkFolder", &json!({"dir": chosen.to_string_lossy(), "missingOnly": false})).unwrap());
assert_eq!(out["relinked"], 0, "{out}");
assert!(!seen.contains(&outside), "{seen:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn relink_folder_takes_the_chosen_folder_as_one_folder() {
// `a\b` is one folder name on Unix; read as a stored path it would be `a/b`.
let dir = folder("relink-backslash");
let chosen = dir.join("a\\b");
std::fs::create_dir_all(&chosen).unwrap();
std::fs::create_dir_all(dir.join("a/b")).unwrap();
let elsewhere = dir.join("a/b/x.png");
std::fs::write(&elsewhere, png()).unwrap();
let mut s = document_with_links(&dir.join("doc.designcraft"), &["x.png"]);
let (out, seen) =
crate::io::touched(|| s.execute("links.relinkFolder", &json!({"dir": chosen.to_string_lossy(), "missingOnly": false})).unwrap());
assert_eq!(out["relinked"], 0, "{out}");
assert!(!seen.contains(&elsewhere), "{seen:?}");
// A folder given with `..` is still that folder.
let real = dir.join("real");
std::fs::create_dir_all(&real).unwrap();
std::fs::write(real.join("x.png"), png()).unwrap();
let roundabout = real.join("..").join("real");
let out = s.execute("links.relinkFolder", &json!({"dir": roundabout.to_string_lossy(), "missingOnly": false})).unwrap();
assert_eq!(out["relinked"], 1, "{out}");
// Through a symlink, `alias/..` is the link target's parent, not the folder holding `alias`.
let inner = dir.join("inner");
std::fs::create_dir_all(inner.join("sub")).unwrap();
std::os::unix::fs::symlink(inner.join("sub"), dir.join("alias")).unwrap();
let beside_alias = dir.join("x.png");
std::fs::write(&beside_alias, png()).unwrap();
let mut s = document_with_links(&dir.join("doc.designcraft"), &["x.png"]);
let (out, seen) = crate::io::touched(|| {
s.execute("links.relinkFolder", &json!({"dir": dir.join("alias").join("..").to_string_lossy(), "missingOnly": false})).unwrap()
});
assert_eq!(out["relinked"], 0, "{out}");
assert!(!seen.contains(&beside_alias), "{seen:?}");
let _ = std::fs::remove_dir_all(&dir);
}
+1 -1
View File
@@ -23,7 +23,7 @@ fn folders(name: &str) -> (PathBuf, Arc<AutomationRoots>) {
for d in ["in", "out", "outside"] {
std::fs::create_dir_all(base.join(d)).unwrap();
}
let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap();
let roots = AutomationRoots::new(&[&base.join("in")], &[&base.join("out")]).unwrap().unwrap();
(base, roots)
}
+294 -1
View File
@@ -1,12 +1,14 @@
//! Tool hosting: the active tool receives pointer/key events and its actions are executed here.
use std::ops::Range;
use std::sync::Arc;
use designcraft_doc::{CellAddr, StoryId};
use designcraft_geom::Unit;
pub use designcraft_tools::SnapView;
use designcraft_tools::{Action, CanvasLayout, Cursor, Mods, Overlay, PointerEvent, PointerKind, ToolContext, ToolKey};
use serde::Serialize;
use serde_json::Value;
use serde_json::{Value, json};
use crate::{EngineError, HistoryEntry, Interaction, Result, Session, record_undo};
@@ -346,3 +348,294 @@ impl Session {
}
}
}
/// The committed text around the Type tool's IME caret.
///
/// While an IME composition is active the marked text is only an interaction preview, so this
/// borrows the interaction's committed snapshot and selection instead of the previewed document.
/// `anchor` and `focus` are UTF-8 byte offsets into `text`; `focus` is the primary caret end.
#[derive(Clone, Copy, Debug)]
pub struct ImeTextContext<'a> {
/// Stable document identity (a new document gets a new value).
pub document_uid: u64,
/// Document-state identity: changes when the state is recreated even if `document_uid` is
/// preserved (File › Revert).
pub document_epoch: u64,
/// Story being edited, or the story that owns the edited table cell.
pub story: StoryId,
/// Edited table cell, when the caret is in one.
pub cell: Option<CellAddr>,
/// Committed logical story text, without the current IME pre-edit.
pub text: &'a str,
/// Selection anchor (the end that stays put under Shift-arrow selection).
pub anchor: usize,
/// Primary selection end (the caret).
pub focus: usize,
}
/// What an accepted IME mutation applied to the committed story text.
///
/// The engine reports this from the whole story, not from the bounded outgoing IME window, so a
/// successful mutation can be described exactly even when the deleted text lies outside that
/// window. `deleted_before` and `deleted_after` are byte ranges of the text before the mutation;
/// `inserted` is the text the mutation typed at the selection. Invalid or unreportable mutations
/// return `None` rather than a guessed description.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ImeMutation {
/// Stable document identity of the mutation.
pub document_uid: u64,
/// Document-state identity (see [`ImeTextContext::document_epoch`]).
pub document_epoch: u64,
/// Story (or the story owning the edited cell) that changed.
pub story: StoryId,
/// Edited table cell, when the caret was in one.
pub cell: Option<CellAddr>,
/// Committed selection before and after the mutation (UTF-8 byte offsets).
pub anchor_before: usize,
pub focus_before: usize,
pub anchor_after: usize,
pub focus_after: usize,
/// Committed story text length before and after the mutation.
pub text_len_before: usize,
pub text_len_after: usize,
/// Text removed before and after the selection (empty when nothing was removed).
pub deleted_before: Range<usize>,
pub deleted_after: Range<usize>,
/// Text typed at the selection, after the commit normalization.
pub inserted: String,
}
/// The identity, committed selection and text length at the IME caret.
#[derive(Clone, Copy, Debug)]
struct ImeState {
document_uid: u64,
document_epoch: u64,
story: StoryId,
cell: Option<CellAddr>,
anchor: usize,
focus: usize,
text_len: usize,
}
/// The ranges a successful `text.deleteSurrounding` reports it removed.
fn ime_ranges_from_command(r: &Value) -> Option<(Range<usize>, Range<usize>)> {
let range = |key: &str| {
let arr = r.get(key)?.as_array()?;
let start = usize::try_from(arr.first()?.as_u64()?).ok()?;
let end = usize::try_from(arr.get(1)?.as_u64()?).ok()?;
(start <= end).then_some(start..end)
};
Some((range("deletedBefore")?, range("deletedAfter")?))
}
/// The byte ranges an IME delete-surrounding request removes around a selection.
///
/// The returned pair is `(before_selection, after_selection)`. Counts exclude the selection
/// itself and the current pre-edit. Character counts are Unicode scalar values. Byte counts
/// round inward: a count that ends inside a character leaves that whole character. Invalid or
/// non-boundary selection offsets return `None` rather than guessed ranges.
pub fn ime_delete_ranges(text: &str, anchor: usize, focus: usize, before: usize, after: usize, bytes: bool) -> Option<(Range<usize>, Range<usize>)> {
if anchor > text.len() || focus > text.len() || !text.is_char_boundary(anchor) || !text.is_char_boundary(focus) {
return None;
}
let min = anchor.min(focus);
let max = anchor.max(focus);
if bytes {
// Start at the requested byte offset and step at most three bytes to the next character
// boundary: the scan is bounded by the count, not by the caret's offset in the story.
let from = min.saturating_sub(before).min(min);
let mut before_start = from;
while before_start < min && !text.is_char_boundary(before_start) {
before_start += 1;
}
let target = max.saturating_add(after).min(text.len());
let mut after_end = max;
for (i, c) in text[max..].char_indices() {
let end = max + i + c.len_utf8();
if end <= target {
after_end = end;
} else {
break;
}
}
Some((before_start..min, max..after_end))
} else {
let before_start = if before == 0 {
min
} else {
let mut seen = 0usize;
let mut start = 0usize;
for (i, _) in text[..min].char_indices().rev() {
seen += 1;
start = i;
if seen == before {
break;
}
}
if seen < before { 0 } else { start }
};
let mut after_end = max;
if after > 0 {
let mut seen = 0usize;
for (i, c) in text[max..].char_indices() {
seen += 1;
after_end = max + i + c.len_utf8();
if seen == after {
break;
}
}
}
Some((before_start..min, max..after_end))
}
}
impl Session {
/// The committed text and selection at the Type tool's IME caret.
///
/// Returns `None` without an insertion point, without a committed story, or while a
/// composition has no interaction snapshot to describe its committed text. The query never
/// copies the story.
pub fn ime_text_context(&self) -> Option<ImeTextContext<'_>> {
let st = self.active()?;
let (doc, ts) = if self.tool.composing() {
let it = st.interaction.as_ref()?;
(it.doc.as_ref(), it.selection.text?)
} else {
(st.doc.as_ref(), st.selection.text?)
};
let story = doc.text_story(ts.story, ts.cell)?;
if ts.anchor > story.text.len()
|| ts.focus > story.text.len()
|| !story.text.is_char_boundary(ts.anchor)
|| !story.text.is_char_boundary(ts.focus)
{
// A stale or corrupt insertion point: report unavailable rather than inventing offsets.
return None;
}
Some(ImeTextContext {
document_uid: st.uid,
document_epoch: st.identity_epoch,
story: ts.story,
cell: ts.cell,
text: &story.text,
anchor: ts.anchor,
focus: ts.focus,
})
}
/// Delete the text an IME asked to remove around the selection, as one undo step.
///
/// The mutation goes through the `text.deleteSurrounding` command, so it restores a Data Merge
/// preview, records undo, auto-sizes, journals and is panic-guarded like any other edit.
/// The selection itself and any current pre-edit are excluded. Character counts are scalar
/// values; byte counts round inward to whole characters. A selection keeps its direction and
/// is remapped past the deleted text.
pub fn ime_delete_surrounding(&mut self, before: usize, after: usize, bytes: bool) -> Result<Option<ImeMutation>> {
if !self.ime_deletion_changes_text(before, after, bytes)? {
return Ok(None);
}
let Some(pre) = self.ime_state() else { return Ok(None) };
self.end_preedit_without_typing()?;
let r = self.execute("text.deleteSurrounding", &json!({"before": before, "after": after, "bytes": bytes}))?;
Ok(self.ime_mutation_from_command(pre, &r, String::new()))
}
/// The IME committed `text`: one `text.insert`, reported as the mutation it applied.
pub fn ime_commit(&mut self, text: &str, view: ViewInfo) -> Result<Option<ImeMutation>> {
let Some(pre) = self.ime_state() else { return self.tool_ime_commit(text, view).map(|()| None) };
self.tool_ime_commit(text, view)?;
let inserted = designcraft_tools::ime_commit_text(text);
// A commit replaces the selection: the removed ranges are empty at its two ends.
let (start, end) = (pre.anchor.min(pre.focus), pre.anchor.max(pre.focus));
Ok(self.ime_mutation_from_states(pre, start..start, end..end, inserted))
}
/// Delete surrounding text and insert the IME commit in one undo step.
///
/// This is the normal delete-surrounding-then-commit batch: both mutations are one
/// `text.deleteSurrounding` command, so one Undo restores the state before the batch.
pub fn ime_commit_after_delete(&mut self, text: &str, view: ViewInfo, before: usize, after: usize, bytes: bool) -> Result<Option<ImeMutation>> {
if !self.ime_deletion_changes_text(before, after, bytes)? {
return self.ime_commit(text, view);
}
let Some(pre) = self.ime_state() else { return Ok(None) };
self.end_preedit_without_typing()?;
let inserted = designcraft_tools::ime_commit_text(text);
let r = self.execute("text.deleteSurrounding", &json!({"before": before, "after": after, "bytes": bytes, "commit": inserted.clone()}))?;
Ok(self.ime_mutation_from_command(pre, &r, inserted))
}
/// The identity, committed selection and text length of the IME caret's story.
fn ime_state(&self) -> Option<ImeState> {
let context = self.ime_text_context()?;
Some(ImeState {
document_uid: context.document_uid,
document_epoch: context.document_epoch,
story: context.story,
cell: context.cell,
anchor: context.anchor,
focus: context.focus,
text_len: context.text.len(),
})
}
/// Report the mutation the delete command applied, from its ranges and the states around it.
fn ime_mutation_from_command(&self, before: ImeState, r: &Value, inserted: String) -> Option<ImeMutation> {
let (deleted_before, deleted_after) = ime_ranges_from_command(r)?;
self.ime_mutation_from_states(before, deleted_before, deleted_after, inserted)
}
fn ime_mutation_from_states(
&self,
before: ImeState,
deleted_before: Range<usize>,
deleted_after: Range<usize>,
inserted: String,
) -> Option<ImeMutation> {
let after = self.ime_state()?;
// The mutation must have stayed in the story it began in.
if after.document_uid != before.document_uid
|| after.document_epoch != before.document_epoch
|| after.story != before.story
|| after.cell != before.cell
{
return None;
}
Some(ImeMutation {
document_uid: before.document_uid,
document_epoch: before.document_epoch,
story: before.story,
cell: before.cell,
anchor_before: before.anchor,
focus_before: before.focus,
anchor_after: after.anchor,
focus_after: after.focus,
text_len_before: before.text_len,
text_len_after: after.text_len,
deleted_before,
deleted_after,
inserted,
})
}
fn end_preedit_without_typing(&mut self) -> Result<()> {
if !self.tool.composing() {
return Ok(());
}
match self.with_ctx(ViewInfo::default(), |s, cx| s.tool.ime_preedit(cx, "", None)) {
Some(actions) => self.run_actions(actions),
None => {
self.tool = designcraft_tools::create(self.tool.id());
Ok(())
}
}
}
fn ime_deletion_changes_text(&self, before: usize, after: usize, bytes: bool) -> Result<bool> {
let Some(context) = self.ime_text_context() else { return Ok(false) };
let Some((before_range, after_range)) = ime_delete_ranges(context.text, context.anchor, context.focus, before, after, bytes) else {
return Err(EngineError::Other("the IME text selection is not on character boundaries".into()));
};
Ok(!before_range.is_empty() || !after_range.is_empty())
}
}
+3 -1
View File
@@ -207,7 +207,9 @@ fn failed_layer_rewrite_aborts_export_instead_of_revealing_hidden_content() {
session.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap();
let placed =
session.execute("file.place", &json!({"base64": base64_encode(&bytes), "name": "missing-layers.pdf", "x": 0, "y": 0, "width": 100})).unwrap();
session.execute("object.layerOptions", &json!({"ids": [placed["id"]], "hidden": ["Red"]})).unwrap();
// Commands now reject unrewritable choices. Simulate stale persisted state after relinking.
let id: designcraft_doc::ItemId = serde_json::from_value(placed["id"].clone()).unwrap();
std::sync::Arc::make_mut(&mut session.doc_mut().unwrap().doc).item_mut(id).unwrap().pdf_hidden_layers = vec!["Red".into()];
let before = session.execute("file.serialize", &json!({})).unwrap();
let result = session.execute("file.exportPdf", &json!({}));
assert!(result.is_err(), "failed layer hiding must not export the original visible layers");
+57 -31
View File
@@ -450,11 +450,12 @@ fn char_css(doc: &Document, c: &CharProps, base_size: f64, generics: &BTreeMap<S
}
_ => {}
}
if c.underline {
out.push_str("text-decoration: underline; ");
}
if c.strikethrough {
out.push_str("text-decoration: line-through; ");
// One declaration: a later `text-decoration` would replace the earlier one.
match (c.underline, c.strikethrough) {
(true, true) => out.push_str("text-decoration: underline line-through; "),
(true, false) => out.push_str("text-decoration: underline; "),
(false, true) => out.push_str("text-decoration: line-through; "),
(false, false) => {}
}
if let Some(h) = hex(doc, &c.fill, c.fill_tint) {
let _ = write!(out, "color: {h}; ");
@@ -785,6 +786,39 @@ impl<'a, 'f> StoryWriter<'a, 'f> {
close_blocks(&mut list, &mut pre, out);
}
/// The markup for one run's text `raw` (starting at byte `a` of the story): anchored objects
/// and notes where their marks are.
fn run_markup(&mut self, st: &Story, raw: &str, a: usize, depth: usize, after: &mut String) -> String {
let doc = self.doc;
let mut t = String::new();
let mut seg_start = 0;
// Index of the next anchored object: marks count from the story start, as
// `Story::object_at` counts them (counted once, at the run's first mark).
let mut mark: Option<usize> = None;
for (i, c) in raw.char_indices() {
if c != FOOTNOTE_REF && c != ENDNOTE_REF && c != OBJECT_MARK {
continue;
}
t.push_str(&run_text(doc, raw.get(seg_start..i).unwrap_or_default()));
seg_start = i + c.len_utf8();
if c == OBJECT_MARK {
let k = mark.unwrap_or_else(|| st.text.get(..a).map_or(0, |t| t.matches(OBJECT_MARK).count()));
mark = Some(k.saturating_add(1));
if let Some(o) = st.objects.get(k) {
t.push_str(&self.object(o));
}
} else if depth < MAX_DEPTH {
if c == FOOTNOTE_REF {
self.footnote(st, a + i, depth, &mut t, after);
} else {
self.endnote(st, a + i, depth, &mut t);
}
}
}
t.push_str(&run_text(doc, raw.get(seg_start..).unwrap_or_default()));
t
}
/// The runs of paragraph `para` of `st`; footnotes go to `after`.
fn runs(&mut self, st: &Story, para: std::ops::Range<usize>, depth: usize, out: &mut String, after: &mut String) {
let doc = self.doc;
@@ -799,32 +833,7 @@ impl<'a, 'f> StoryWriter<'a, 'f> {
continue;
}
let Some(raw) = st.text.get(a..b) else { continue };
let mut t = String::new();
let mut seg_start = 0;
// Index of the next anchored object: marks count from the story start, as
// `Story::object_at` counts them (counted once, at the run's first mark).
let mut mark: Option<usize> = None;
for (i, c) in raw.char_indices() {
if c != FOOTNOTE_REF && c != ENDNOTE_REF && c != OBJECT_MARK {
continue;
}
t.push_str(&run_text(doc, raw.get(seg_start..i).unwrap_or_default()));
seg_start = i + c.len_utf8();
if c == OBJECT_MARK {
let k = mark.unwrap_or_else(|| st.text.get(..a).map_or(0, |t| t.matches(OBJECT_MARK).count()));
mark = Some(k.saturating_add(1));
if let Some(o) = st.objects.get(k) {
t.push_str(&self.object(o));
}
} else if depth < MAX_DEPTH {
if c == FOOTNOTE_REF {
self.footnote(st, a + i, depth, &mut t, after);
} else {
self.endnote(st, a + i, depth, &mut t);
}
}
}
t.push_str(&run_text(doc, raw.get(seg_start..).unwrap_or_default()));
let t = self.run_markup(st, raw, a, depth, after);
let (ctag, cls) = if f.style != story::NO_CHAR_STYLE {
let (tag, class) = export_mapping(&doc.styles, &f.style, true, &self.cc);
(tag, format!(" class=\"{}\"", xml_attr(&class)))
@@ -2223,4 +2232,21 @@ mod tests {
let last = "<li id=\"en-1\"><a href=\"#enref-1\">1</a> End note</li>";
assert_eq!(link_across_chapters(last, "content2.xhtml", &files), "<li id=\"en-1\"><a href=\"content.xhtml#enref-1\">1</a> End note</li>");
}
#[test]
fn char_css_joins_underline_and_strikethrough_in_one_declaration() {
let d = Document::new(&NewDocument::default());
let css = |u: bool, s: bool| {
let c = CharProps { underline: u, strikethrough: s, ..CharProps::default() };
let mut out = String::new();
char_css(&d, &c, 12.0, &BTreeMap::new(), &mut out);
out
};
let both = css(true, true);
assert_eq!(both.matches("text-decoration:").count(), 1, "{both}");
assert!(both.contains("text-decoration: underline line-through;"), "{both}");
assert!(css(true, false).contains("text-decoration: underline;"));
assert!(css(false, true).contains("text-decoration: line-through;"));
assert!(!css(false, false).contains("text-decoration"));
}
}
+111 -38
View File
@@ -3,7 +3,7 @@
use std::collections::HashMap;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use std::sync::{Arc, Mutex, PoisonError, RwLock};
use kurbo::BezPath;
use skrifa::instance::{Location, LocationRef, Size};
@@ -148,12 +148,12 @@ pub struct FaceRef(&'static FontFace);
impl FaceRef {
/// The handle for a loaded face (memoised per face).
pub fn of(face: &Arc<FontFace>) -> FaceRef {
static LEAKED: RwLock<Vec<Option<&'static FontFace>>> = RwLock::new(Vec::new());
static LEAKED: RwLock<Vec<Option<&FontFace>>> = RwLock::new(Vec::new());
let id = face.id as usize;
if let Some(Some(f)) = LEAKED.read().unwrap_or_else(|e| e.into_inner()).get(id) {
if let Some(Some(f)) = LEAKED.read().unwrap_or_else(PoisonError::into_inner).get(id) {
return FaceRef(f);
}
let mut w = LEAKED.write().unwrap_or_else(|e| e.into_inner());
let mut w = LEAKED.write().unwrap_or_else(PoisonError::into_inner);
if w.len() <= id {
w.resize(id + 1, None);
}
@@ -579,7 +579,7 @@ fn script_chain(c: char) -> Option<&'static [&'static str]> {
/// The Japanese fallback chain: the craft-fonts Japanese faces' families in document order
/// (Mincho first; none without craft-fonts), then [`JAPANESE_FALLBACKS`].
fn japanese_chain() -> &'static [&'static str] {
static CHAIN: std::sync::OnceLock<Vec<&'static str>> = std::sync::OnceLock::new();
static CHAIN: std::sync::OnceLock<Vec<&str>> = std::sync::OnceLock::new();
CHAIN.get_or_init(|| {
let mut chain: Vec<&'static str> = Vec::new();
for f in crate::japanese_document_fonts() {
@@ -1037,7 +1037,7 @@ pub fn same_style(a: &str, b: &str) -> bool {
}
fn norm(s: &str) -> String {
s.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect()
s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect()
}
/// Weight implied by a style name.
@@ -1067,12 +1067,20 @@ fn style_italic(style: &str) -> bool {
}
impl FontDb {
/// A database holding the bundled fonts, then the craft-fonts Japanese faces (Mincho first,
/// so they are the fallback for Japanese text after the requested and bundled fonts; empty
/// without `CRAFT_FONTS_DIR`), whose system font scan reads `font_dirs`.
/// A database holding the bundled fonts, then the craft-fonts Japanese faces (Mincho first),
/// followed by Noto Sans Hebrew Regular and Noto Sans Devanagari Regular for document text.
/// The optional craft-fonts faces are absent without `CRAFT_FONTS_DIR`; system font scans
/// read `font_dirs`. Japanese fallback and UI ordering remain independent of these additions.
pub fn with_font_dirs(font_dirs: Vec<std::path::PathBuf>) -> Self {
let mut faces = Vec::new();
let craft = crate::japanese_document_fonts().into_iter().map(|f| f.bytes);
let craft = crate::japanese_document_fonts()
.into_iter()
.chain(
["Noto Sans Hebrew", "Noto Sans Devanagari"]
.into_iter()
.filter_map(|family| crate::CRAFT_FONTS.iter().find(|f| f.family == family && f.style == "Regular")),
)
.map(|f| f.bytes);
for data in BUNDLED.iter().copied().chain(craft) {
for (i, family, style, coords) in enumerate_faces(data) {
if let Some(f) = make_face(FontBytes::Static(data), FontSource::Bundled, i, family, style, coords) {
@@ -1107,7 +1115,7 @@ impl FontDb {
pub fn set_system_fallback(&self, on: bool) {
#[cfg(not(target_arch = "wasm32"))]
{
self.sys.lock().unwrap_or_else(|e| e.into_inner()).enabled = on;
self.sys.lock().unwrap_or_else(PoisonError::into_inner).enabled = on;
}
#[cfg(target_arch = "wasm32")]
let _ = on;
@@ -1123,20 +1131,20 @@ impl FontDb {
}
fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec<Arc<FontFace>>> {
self.faces.read().unwrap_or_else(|e| e.into_inner())
self.faces.read().unwrap_or_else(PoisonError::into_inner)
}
/// The system font catalog, scanned first if it hasn't been yet.
#[cfg(not(target_arch = "wasm32"))]
fn read_catalog(&self) -> std::sync::RwLockReadGuard<'_, Vec<CatalogEntry>> {
self.ensure_catalog();
self.catalog.read().unwrap_or_else(|e| e.into_inner())
self.catalog.read().unwrap_or_else(PoisonError::into_inner)
}
/// What the document with font scope `scope` sees: its own fonts ahead of the shared ones.
/// Scope 0 (a document without fonts of its own) or a closed scope sees the shared fonts.
pub fn scoped(&self, scope: u32) -> ScopedFonts<'_> {
let own = if scope == 0 { None } else { self.scopes.read().unwrap_or_else(|e| e.into_inner()).get(&scope).cloned() };
let own = if scope == 0 { None } else { self.scopes.read().unwrap_or_else(PoisonError::into_inner).get(&scope).cloned() };
ScopedFonts { db: self, own }
}
@@ -1187,7 +1195,7 @@ impl FontDb {
/// family's bold without its regular), skipping a family and style another thread has added
/// meanwhile. Returns the number added.
fn insert_faces(&self, new: Vec<FontFace>) -> usize {
let mut faces = self.faces.write().unwrap_or_else(|e| e.into_inner());
let mut faces = self.faces.write().unwrap_or_else(PoisonError::into_inner);
let mut added = 0;
for f in new {
if !has_face(&faces, &f.family, &f.style) {
@@ -1238,7 +1246,7 @@ impl FontDb {
let count = first.unwrap_or_else(|| self.scan_font_dirs());
// Retry characters and CJK chain families that newly installed fonts can now cover.
// Reset outside scan_font_dirs: a chain lookup can hold sys during the initial scan.
let mut sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
let mut sys = self.sys.lock().unwrap_or_else(PoisonError::into_inner);
sys.generation = sys.generation.wrapping_add(1);
sys.misses.clear();
sys.chain_tried.clear();
@@ -1257,7 +1265,7 @@ impl FontDb {
// Rescan first: a lookup after the faces are dropped finds the new files.
let cataloged = self.load_system_fonts();
let changed: Vec<std::path::PathBuf> = {
let mut files = self.installed_files.lock().unwrap_or_else(|e| e.into_inner());
let mut files = self.installed_files.lock().unwrap_or_else(PoisonError::into_inner);
let mut changed: Vec<_> = files.iter().filter(|(p, stamp)| file_stamp(p).as_ref() != Some(*stamp)).map(|(p, _)| p.clone()).collect();
changed.sort();
for p in &changed {
@@ -1268,15 +1276,15 @@ impl FontDb {
if !changed.is_empty() {
let stale = |f: &FontFace| matches!(&f.source, FontSource::Installed(p) if changed.contains(p));
let mut dropped = std::collections::HashSet::new();
self.faces.write().unwrap_or_else(|e| e.into_inner()).retain(|f| {
self.faces.write().unwrap_or_else(PoisonError::into_inner).retain(|f| {
let keep = !stale(f);
if !keep {
dropped.insert(f.id);
}
keep
});
self.instances.write().unwrap_or_else(|e| e.into_inner()).retain(|(base, _), f| !dropped.contains(base) && !stale(f));
self.outlines.lock().unwrap_or_else(|e| e.into_inner()).retain(|(face, _), _| !dropped.contains(face));
self.instances.write().unwrap_or_else(PoisonError::into_inner).retain(|(base, _), f| !dropped.contains(base) && !stale(f));
self.outlines.lock().unwrap_or_else(PoisonError::into_inner).retain(|(face, _), _| !dropped.contains(face));
log::debug!("reloading {} changed font files ({} faces)", changed.len(), dropped.len());
}
ReloadedFonts { cataloged, changed }
@@ -1291,7 +1299,7 @@ impl FontDb {
fn read_installed(&self, path: &std::path::Path) -> Option<Vec<u8>> {
let stamp = file_stamp(path)?;
let data = std::fs::read(path).ok()?;
self.installed_files.lock().unwrap_or_else(|e| e.into_inner()).insert(path.to_path_buf(), stamp);
self.installed_files.lock().unwrap_or_else(PoisonError::into_inner).insert(path.to_path_buf(), stamp);
Some(data)
}
@@ -1314,7 +1322,7 @@ impl FontDb {
stack.push(p);
continue;
}
let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
let ext = p.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase);
if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) {
continue;
}
@@ -1351,7 +1359,7 @@ impl FontDb {
}
let n = found.len();
log::debug!("cataloged {n} system font faces");
*self.catalog.write().unwrap_or_else(|e| e.into_inner()) = found;
*self.catalog.write().unwrap_or_else(PoisonError::into_inner) = found;
n
}
@@ -1399,7 +1407,7 @@ impl FontDb {
let Ok(entries) = std::fs::read_dir(folder) else { return out };
let mut files: Vec<std::path::PathBuf> = Vec::new();
for path in entries.flatten().map(|e| e.path()) {
let ext = path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
let ext = path.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase);
if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) {
continue;
}
@@ -1421,7 +1429,7 @@ impl FontDb {
let name = path.file_name().map_or_else(|| path.display().to_string(), |n| n.to_string_lossy().into_owned());
// The listed length skips oversized files unread.
let meta = std::fs::metadata(path).ok();
let listed = meta.as_ref().map_or(0, |m| m.len());
let listed = meta.as_ref().map_or(0, std::fs::Metadata::len);
let faces = if listed > MAX_DOCUMENT_FONT_BYTES {
Err(too_large())
} else if total.saturating_add(listed) > MAX_DOCUMENT_FONTS_TOTAL {
@@ -1449,7 +1457,7 @@ impl FontDb {
if !own.is_empty() {
out.scope = NEXT_SCOPE.fetch_add(1, Ordering::Relaxed);
out.faces = own.len();
self.scopes.write().unwrap_or_else(|e| e.into_inner()).insert(out.scope, own.into());
self.scopes.write().unwrap_or_else(PoisonError::into_inner).insert(out.scope, own.into());
}
out
}
@@ -1466,7 +1474,7 @@ impl FontDb {
room: u64,
) -> Result<(Vec<Arc<FontFace>>, u64), String> {
use std::io::Read;
if let Some((len, m, faces)) = self.document_files.lock().unwrap_or_else(|e| e.into_inner()).get(path)
if let Some((len, m, faces)) = self.document_files.lock().unwrap_or_else(PoisonError::into_inner).get(path)
&& (*len, *m) == (listed, modified)
{
return Ok((faces.clone(), listed));
@@ -1494,14 +1502,14 @@ impl FontDb {
if faces.is_empty() {
return Err("not a font DesignCraft can read".into());
}
self.document_files.lock().unwrap_or_else(|e| e.into_inner()).insert(path.to_path_buf(), (listed, modified, faces.clone()));
self.document_files.lock().unwrap_or_else(PoisonError::into_inner).insert(path.to_path_buf(), (listed, modified, faces.clone()));
Ok((faces, len))
}
/// Close a document's font scope: its fonts are no longer found (in it or anywhere). Their
/// files stay read, for the document opening again.
pub fn close_scope(&self, scope: u32) {
self.scopes.write().unwrap_or_else(|e| e.into_inner()).remove(&scope);
self.scopes.write().unwrap_or_else(PoisonError::into_inner).remove(&scope);
}
/// The shared fonts' face for `family` + `style` (see [`ScopedFonts::face`]).
@@ -1542,7 +1550,7 @@ impl FontDb {
return false;
}
let generation = {
let sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
let sys = self.sys.lock().unwrap_or_else(PoisonError::into_inner);
if !sys.enabled || sys.misses.contains(&c) {
return false;
}
@@ -1558,12 +1566,12 @@ impl FontDb {
}
}
}
let mut paths: Vec<std::path::PathBuf> = self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter().map(|e| e.path.clone()).collect();
let mut paths: Vec<std::path::PathBuf> = self.catalog.read().unwrap_or_else(PoisonError::into_inner).iter().map(|e| e.path.clone()).collect();
paths.sort();
paths.dedup();
#[cfg(all(test, target_os = "linux"))]
{
let hook = self.fallback_snapshot_hook.lock().unwrap_or_else(|e| e.into_inner()).take();
let hook = self.fallback_snapshot_hook.lock().unwrap_or_else(PoisonError::into_inner).take();
if let Some(hook) = hook {
let _ = hook.reached.send(());
hook.resume.wait();
@@ -1580,7 +1588,7 @@ impl FontDb {
return true;
}
}
let mut sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
let mut sys = self.sys.lock().unwrap_or_else(PoisonError::into_inner);
if sys.generation == generation {
sys.misses.insert(c);
}
@@ -1619,12 +1627,12 @@ impl FontDb {
/// disappears.
pub fn outline(&self, face: &FontFace, gid: u32) -> Arc<BezPath> {
let key = (face.id, gid);
if let Some(p) = self.outlines.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
if let Some(p) = self.outlines.lock().unwrap_or_else(PoisonError::into_inner).get(&key) {
return p.clone();
}
let drawn = font_outline(face, gid);
let p = Arc::new(if gid == 0 && drawn.elements().is_empty() { self.missing_box(face).unwrap_or(drawn) } else { drawn });
let mut cache = self.outlines.lock().unwrap_or_else(|e| e.into_inner());
let mut cache = self.outlines.lock().unwrap_or_else(PoisonError::into_inner);
if cache.len() >= OUTLINE_CACHE_MAX {
cache.clear();
}
@@ -1986,7 +1994,7 @@ impl ScopedFonts<'_> {
return Some(base);
}
let key = (base.id, style.to_string());
if let Some(f) = self.db.instances.read().unwrap_or_else(|e| e.into_inner()).get(&key) {
if let Some(f) = self.db.instances.read().unwrap_or_else(PoisonError::into_inner).get(&key) {
return Some(f.clone());
}
let mut coords = base.coords.clone();
@@ -2004,7 +2012,7 @@ impl ScopedFonts<'_> {
}
}
let f = make_face(base.bytes.clone(), base.source.clone(), base.index, base.family.clone(), style.to_string(), coords)?;
Some(self.db.instances.write().unwrap_or_else(|e| e.into_inner()).entry(key).or_insert_with(|| Arc::new(f)).clone())
Some(self.db.instances.write().unwrap_or_else(PoisonError::into_inner).entry(key).or_insert_with(|| Arc::new(f)).clone())
}
fn find(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
@@ -2077,7 +2085,7 @@ impl ScopedFonts<'_> {
#[cfg(not(target_arch = "wasm32"))]
if !self.db.is_loaded(family) {
// Held while loading, so a parallel lookup waits for the font instead of passing it by.
let mut sys = self.db.sys.lock().unwrap_or_else(|e| e.into_inner());
let mut sys = self.db.sys.lock().unwrap_or_else(PoisonError::into_inner);
if sys.enabled && sys.chain_tried.insert(family) {
self.db.load_cataloged(family);
}
@@ -2200,6 +2208,71 @@ mod tests_fallback;
#[path = "tests_docfonts.rs"]
mod tests_docfonts;
#[cfg(all(test, not(target_arch = "wasm32")))]
mod native_gui_document_fonts_tests {
use super::*;
#[test]
fn native_gui_canonical_document_faces_load_without_system_or_added_fonts() {
if crate::CRAFT_FONTS.is_empty() {
assert!(std::env::var_os("CRAFT_FONTS_REQUIRED").is_none(), "required canonical fonts were not embedded");
return; // Optional-input builds retain their supported Latin-only database.
}
let db = FontDb::with_font_dirs(Vec::new());
db.set_system_fallback(false);
let scope = db.scoped(0);
let japanese = crate::japanese_document_fonts();
let previous: Vec<_> = BUNDLED
.iter()
.copied()
.chain(japanese.iter().map(|f| f.bytes))
.flat_map(enumerate_faces)
.map(|(_, family, style, _)| (family, style))
.collect();
let loaded: Vec<_> = db.read_faces().iter().map(|f| (f.family.clone(), f.style.clone())).collect();
assert!(loaded.len() >= previous.len(), "existing bundled/Japanese faces disappeared");
assert_eq!(&loaded[..previous.len()], previous.as_slice(), "preserve all Latin and Japanese face ordering");
for family in ["Source Sans 3", "Source Serif 4", "Inter", "JetBrains Mono"] {
let face = db.face(family, "Regular");
assert_eq!((face.family.as_str(), face.style.as_str(), &face.source), (family, "Regular", &FontSource::Bundled));
}
assert!(!japanese.is_empty(), "the required central manifest includes the existing Japanese faces");
assert!(japanese[0].family.contains("Mincho"));
assert_eq!(crate::japanese_ui_fonts(false)[0].family, "BIZ UDPGothic");
assert_eq!(crate::japanese_ui_fonts(true)[0].style, "Bold");
let latin = db.face(crate::DEFAULT_FAMILY, "Regular");
for language in [Some("ja"), None] {
assert!(db.fallback_for('語', latin.id(), language).unwrap().family.contains("Mincho"), "Japanese fallback changed: {language:?}");
}
let mut admitted = Vec::new();
for (family, text) in [("Noto Sans Hebrew", "שלום עולם"), ("Noto Sans Devanagari", "नमस्ते दुनिया")] {
let embedded: Vec<_> = crate::CRAFT_FONTS.iter().filter(|f| f.family == family && f.style == "Regular").collect();
assert_eq!(embedded.len(), 1, "exactly one canonical {family} Regular input");
assert!(scope.has_family(family), "production database did not register {family}");
assert!(scope.has_style(family, "Regular"), "{family} Regular");
assert!(!scope.has_style(family, "Unavailable Native GUI Style"));
assert!(db.families().iter().any(|f| f == family), "family catalog omits {family}");
assert!(scope.family_infos().iter().any(|f| f.family == family), "menu catalog omits {family}");
let face = scope.face(family, "Regular");
assert_eq!((face.family.as_str(), face.style.as_str(), &face.source), (family, "Regular", &FontSource::Bundled));
assert_eq!(face.data(), embedded[0].bytes, "neither OS discovery nor an injected face may satisfy this contract");
for c in text.chars().filter(|c| !c.is_whitespace()) {
assert_ne!(face.glyph_for(c), 0, "{family} lacks U+{:04X} ({c})", u32::from(c));
let fallback = db.fallback_for(c, latin.id(), None).unwrap();
assert_eq!((fallback.family.as_str(), fallback.style.as_str(), &fallback.source), (family, "Regular", &FontSource::Bundled));
assert_eq!(fallback.data(), embedded[0].bytes, "document fallback must use the canonical {family} face for {c}");
assert_ne!(fallback.glyph_for(c), 0);
}
let replacement = scope.face(family, "Unavailable Native GUI Style");
assert_eq!((replacement.family.as_str(), replacement.style.as_str()), (family, "Regular"), "nearest-style policy stays truthful");
admitted.push((family.to_string(), "Regular".to_string()));
}
assert_eq!(&loaded[previous.len()..], admitted.as_slice(), "only the two deliberate Regular tuples follow the existing document faces");
assert!(!scope.has_family("Unavailable Native GUI Family"));
assert_eq!(scope.face("Unavailable Native GUI Family", "Regular").family, FALLBACK_FAMILY);
}
}
#[cfg(test)]
#[path = "tests_vertical.rs"]
mod tests_vertical;
+18 -1
View File
@@ -281,9 +281,12 @@ impl PathData {
for el in bp.elements() {
match *el {
PathEl::MoveTo(p) => {
if let Some(sp) = cur.take()
if let Some(mut sp) = cur.take()
&& !sp.anchors.is_empty()
{
for a in &mut sp.anchors {
*a = Anchor::with_handles(a.p, a.h_in, a.h_out);
}
subpaths.push(sp);
}
cur = Some(SubPath { anchors: vec![Anchor::corner(p)], closed: false });
@@ -418,6 +421,20 @@ mod tests {
PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0), Point::new(0.0, 10.0)], true))
}
#[test]
fn from_bezpath_detects_smooth_on_every_open_subpath() {
let mut bp = BezPath::new();
for _ in 0..2 {
bp.move_to((0.0, 0.0));
bp.curve_to((0.0, 10.0), (10.0, 10.0), (10.0, 0.0));
bp.curve_to((10.0, -10.0), (20.0, -10.0), (20.0, 0.0));
}
let pd = PathData::from_bezpath(&bp);
assert_eq!(pd.subpaths.len(), 2);
assert_eq!(pd.subpaths[1].anchors[1].kind, AnchorKind::Smooth);
assert_eq!(pd.subpaths[0].anchors[1].kind, pd.subpaths[1].anchors[1].kind);
}
#[test]
fn anchors_serialize_compactly_and_read_v1_maps() {
let corner = Anchor::corner(Point::new(1.0, 2.0));
+12 -7
View File
@@ -5,12 +5,9 @@ use std::collections::HashMap;
use crate::Point;
/// Closed outlines around the `true` cells of a `w`×`h` mask (row-major), simplified to within
/// `tol` pixels; outlines enclosing less than `min_area` pixels are dropped. Holes come out
/// with the opposite winding.
pub fn trace(mask: &[bool], w: usize, h: usize, tol: f64, min_area: f64) -> Vec<Vec<Point>> {
/// Pixel-border edges of the mask with the inside on the right (clockwise, y down), by start point.
fn border_edges(mask: &[bool], w: usize, h: usize) -> HashMap<(isize, isize), Vec<(isize, isize)>> {
let inside = |x: isize, y: isize| x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h && mask[y as usize * w + x as usize];
// Pixel-border edges with the inside on the right (clockwise, y down).
let mut next: HashMap<(isize, isize), Vec<(isize, isize)>> = HashMap::new();
let mut add = |a: (isize, isize), b: (isize, isize)| next.entry(a).or_default().push(b);
for y in 0..h as isize {
@@ -32,16 +29,24 @@ pub fn trace(mask: &[bool], w: usize, h: usize, tol: f64, min_area: f64) -> Vec<
}
}
}
next
}
/// Closed outlines around the `true` cells of a `w`×`h` mask (row-major), simplified to within
/// `tol` pixels; outlines enclosing less than `min_area` pixels are dropped. Holes come out
/// with the opposite winding.
pub fn trace(mask: &[bool], w: usize, h: usize, tol: f64, min_area: f64) -> Vec<Vec<Point>> {
let mut next = border_edges(mask, w, h);
let mut out = Vec::new();
let mut starts: Vec<(isize, isize)> = next.keys().copied().collect();
starts.sort();
for s in starts {
while let Some(first) = next.get_mut(&s).and_then(|v| v.pop()) {
while let Some(first) = next.get_mut(&s).and_then(Vec::pop) {
let mut ring = vec![s];
let mut cur = first;
while cur != s {
ring.push(cur);
match next.get_mut(&cur).and_then(|v| v.pop()) {
match next.get_mut(&cur).and_then(Vec::pop) {
Some(n) => cur = n,
None => break,
}
+113 -78
View File
@@ -177,7 +177,7 @@ impl<'a> Ex<'a> {
}
fn assign_swatch_ids(&mut self) {
let mut used: HashSet<String> = ["Swatch/None", "Color/Paper", "Color/Black", "Color/Registration"].iter().map(|s| s.to_string()).collect();
let mut used: HashSet<String> = ["Swatch/None", "Color/Paper", "Color/Black", "Color/Registration"].iter().map(ToString::to_string).collect();
self.swatch_ids.insert(swatch::NONE.into(), "Swatch/None".into());
self.swatch_ids.insert(swatch::PAPER.into(), "Color/Paper".into());
self.swatch_ids.insert(swatch::BLACK.into(), "Color/Black".into());
@@ -309,28 +309,7 @@ impl<'a> Ex<'a> {
);
}
for t in &d.styles.mojikumi_tables {
let mut e = El::new("MojikumiTable").attr("Self", format!("MojikumiTable/{}", escape_id(&t.name))).attr("Name", &t.name);
if !t.based_on.is_empty() {
e.set("BasedOnMojikumiSet", &t.based_on);
}
if !t.overrides.is_empty() {
let mut list = El::new("OverrideMojikumiAkiList");
for r in &t.overrides {
list.push(
El::new("OverrideMojikumiAkiType")
.attr("TargetMojikumiClass", r.target_class)
.attr("SideMojikumiClass", r.side_class)
.attr("SideIsAfterTarget", bool_s(r.after))
.attr("Minimum", num(r.minimum))
.attr("Desired", num(r.desired))
.attr("Maximum", num(r.maximum))
.attr("CompressionPriority", r.priority)
.attr("AkiDoesNotFloat", bool_s(r.does_not_float)),
);
}
e.push(El::new("Properties").child(list));
}
root.push(e);
root.push(mojikumi_table_el(t));
}
root.push(El::new("idPkg:Graphic").attr("src", "Resources/Graphic.xml"));
root.push(El::new("idPkg:Fonts").attr("src", "Resources/Fonts.xml"));
@@ -364,28 +343,7 @@ impl<'a> Ex<'a> {
}
// IDML lists layers back to front.
for l in d.layers.iter().rev() {
let color = match names::layer_color_out(l.color) {
Some(n) => p("LayerColor", "enumeration", n),
None => {
let mut e = El::new("LayerColor").attr("type", "list");
for c in l.color {
e.push(p("ListItem", "double", num(c as f64)));
}
e
}
};
let el = El::new("Layer")
.attr("Self", uid(l.id.0))
.attr("Name", &l.name)
.attr("Visible", bool_s(l.visible))
.attr("Locked", bool_s(l.locked))
.attr("IgnoreWrap", bool_s(l.suppress_wrap_when_hidden))
.attr("ShowGuides", bool_s(l.show_guides))
.attr("LockGuides", "false")
.attr("UI", "true")
.attr("Expendable", "true")
.attr("Printable", bool_s(l.printable));
root.push(with_props(el, vec![color]));
root.push(layer_el(l));
}
for (name, _) in masters {
root.push(El::new("idPkg:MasterSpread").attr("src", name));
@@ -1570,6 +1528,7 @@ impl<'a> Ex<'a> {
}
n!(keep_with_next, "KeepWithNext");
b!(keep_lines_together, "KeepLinesTogether");
b!(ignore_optical_margin, "IgnoreEdgeAlignment");
b!(keep_all_lines, "KeepAllLinesTogether");
n!(keep_first, "KeepFirstLines");
n!(keep_last, "KeepLastLines");
@@ -1828,33 +1787,7 @@ impl<'a> Ex<'a> {
.attr("ItemTransform", format!("1 0 0 1 {} {}", num(pg.x - ox), num(-oy)))
.child(margin_el(pg));
let mut pe = pe;
// Ruler guides, measured from the ruler origin written in the preferences: the
// spread's top left, the page's, or the spine (the import reads them the same way).
let left = sp.pages.iter().map(|p| p.x).fold(f64::INFINITY, f64::min);
let left = if left.is_finite() { left } else { 0.0 };
let spine = sp.pages.iter().map(|p| p.x - left).filter(|x| *x > 1e-6).fold(f64::INFINITY, f64::min);
let spine = if spine.is_finite() && facing { spine } else { 0.0 };
let origin_x = match d.settings.ruler_origin {
designcraft_doc::RulerOrigin::Spread => left,
designcraft_doc::RulerOrigin::Page => pg.x,
designcraft_doc::RulerOrigin::Spine => left + spine,
};
for g in &pg.guides {
let vertical = g.orientation == designcraft_doc::Orientation::Vertical;
let mut ge = El::new("Guide")
.attr("Self", format!("{}g{}", uid(pg.id.0), pe.elements().count()))
.attr("Orientation", if vertical { "Vertical" } else { "Horizontal" })
.attr("Location", num(if vertical { g.position - origin_x } else { g.position }))
.attr("FitToPage", bool_s(!g.spread))
.attr("ViewThreshold", "5")
.attr("Locked", bool_s(g.locked))
.attr("PageIndex", (first_page + i).to_string())
.attr("GuideType", if g.liquid { "Liquid" } else { "Ruler" });
if let Some(l) = g.layer {
ge.set("ItemLayer", uid(l.0));
}
pe.push(ge);
}
push_page_guides(d, sp, pg, &mut pe, first_page + i);
el.push(pe);
}
let origin = Affine::translate((-ox, -oy));
@@ -2012,12 +1945,21 @@ impl<'a> Ex<'a> {
}
if let Content::Text(t) = &it.content {
el.push(text_frame_pref(&t.options, &self.sw(&t.options.column_rule_color)));
if let Some((start, inc)) = t.options.baseline_grid {
if let Some(g) = t.options.baseline_grid {
el.push(
El::new("BaselineFrameGridOption")
.attr("UseCustomBaselineFrameGrid", "true")
.attr("StartingOffsetForBaselineFrameGrid", num(start))
.attr("BaselineFrameGridIncrement", num(inc)),
.attr("StartingOffsetForBaselineFrameGrid", num(g.start))
.attr(
"BaselineFrameGridRelativeOption",
match g.relative_to {
designcraft_doc::FrameGridRelative::TopOfPage => "TopOfPageOfBaselineFrameGridRelativeOption",
designcraft_doc::FrameGridRelative::TopMargin => "TopOfMarginOfBaselineFrameGridRelativeOption",
designcraft_doc::FrameGridRelative::TopOfFrame => "TopOfFrameOfBaselineFrameGridRelativeOption",
designcraft_doc::FrameGridRelative::TopInset => "TopOfInsetOfBaselineFrameGridRelativeOption",
},
)
.attr("BaselineFrameGridIncrement", num(g.increment)),
);
}
}
@@ -2285,8 +2227,11 @@ impl<'a> Ex<'a> {
.attr("StoryTitle", "$ID/");
el.push(
El::new("StoryPreference")
.attr("OpticalMarginAlignment", "false")
.attr("OpticalMarginSize", "12")
.attr("OpticalMarginAlignment", bool_s(s.optical_margin.on))
.attr(
"OpticalMarginSize",
num(designcraft_doc::OpticalMargin::clamp_size(s.optical_margin.size).unwrap_or(designcraft_doc::OpticalMargin::DEFAULT_SIZE)),
)
.attr("FrameType", "TextFrameType")
.attr("StoryOrientation", if s.vertical { "Vertical" } else { "Horizontal" })
.attr(
@@ -2414,6 +2359,7 @@ impl<'a> Ex<'a> {
object_point,
ref_point,
keep_within_column,
spine_relative,
} => El::new("AnchoredObjectSetting")
.attr("AnchoredPosition", "Anchored")
.attr("AnchorPoint", names::anchor_out(*object_point))
@@ -2424,7 +2370,8 @@ impl<'a> Ex<'a> {
// IDML measures a left-aligned object's X offset leftwards.
.attr("AnchorXoffset", num(if *ref_point % 3 == 0 { 0.0 - *x_offset } else { *x_offset }))
.attr("AnchorYoffset", num(*y_offset))
.attr("PinPosition", bool_s(*keep_within_column)),
.attr("PinPosition", bool_s(*keep_within_column))
.attr("SpineRelative", bool_s(*spine_relative)),
};
e.push(setting);
out.push(Node::El(e));
@@ -3195,6 +3142,90 @@ fn metadata_xmp(d: &Document) -> String {
)
}
/// The page's ruler guides, measured from the ruler origin written in the preferences.
fn push_page_guides(d: &Document, sp: &Spread, pg: &designcraft_doc::Page, pe: &mut El, page_index: usize) {
let facing = d.settings.facing_pages;
// Ruler guides, measured from the ruler origin written in the preferences: the
// spread's top left, the page's, or the spine (the import reads them the same way).
let left = sp.pages.iter().map(|p| p.x).fold(f64::INFINITY, f64::min);
let left = if left.is_finite() { left } else { 0.0 };
let spine = sp.pages.iter().map(|p| p.x - left).filter(|x| *x > 1e-6).fold(f64::INFINITY, f64::min);
let spine = if spine.is_finite() && facing { spine } else { 0.0 };
let origin_x = match d.settings.ruler_origin {
designcraft_doc::RulerOrigin::Spread => left,
designcraft_doc::RulerOrigin::Page => pg.x,
designcraft_doc::RulerOrigin::Spine => left + spine,
};
for g in &pg.guides {
let vertical = g.orientation == designcraft_doc::Orientation::Vertical;
let mut ge = El::new("Guide")
.attr("Self", format!("{}g{}", uid(pg.id.0), pe.elements().count()))
.attr("Orientation", if vertical { "Vertical" } else { "Horizontal" })
.attr("Location", num(if vertical { g.position - origin_x } else { g.position }))
.attr("FitToPage", bool_s(!g.spread))
.attr("ViewThreshold", "5")
.attr("Locked", bool_s(g.locked))
.attr("PageIndex", page_index.to_string())
.attr("GuideType", if g.liquid { "Liquid" } else { "Ruler" });
if let Some(l) = g.layer {
ge.set("ItemLayer", uid(l.0));
}
pe.push(ge);
}
}
/// A `MojikumiTable` element with its spacing overrides.
fn mojikumi_table_el(t: &designcraft_doc::cjk::MojikumiTable) -> El {
let mut e = El::new("MojikumiTable").attr("Self", format!("MojikumiTable/{}", escape_id(&t.name))).attr("Name", &t.name);
if !t.based_on.is_empty() {
e.set("BasedOnMojikumiSet", &t.based_on);
}
if !t.overrides.is_empty() {
let mut list = El::new("OverrideMojikumiAkiList");
for r in &t.overrides {
list.push(
El::new("OverrideMojikumiAkiType")
.attr("TargetMojikumiClass", r.target_class)
.attr("SideMojikumiClass", r.side_class)
.attr("SideIsAfterTarget", bool_s(r.after))
.attr("Minimum", num(r.minimum))
.attr("Desired", num(r.desired))
.attr("Maximum", num(r.maximum))
.attr("CompressionPriority", r.priority)
.attr("AkiDoesNotFloat", bool_s(r.does_not_float)),
);
}
e.push(El::new("Properties").child(list));
}
e
}
/// A `Layer` element with its colour property.
fn layer_el(l: &designcraft_doc::Layer) -> El {
let color = match names::layer_color_out(l.color) {
Some(n) => p("LayerColor", "enumeration", n),
None => {
let mut e = El::new("LayerColor").attr("type", "list");
for c in l.color {
e.push(p("ListItem", "double", num(c as f64)));
}
e
}
};
let el = El::new("Layer")
.attr("Self", uid(l.id.0))
.attr("Name", &l.name)
.attr("Visible", bool_s(l.visible))
.attr("Locked", bool_s(l.locked))
.attr("IgnoreWrap", bool_s(l.suppress_wrap_when_hidden))
.attr("ShowGuides", bool_s(l.show_guides))
.attr("LockGuides", "false")
.attr("UI", "true")
.attr("Expendable", "true")
.attr("Printable", bool_s(l.printable));
with_props(el, vec![color])
}
/// A text variable definition with its type's settings element.
fn text_variable_el(v: &designcraft_doc::vars::TextVariable) -> El {
use designcraft_doc::vars::{Use, VarKind};
@@ -3336,6 +3367,10 @@ impl Ex<'_> {
if let Some(v) = a.kenten_overprint_stroke {
el.set("KentenOverprintStroke", crate::cjk::overprint_out(v));
}
self.shatai_chars(el, a);
}
fn shatai_chars(&self, el: &mut El, a: &CharAttrs) {
if let Some(v) = a.shatai_magnification {
el.set("ShataiMagnification", num(v));
}
+55 -24
View File
@@ -1641,28 +1641,7 @@ impl<'r> Importer<'r> {
a.strikethrough_tint = tint;
}
}
// OpenType features (only when the element sets any).
let mut otf_list: Vec<String> = Vec::new();
let mut any = false;
for (tag, _, attr) in designcraft_doc::otf::TOGGLES {
if let Some(on) = e.boolean(attr) {
designcraft_doc::otf::set(&mut otf_list, tag, on);
any = true;
}
}
if let Some(fig) = e.prop("OTFFigureStyle")
&& let Some((id, ..)) = designcraft_doc::otf::FIGURES.iter().find(|f| f.3 == fig.trim())
{
designcraft_doc::otf::set_figures(&mut otf_list, id);
any = true;
}
if let Some(m) = e.num("OTFStylisticSets") {
designcraft_doc::otf::set_stylistic_sets(&mut otf_list, m.max(0.0) as u32);
any = true;
}
if any {
a.otf_features = Some(otf_list);
}
read_otf_features(e, &mut a);
a.glyph_form = e.prop("GlyphForm");
a.no_break = e.boolean("NoBreak");
a.tate_chu_yoko = e.boolean("Tatechuyoko");
@@ -1841,6 +1820,7 @@ impl<'r> Importer<'r> {
a.single_word_justify = e.prop("SingleWordJustification").and_then(|v| names::align_in(v.trim()));
a.keep_with_next = u("KeepWithNext");
a.keep_lines_together = e.boolean("KeepLinesTogether");
a.ignore_optical_margin = e.boolean("IgnoreEdgeAlignment");
a.keep_all_lines = e.boolean("KeepAllLinesTogether");
a.keep_first = u("KeepFirstLines");
a.keep_last = u("KeepLastLines");
@@ -2116,6 +2096,14 @@ impl<'r> Importer<'r> {
let format = self.xref_format_names.get(&fmt).cloned().unwrap_or_else(|| "Full Paragraph & Page Number".into());
xref_list.push(Arc::new(designcraft_doc::CrossRef { target: 0, format }));
}
let pref = e.find("StoryPreference");
let optical_margin = designcraft_doc::OpticalMargin {
on: pref.and_then(|p| p.boolean("OpticalMarginAlignment")).unwrap_or(false),
size: pref
.and_then(|p| p.num("OpticalMarginSize"))
.and_then(designcraft_doc::OpticalMargin::clamp_size)
.unwrap_or(designcraft_doc::OpticalMargin::DEFAULT_SIZE),
};
let mut st = Story {
id,
text,
@@ -2128,6 +2116,7 @@ impl<'r> Importer<'r> {
designcraft_doc::TextDirection::LeftToRight
},
vertical: e.find("StoryPreference").and_then(|p| p.get("StoryOrientation")) == Some("Vertical"),
optical_margin,
rev: 0,
tables,
notes,
@@ -2449,6 +2438,7 @@ impl<'r> Importer<'r> {
object_point: names::anchor_in(s("AnchorPoint")),
ref_point: row * 3 + col,
keep_within_column: s("PinPosition") == "true",
spine_relative: s("SpineRelative") == "true",
}
}
_ => designcraft_doc::AnchorPosition::Inline { y_offset: get("AnchorYoffset") },
@@ -2994,8 +2984,11 @@ impl<'r> Importer<'r> {
if let Some(g) = self.with_style(e, "BaselineFrameGridOption")
&& g.get("UseCustomBaselineFrameGrid") == Some("true")
{
options.baseline_grid =
Some((g.num("StartingOffsetForBaselineFrameGrid").unwrap_or(0.0), g.num("BaselineFrameGridIncrement").unwrap_or(12.0)));
options.baseline_grid = Some(designcraft_doc::FrameBaselineGrid {
start: g.num("StartingOffsetForBaselineFrameGrid").unwrap_or(0.0),
increment: g.num("BaselineFrameGridIncrement").unwrap_or(12.0),
relative_to: g.get("BaselineFrameGridRelativeOption").map(frame_grid_relative).unwrap_or_default(),
});
}
it.content = Content::Text(TextFrame { story, options });
self.ctx.threads.push((
@@ -3536,6 +3529,19 @@ fn fit_columns(mut columns: Columns, margins: &Margins, page_width: f64) -> Colu
columns
}
/// A `BaselineFrameGridRelativeOption` value (`TopOfInsetOfBaselineFrameGridRelativeOption`, or a
/// short form such as `TopOfInset`); unknown values measure from the top of the page.
fn frame_grid_relative(v: &str) -> designcraft_doc::FrameGridRelative {
use designcraft_doc::FrameGridRelative as R;
let v = v.strip_suffix("OfBaselineFrameGridRelativeOption").unwrap_or(v);
match v {
"TopOfMargin" | "TopMargin" => R::TopMargin,
"TopOfFrame" => R::TopOfFrame,
"TopOfInset" | "TopInset" => R::TopInset,
_ => R::TopOfPage,
}
}
/// Text Frame Options from `TextFramePreference` elements: each attribute from the first that sets it.
fn text_frame_options(prefs: &[&El]) -> TextFrameOptions {
let e = Prefs(prefs);
@@ -3888,6 +3894,31 @@ impl Importer<'_> {
}
}
/// OpenType features (only when the element sets any).
fn read_otf_features(e: &El, a: &mut CharAttrs) {
let mut otf_list: Vec<String> = Vec::new();
let mut any = false;
for (tag, _, attr) in designcraft_doc::otf::TOGGLES {
if let Some(on) = e.boolean(attr) {
designcraft_doc::otf::set(&mut otf_list, tag, on);
any = true;
}
}
if let Some(fig) = e.prop("OTFFigureStyle")
&& let Some((id, ..)) = designcraft_doc::otf::FIGURES.iter().find(|f| f.3 == fig.trim())
{
designcraft_doc::otf::set_figures(&mut otf_list, id);
any = true;
}
if let Some(m) = e.num("OTFStylisticSets") {
designcraft_doc::otf::set_stylistic_sets(&mut otf_list, m.max(0.0) as u32);
any = true;
}
if any {
a.otf_features = Some(otf_list);
}
}
#[cfg(test)]
mod numeric_property_tests {
use super::*;
+36 -31
View File
@@ -98,11 +98,45 @@ pub(crate) fn read(e: &El, a: &mut CharAttrs, mut swatch: impl FnMut(&str) -> St
a.ruby_overprint_stroke = overprint("RubyOverprintStroke");
}
/// Ruby parent-text options (OpenType Pro, auto tate-chu-yoko, overhang, spacing, scaling).
fn write_parent_options(el: &mut El, a: &CharAttrs) {
let b = |v: bool| if v { "true" } else { "false" };
if let Some(v) = a.ruby_open_type_pro {
el.set("RubyOpenTypePro", b(v));
}
if let Some(v) = a.ruby_auto_tcy_digits {
el.set("RubyAutoTcyDigits", v);
}
if let Some(v) = a.ruby_auto_tcy_include_roman {
el.set("RubyAutoTcyIncludeRoman", b(v));
}
if let Some(v) = a.ruby_auto_tcy_auto_scale {
el.set("RubyAutoTcyAutoScale", b(v));
}
if let Some(v) = a.ruby_overhang {
el.set("RubyOverhang", b(v));
}
if let Some(v) = a.ruby_overhang_amount.and_then(|v| name(OVERHANGS, v)) {
el.set("RubyParentOverhangAmount", v);
}
if let Some(v) = a.ruby_parent_spacing.and_then(|v| name(SPACINGS, v)) {
el.set("RubyParentSpacing", v);
}
if let Some(v) = a.ruby_auto_align {
el.set("RubyAutoAlign", b(v));
}
if let Some(v) = a.ruby_auto_scaling {
el.set("RubyAutoScaling", b(v));
}
if let Some(v) = a.ruby_scaling_min {
el.set("RubyParentScalingPercent", num(v * 100.0));
}
}
/// Write the ruby settings set in `a`: attributes on `el`, fonts and colours as `props`. `sw`
/// maps a swatch name to its IDML reference. Returns the ruby font (family, style) to list in
/// the fonts part.
pub(crate) fn write(el: &mut El, props: &mut Vec<El>, a: &CharAttrs, sw: impl Fn(&str) -> String) -> Option<(String, String)> {
let b = |v: bool| if v { "true" } else { "false" };
let prop = |k: &str, ty: &str, v: String| El::new(k).attr("type", ty).text(v);
if let Some(v) = a.ruby_type.and_then(|v| name(TYPES, v)) {
el.set("RubyType", v);
@@ -134,36 +168,7 @@ pub(crate) fn write(el: &mut El, props: &mut Vec<El>, a: &CharAttrs, sw: impl Fn
if let Some(v) = a.ruby_y_scale {
el.set("RubyYScale", num(v * 100.0));
}
if let Some(v) = a.ruby_open_type_pro {
el.set("RubyOpenTypePro", b(v));
}
if let Some(v) = a.ruby_auto_tcy_digits {
el.set("RubyAutoTcyDigits", v);
}
if let Some(v) = a.ruby_auto_tcy_include_roman {
el.set("RubyAutoTcyIncludeRoman", b(v));
}
if let Some(v) = a.ruby_auto_tcy_auto_scale {
el.set("RubyAutoTcyAutoScale", b(v));
}
if let Some(v) = a.ruby_overhang {
el.set("RubyOverhang", b(v));
}
if let Some(v) = a.ruby_overhang_amount.and_then(|v| name(OVERHANGS, v)) {
el.set("RubyParentOverhangAmount", v);
}
if let Some(v) = a.ruby_parent_spacing.and_then(|v| name(SPACINGS, v)) {
el.set("RubyParentSpacing", v);
}
if let Some(v) = a.ruby_auto_align {
el.set("RubyAutoAlign", b(v));
}
if let Some(v) = a.ruby_auto_scaling {
el.set("RubyAutoScaling", b(v));
}
if let Some(v) = a.ruby_scaling_min {
el.set("RubyParentScalingPercent", num(v * 100.0));
}
write_parent_options(el, a);
for (k, c) in [("RubyFill", &a.ruby_fill), ("RubyStroke", &a.ruby_stroke)] {
if let Some(c) = c {
props.push(if c.is_empty() { prop(k, "string", TEXT_COLOR.into()) } else { prop(k, "object", sw(c.as_str())) });
+93
View File
@@ -10,6 +10,8 @@ use super::*;
mod assets;
mod clipping;
mod decorations;
mod facing_ltr;
mod frame_grid;
mod index_sort;
mod liquid_guides;
mod object_styles;
@@ -298,6 +300,7 @@ fn indesign_text_defaults() -> Vec<AttrRow> {
"HyphenateLadderLimit": "3" => 3, "0" => 0, |r| r.0.hyph_limit;
"HyphenationZone": "36" => 36.0, "18" => 18.0, |r| r.0.hyph_zone;
"HyphenateCapitalizedWords": "true" => true, "false" => false, |r| r.0.hyph_capitalized;
"IgnoreEdgeAlignment": "false" => false, "true" => true, |r| r.0.ignore_optical_margin;
"HyphenateLastWord": "true" => true, "false" => false, |r| r.0.hyph_last_word;
"HyphenateAcrossColumns": "true" => true, "false" => false, |r| r.0.hyph_across_column;
"HyphenWeight": "5" => 0.5, "9" => 0.9, |r| r.0.hyph_weight;
@@ -482,6 +485,57 @@ fn text_color_rules_round_trip() {
assert_eq!(p.para.kashidas, Some(false));
}
fn optical_story(pref: &str) -> String {
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0">
<Story Self="s1">
<StoryPreference {pref} StoryOrientation="Horizontal" StoryDirection="LeftToRightDirection"/>
<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/Body">
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>“Hung” here.</Content><Br/></CharacterStyleRange>
</ParagraphStyleRange>
<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/Body" IgnoreEdgeAlignment="true">
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>“Flush” here.</Content></CharacterStyleRange>
</ParagraphStyleRange>
</Story>
</idPkg:Story>"#
)
}
/// Story ▸ Optical Margin Alignment and its size, and Ignore Optical Margin on paragraphs and
/// paragraph styles, survive import and export.
#[test]
fn story_optical_margin_round_trips() {
let story = |d: &Document| d.stories.values().find(|s| s.text.starts_with("“Hung”")).cloned().unwrap();
let d = import_idml_with(&fixture_with_story(&optical_story(r#"OpticalMarginAlignment="true" OpticalMarginSize="18""#)), &|_| None).unwrap();
let s = story(&d);
assert_eq!(s.optical_margin, designcraft_doc::OpticalMargin { on: true, size: 18.0 });
assert_eq!((s.paras[0].para.ignore_optical_margin, s.paras[1].para.ignore_optical_margin), (None, Some(true)));
let bytes = export_idml(&d);
let text = zip_text(&bytes, "Stories/");
assert!(text.contains(r#"OpticalMarginAlignment="true" OpticalMarginSize="18""#), "{text}");
assert!(text.contains(r#"IgnoreEdgeAlignment="true""#), "{text}");
let back = story(&import_idml(&bytes).unwrap());
assert_eq!(back.optical_margin, s.optical_margin);
assert_eq!(back.paras[1].para.ignore_optical_margin, Some(true));
// Sizes outside InDesign's range are clamped; malformed ones and an absent preference are the default.
for (pref, want) in [
(r#"OpticalMarginAlignment="true" OpticalMarginSize="1e9""#, (true, 1296.0)),
(r#"OpticalMarginAlignment="true" OpticalMarginSize="0""#, (true, 0.1)),
(r#"OpticalMarginAlignment="true" OpticalMarginSize="NaN""#, (true, 12.0)),
(r#"OpticalMarginAlignment="false""#, (false, 12.0)),
] {
let d = import_idml_with(&fixture_with_story(&optical_story(pref)), &|_| None).unwrap();
let om = story(&d).optical_margin;
assert_eq!((om.on, om.size), want, "{pref}");
}
// On a paragraph style.
let d = import_idml_with(&fixture_with_styles(&keep_styles(r#"IgnoreEdgeAlignment="true""#)), &|_| None).unwrap();
assert!(d.styles.resolve_para_style("Body").0.ignore_optical_margin);
let styles = zip_text(&export_idml(&d), "Resources/Styles.xml");
assert!(styles.contains(r#"IgnoreEdgeAlignment="true""#), "{styles}");
}
/// `[Basic Paragraph]` is InDesign's `$ID/NormalParagraphStyle`, both ways.
#[test]
fn basic_paragraph_is_normal_paragraph_style() {
@@ -1354,6 +1408,7 @@ fn anchored_objects_take_unset_settings_from_object_styles() {
object_point,
ref_point: 6,
keep_within_column,
spine_relative: false,
};
let positions: Vec<_> = s.objects.iter().map(|o| o.position.clone()).collect();
assert_eq!(
@@ -1366,6 +1421,44 @@ fn anchored_objects_take_unset_settings_from_object_styles() {
);
}
/// Relative to Spine comes from the object style chain like the other anchored settings, an
/// item's own setting wins, and export writes it back.
#[test]
fn anchored_spine_relative_comes_from_object_styles_and_round_trips() {
use designcraft_doc::AnchorPosition;
let styles = STYLES.replace(
r#"<ObjectStyle Self="ObjectStyle/$ID/[None]" Name="$ID/[None]"/>"#,
r#"<ObjectStyle Self="ObjectStyle/$ID/[None]" Name="$ID/[None]"/>
<ObjectStyle Self="ObjectStyle/Side" Name="Side"><AnchoredObjectSetting SpineRelative="true"/></ObjectStyle>
<ObjectStyle Self="ObjectStyle/Note" Name="Note"><Properties><BasedOn type="object">ObjectStyle/Side</BasedOn></Properties></ObjectStyle>"#,
);
let rect = |style: &str, setting: &str| {
format!(
r#"<Rectangle Self="r{style}" AppliedObjectStyle="ObjectStyle/{style}" ItemTransform="1 0 0 1 0 0">
<Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>
<PathPointType Anchor="0 0"/><PathPointType Anchor="0 10"/><PathPointType Anchor="10 10"/><PathPointType Anchor="10 0"/>
</PathPointArray></GeometryPathType></PathGeometry></Properties>
<AnchoredObjectSetting AnchoredPosition="Anchored" {setting}/>
</Rectangle>"#
)
};
let story = format!(
r#"<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"><Story Self="s1">
<ParagraphStyleRange><CharacterStyleRange><Content>a</Content>{}{}{}</CharacterStyleRange></ParagraphStyleRange>
</Story></idPkg:Story>"#,
rect("Note", ""),
rect("Note", r#"SpineRelative="false""#),
rect("$ID/[None]", ""),
);
let spine = |d: &Document| -> Vec<bool> {
let s = d.stories.values().find(|s| !s.objects.is_empty()).unwrap();
s.objects.iter().map(|o| matches!(o.position, AnchorPosition::Custom { spine_relative: true, .. })).collect()
};
let d = import_idml(&fixture_with_table_styles(&styles, &story)).unwrap();
assert_eq!(spine(&d), [true, false, false]);
assert_eq!(spine(&import_idml(&export_idml(&d)).unwrap()), [true, false, false]);
}
/// IDML measures a left-aligned anchored object's X offset leftwards; the document model and
/// composition measure every X offset rightwards.
#[test]
+111
View File
@@ -0,0 +1,111 @@
use designcraft_doc::PageSide::{self, Left, Right};
use super::*;
const NS: &str = r#"xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0""#;
/// A running head: a text frame across `x0..x1` (IDML spread x), 20 pt below the page top.
fn head(id: &str, story: &str, x0: f64, x1: f64) -> String {
let pts: String = [(x0, -280.0), (x0, -260.0), (x1, -260.0), (x1, -280.0)]
.iter()
.map(|(x, y)| format!(r#"<PathPointType Anchor="{x} {y}" LeftDirection="{x} {y}" RightDirection="{x} {y}"/>"#))
.collect();
format!(
r#"<TextFrame Self="{id}" ParentStory="{story}" PreviousTextFrame="n" NextTextFrame="n" ItemLayer="L1" ItemTransform="1 0 0 1 0 0"><Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>{pts}</PathPointArray></GeometryPathType></PathGeometry></Properties></TextFrame>"#
)
}
fn story(id: &str, text: &str) -> String {
format!(
r#"<idPkg:Story {NS}><Story Self="{id}"><ParagraphStyleRange><CharacterStyleRange><Content>{text}</Content></CharacterStyleRange></ParagraphStyleRange></Story></idPkg:Story>"#
)
}
/// Bound left to right with facing pages: the first page of a spread lies left of the spine
/// (except page 1, alone on the right). Every page has margins `Left=36 Right=120` (inside 36,
/// outside 120), the parent's left page a ruler guide at 108 and a "Left head" running head, its
/// right page a guide at 300 and a "Right head". Three pages: 1 alone, then 2–3. Rulers measure
/// from each page.
fn left_to_right_fixture() -> Vec<u8> {
let margins = r#"<MarginPreference ColumnCount="1" ColumnGutter="12" Top="36" Bottom="36" Left="36" Right="120"/>"#;
let page = |id: &str, x: f64, applied: bool, guide: Option<f64>| {
let applied = if applied { r#" AppliedMaster="m1""# } else { "" };
let guide = guide.map_or(String::new(), |at| format!(r#"<Guide Self="{id}g" Orientation="Vertical" Location="{at}"/>"#));
format!(r#"<Page Self="{id}"{applied} GeometricBounds="0 0 600 400" ItemTransform="1 0 0 1 {x} -300">{margins}{guide}</Page>"#)
};
let master = format!(
r#"<idPkg:MasterSpread {NS}><MasterSpread Self="m1" Name="A-Parent" NamePrefix="A" BaseName="Parent" PageCount="2" ItemTransform="1 0 0 1 0 0">{}{}{}{}</MasterSpread></idPkg:MasterSpread>"#,
page("mp1", -400.0, false, Some(108.0)),
page("mp2", 0.0, false, Some(300.0)),
head("hl", "sl", -280.0, -36.0),
head("hr", "sr", 36.0, 280.0),
);
let spread = |id: &str, pages: &[(&str, f64)]| {
let ps: String = pages.iter().map(|(p, x)| page(p, *x, true, None)).collect();
format!(r#"<idPkg:Spread {NS}><Spread Self="{id}" PageCount="{}" ItemTransform="1 0 0 1 0 0">{ps}</Spread></idPkg:Spread>"#, pages.len())
};
let map = format!(
r#"<Document {NS} Self="d" StoryList="sl sr"><idPkg:Preferences src="Resources/Preferences.xml"/><Layer Self="L1" Name="Layer 1"/><idPkg:MasterSpread src="MasterSpreads/m1.xml"/><idPkg:Spread src="Spreads/sp0.xml"/><idPkg:Spread src="Spreads/sp1.xml"/><idPkg:Story src="Stories/sl.xml"/><idPkg:Story src="Stories/sr.xml"/></Document>"#
);
let prefs = format!(
r#"<idPkg:Preferences {NS}><DocumentPreference PageWidth="400" PageHeight="600" FacingPages="true" PageBinding="LeftToRight"/><ViewPreference RulerOrigin="PageOrigin"/></idPkg:Preferences>"#
);
zip_files(&[
("designmap.xml", &map),
("Resources/Preferences.xml", &prefs),
("MasterSpreads/m1.xml", &master),
("Spreads/sp0.xml", &spread("sp0", &[("p1", 0.0)])),
("Spreads/sp1.xml", &spread("sp1", &[("p2", -400.0), ("p3", 0.0)])),
("Stories/sl.xml", &story("sl", "Left head")),
("Stories/sr.xml", &story("sr", "Right head")),
])
}
/// The text of the running heads a document page shows from its parent page.
fn shown_heads(d: &Document, abs: usize) -> Vec<String> {
let (ppi, ppage) = d.parent_page_for(abs).unwrap();
let parent = &d.parents[ppi];
parent
.items
.iter()
.filter(|it| parent.page_at_x(it.bounds().center().x) == Some(ppage))
.filter_map(|it| d.story(it.text_frame()?.story).map(|s| s.text.clone()))
.collect()
}
/// Ruler guides of the parent page a document page shows, as x from that parent page's left edge.
fn parent_guides(d: &Document, abs: usize) -> Vec<f64> {
let (ppi, ppage) = d.parent_page_for(abs).unwrap();
let pp = &d.parents[ppi].pages[ppage];
pp.guides.iter().map(|g| g.position - pp.x).collect()
}
fn assert_right_pages_mirror_left_pages(d: &Document) {
assert!(d.settings.facing_pages && !d.settings.right_to_left_binding);
let sides: Vec<PageSide> = d.parents[0].pages.iter().map(|p| p.side).collect();
assert_eq!(sides, vec![Left, Right]);
// Right pages: inside (36) on the left; left pages: outside (120) on the left.
for (abs, side, live, guide, text) in [
(0, Right, (36.0, 280.0), 300.0, "Right head"),
(1, Left, (120.0, 364.0), 108.0, "Left head"),
(2, Right, (36.0, 280.0), 300.0, "Right head"),
] {
let n = abs + 1;
let page = d.page(abs).unwrap();
assert_eq!(page.side, side, "page {n}");
let m = page.margin_rect();
assert!((m.x0 - page.x - live.0).abs() < 1e-6 && (m.x1 - page.x - live.1).abs() < 1e-6, "page {n}: margins {m:?}");
assert_eq!(d.parents[0].pages[d.parent_page_for(abs).unwrap().1].side, side, "page {n} shows the parent's {side:?} page");
let guides = parent_guides(d, abs);
assert!(guides.len() == 1 && (guides[0] - guide).abs() < 1e-6, "page {n}: guides {guides:?}");
assert_eq!(shown_heads(d, abs), vec![text.to_string()], "page {n}");
}
}
#[test]
fn left_to_right_right_pages_take_mirrored_margins_and_the_right_parent_page() {
let d = import_idml(&left_to_right_fixture()).unwrap();
assert_right_pages_mirror_left_pages(&d);
let again = import_idml(&export_idml(&d)).unwrap();
assert_right_pages_mirror_left_pages(&again);
}
+98
View File
@@ -0,0 +1,98 @@
use super::*;
const NS: &str = r#"xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="14.0""#;
/// A page with one text frame at its top edge whose custom baseline grid starts at 6 pt with a
/// 14 pt increment; its four paragraphs align to the grid. The frame's top is the page's top, so
/// the grid lines are 6 + n·14 pt down from the frame top whatever the grid is measured from.
fn frame_grid_doc() -> Document {
frame_grid_doc_relative(None)
}
/// [`frame_grid_doc`] with `BaselineFrameGridRelativeOption` set to `relative` when given.
fn frame_grid_doc_relative(relative: Option<&str>) -> Document {
let relative = relative.map(|r| format!(r#" BaselineFrameGridRelativeOption="{r}""#)).unwrap_or_default();
let designmap = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Document {NS} Self="d" StoryList="s1" Name="grid.indd"><idPkg:Graphic src="Resources/Graphic.xml"/><idPkg:Styles src="Resources/Styles.xml"/><idPkg:Preferences src="Resources/Preferences.xml"/><Layer Self="L1" Name="Layer 1" Visible="true" Locked="false"/><idPkg:Spread src="Spreads/Spread_sp1.xml"/><idPkg:Story src="Stories/Story_s1.xml"/></Document>"#
);
let prefs = format!(r#"<idPkg:Preferences {NS}><DocumentPreference PageWidth="400" PageHeight="600" FacingPages="false"/></idPkg:Preferences>"#);
let pts: String = [(50.0, 0.0), (50.0, 300.0), (250.0, 300.0), (250.0, 0.0)]
.iter()
.map(|(x, y)| format!(r#"<PathPointType Anchor="{x} {y}" LeftDirection="{x} {y}" RightDirection="{x} {y}"/>"#))
.collect();
let spread = format!(
r#"<idPkg:Spread {NS}><Spread Self="sp1" PageCount="1" ItemTransform="1 0 0 1 0 0"><Page Self="p1" GeometricBounds="0 0 600 400" ItemTransform="1 0 0 1 0 -300"/>
<TextFrame Self="t1" ParentStory="s1" PreviousTextFrame="n" NextTextFrame="n" ItemLayer="L1" ItemTransform="1 0 0 1 0 -300"><Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>{pts}</PathPointArray></GeometryPathType></PathGeometry></Properties><TextFramePreference InsetSpacing="0"/><BaselineFrameGridOption UseCustomBaselineFrameGrid="true" StartingOffsetForBaselineFrameGrid="6" BaselineFrameGridIncrement="14"{relative}/></TextFrame>
</Spread></idPkg:Spread>"#
);
let story = format!(
r#"<idPkg:Story {NS}><Story Self="s1"><ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/$ID/[No paragraph style]" GridAlignment="AlignToBaseline"><CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>One</Content><Br/><Content>Two</Content><Br/><Content>Three</Content><Br/><Content>Four</Content></CharacterStyleRange></ParagraphStyleRange></Story></idPkg:Story>"#
);
let bytes = zip_files(&[
("designmap.xml", &designmap),
("Resources/Graphic.xml", GRAPHIC),
("Resources/Styles.xml", STYLES),
("Resources/Preferences.xml", &prefs),
("Spreads/Spread_sp1.xml", &spread),
("Stories/Story_s1.xml", &story),
]);
import_idml_with(&bytes, &|_| None).unwrap()
}
/// Each composed line's baseline below the frame top.
fn baselines(d: &Document) -> Vec<f64> {
let sid = *d.stories.keys().next().unwrap();
let top = d.spreads[0].items[0].text_area().y0;
let cs = designcraft_compose::compose_story(d, sid, &Default::default());
cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.baseline - top).collect()
}
#[test]
fn custom_frame_grid_sets_lines_from_its_start_by_its_increment() {
let d = frame_grid_doc();
let grid = d.spreads[0].items[0].text_frame().unwrap().options.baseline_grid;
assert_eq!(grid, Some(designcraft_doc::FrameBaselineGrid::new(6.0, 14.0)));
let b = baselines(&d);
assert_eq!(b.len(), 4, "{b:?}");
for y in &b {
let n = (y - 6.0) / 14.0;
assert!((n - n.round()).abs() < 1e-6 && n >= 0.0, "baseline {y} is on the 6 + n·14 grid: {b:?}");
}
// The first line's ascent is past the 6 pt line, so it sits on the next one.
assert!((b[0] - 20.0).abs() < 1e-6, "{b:?}");
}
#[test]
fn custom_frame_grid_round_trips() {
let d = frame_grid_doc();
let bytes = export_idml(&d);
let spread = zip_text(&bytes, "Spreads/");
assert!(spread.contains(r#"StartingOffsetForBaselineFrameGrid="6""#), "{spread}");
assert!(spread.contains(r#"BaselineFrameGridIncrement="14""#), "{spread}");
let back = import_idml(&bytes).unwrap();
let grid = |d: &Document| d.spreads[0].items[0].text_frame().unwrap().options.baseline_grid;
assert_eq!(grid(&back), grid(&d));
assert_eq!(baselines(&back), baselines(&d));
}
#[test]
fn custom_frame_grid_relative_to_round_trips() {
use designcraft_doc::FrameGridRelative as R;
let relative = |d: &Document| d.spreads[0].items[0].text_frame().unwrap().options.baseline_grid.map(|g| g.relative_to);
for (attr, want) in [
("TopOfPageOfBaselineFrameGridRelativeOption", R::TopOfPage),
("TopOfMarginOfBaselineFrameGridRelativeOption", R::TopMargin),
("TopOfFrameOfBaselineFrameGridRelativeOption", R::TopOfFrame),
("TopOfInsetOfBaselineFrameGridRelativeOption", R::TopInset),
("TopOfInset", R::TopInset),
("SomethingNew", R::TopOfPage),
] {
let d = frame_grid_doc_relative(Some(attr));
assert_eq!(relative(&d), Some(want), "{attr}");
let back = import_idml(&export_idml(&d)).unwrap();
assert_eq!(relative(&back), Some(want), "{attr} after export");
}
// A frame without the attribute measures from the top of the page.
assert_eq!(relative(&frame_grid_doc()), Some(R::TopOfPage));
}
+2
View File
@@ -14,6 +14,8 @@ designcraft-fonts = { workspace = true }
image = { workspace = true }
tiff = { workspace = true }
resvg = { workspace = true }
quick-xml = { workspace = true }
flate2 = "1"
log = { workspace = true }
craft-cms = { workspace = true }
zune-core = { version = "0.5", default-features = false }
+173 -6
View File
@@ -77,8 +77,23 @@ fn svg_options() -> &'static usvg::Options<'static> {
let font_resolver = usvg::FontResolver { select_font: select_font(), select_fallback: select_fallback() };
// Images inside an SVG come only from `data:` URLs: an `href` naming a file is not read
// (it is left out, as usvg leaves out an image it can't load).
let image_href_resolver =
usvg::ImageHrefResolver { resolve_data: usvg::ImageHrefResolver::default_data_resolver(), resolve_string: Box::new(|_, _| None) };
let default_data = usvg::ImageHrefResolver::default_data_resolver();
let image_href_resolver = usvg::ImageHrefResolver {
resolve_data: Box::new(move |mime, data, options| {
let raster = matches!(
image::guess_format(&data),
Ok(image::ImageFormat::Png | image::ImageFormat::Jpeg | image::ImageFormat::Gif | image::ImageFormat::WebP)
);
if mime == "image/svg+xml" || (mime == "text/plain" && !raster) {
let xml = svg_data(&data)?;
svg_preflight(&xml)?;
default_data(mime, Arc::new(xml.into_owned()), options)
} else {
default_data(mime, data, options)
}
}),
resolve_string: Box::new(|_, _| None),
};
usvg::Options { fontdb: Arc::new(db), font_family: "Source Sans 3".into(), font_resolver, image_href_resolver, ..Default::default() }
})
}
@@ -207,9 +222,69 @@ fn add_face(db: &mut Arc<usvg::fontdb::Database>, face: Arc<designcraft_fonts::F
ids.into_iter().find(|id| db.face(*id).is_some_and(|f| f.index == index))
}
/// The parsed SVG.
/// Limits apply before usvg's recursive XML/SVG parsing, including SVGZ output.
/// Ordinary SVG doctypes are accepted; internal DTD subsets are not, since entity
/// expansion could introduce markup that the streaming depth check never sees.
const MAX_SVG_BYTES: usize = 16 * 1024 * 1024;
const MAX_SVG_EVENTS: usize = 100_000;
const MAX_SVG_DEPTH: usize = 64;
fn svg_data(bytes: &[u8]) -> Option<std::borrow::Cow<'_, [u8]>> {
use std::io::Read;
if bytes.len() > MAX_SVG_BYTES {
return None;
}
if bytes.starts_with(&[0x1f, 0x8b]) {
let mut xml = Vec::new();
flate2::read::GzDecoder::new(bytes).take(MAX_SVG_BYTES as u64 + 1).read_to_end(&mut xml).ok()?;
(xml.len() <= MAX_SVG_BYTES).then_some(std::borrow::Cow::Owned(xml))
} else {
Some(std::borrow::Cow::Borrowed(bytes))
}
}
fn svg_preflight(bytes: &[u8]) -> Option<()> {
use quick_xml::events::Event;
let text = std::str::from_utf8(bytes).ok()?;
let mut reader = quick_xml::Reader::from_str(text);
let mut depth = 0usize;
for _ in 0..MAX_SVG_EVENTS {
match reader.read_event().ok()? {
Event::Start(_) => {
depth += 1;
if depth > MAX_SVG_DEPTH {
return None;
}
}
Event::Empty(_) if depth >= MAX_SVG_DEPTH => return None,
Event::End(_) => depth = depth.checked_sub(1)?,
Event::DocType(doctype) => {
// A '[' inside a quoted external identifier is not an internal subset.
let mut quote = None;
for &byte in doctype.as_ref() {
match (quote, byte) {
(Some(q), b) if b == q => quote = None,
(None, b'\'' | b'"') => quote = Some(byte),
(None, b'[') => return None,
_ => {}
}
}
}
Event::Eof => return (depth == 0).then_some(()),
_ => {}
}
}
None
}
/// Parse an SVG with at most 16 MiB encoded/decoded data, 100,000 XML events and
/// 64 nested elements. Reject internal DTD subsets before recursive conversion.
pub fn svg_tree(bytes: &[u8]) -> Option<usvg::Tree> {
usvg::Tree::from_data(bytes, svg_options()).ok()
let xml = svg_data(bytes)?;
svg_preflight(&xml)?;
usvg::Tree::from_data(&xml, svg_options()).ok()
}
/// Natural size of a placed graphic in points: rasters at 72 ppi (one pixel per point), PSDs
@@ -503,6 +578,98 @@ mod tests {
<text x="110" y="60" font-size="20">Hi</text>
</svg>"##;
fn nested_svg(groups: usize) -> String {
format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10">{}<rect width="10" height="10"/>{}</svg>"#,
"<g opacity=\"0.99\">".repeat(groups),
"</g>".repeat(groups)
)
}
fn gzip(bytes: &[u8]) -> Vec<u8> {
use std::io::Write;
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
encoder.write_all(bytes).unwrap();
encoder.finish().unwrap()
}
#[test]
fn svg_depth_is_checked_before_recursive_parsing() {
// Count the SVG root and leaf too; the boundary document still draws its path.
let accepted = nested_svg(MAX_SVG_DEPTH - 2);
let tree = svg_tree(accepted.as_bytes()).unwrap();
assert!(!tree.root().children().is_empty());
drop(tree);
for depth in [MAX_SVG_DEPTH - 1, 2000] {
let rejected = nested_svg(depth);
assert!(svg_tree(rejected.as_bytes()).is_none());
assert!(natural_size(rejected.as_bytes()).is_none());
assert!(render_svg(rejected.as_bytes(), 10).is_none());
}
}
#[test]
fn svg_depth_ignores_markup_in_comments_cdata_and_attributes() {
let markup = "<g>".repeat(2000);
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><!--{markup}--><desc><![CDATA[{markup}]]></desc><rect data-label="&lt;g>" width="10" height="10"/></svg>"#
);
assert!(svg_tree(svg.as_bytes()).is_some());
}
#[test]
fn svg_external_doctype_is_preserved_but_entity_markup_is_refused() {
let external =
format!(r#"<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">{}"#, nested_svg(2));
assert!(svg_tree(external.as_bytes()).is_some());
let internal = r#"<!DOCTYPE svg [<!ENTITY shape "<rect width='10' height='10'/>">]><svg xmlns="http://www.w3.org/2000/svg" width="10" height="10">&shape;</svg>"#;
assert!(svg_tree(internal.as_bytes()).is_none());
}
#[test]
fn svg_byte_and_event_limits_are_enforced() {
let mut bytes = nested_svg(0).into_bytes();
bytes.resize(MAX_SVG_BYTES, b' ');
assert!(svg_tree(&bytes).is_some());
bytes.push(b' ');
assert!(svg_tree(&bytes).is_none());
let document = |comments| {
format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10">{}<rect width="10" height="10"/></svg>"#,
"<!-- -->".repeat(comments)
)
};
assert!(svg_tree(document(MAX_SVG_EVENTS - 4).as_bytes()).is_some());
assert!(svg_tree(document(MAX_SVG_EVENTS - 3).as_bytes()).is_none());
}
#[test]
fn svgz_has_the_same_depth_and_decoded_size_limits() {
assert!(svg_tree(&gzip(nested_svg(2).as_bytes())).is_some());
assert!(svg_tree(&gzip(nested_svg(2000).as_bytes())).is_none());
let mut bytes = nested_svg(0).into_bytes();
bytes.resize(MAX_SVG_BYTES + 1, b' ');
let compressed = gzip(&bytes);
assert!(compressed.len() < MAX_SVG_BYTES);
assert!(svg_tree(&compressed).is_none());
assert!(svg_tree(b"\x1f\x8bnot gzip").is_none());
}
#[test]
fn nested_svg_data_images_cannot_bypass_the_depth_limit() {
let nested = nested_svg(2000);
let encoded = nested.bytes().map(|b| format!("%{b:02X}")).collect::<String>();
for mime in ["image/svg+xml", "text/plain"] {
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><image width="10" height="10" href="data:{mime},{encoded}"/></svg>"#
);
// As with other unreadable data images, the parent survives without the image.
assert!(svg_tree(svg.as_bytes()).unwrap().root().children().is_empty());
}
}
#[test]
fn svg_sniff_size_and_render() {
let b = SVG.as_bytes();
@@ -738,8 +905,8 @@ mod tests {
impl<const BITS: u16> tiff::encoder::colortype::ColorType for Palette<BITS> {
type Inner = u8;
const TIFF_VALUE: tiff::tags::PhotometricInterpretation = tiff::tags::PhotometricInterpretation::RGBPalette;
const BITS_PER_SAMPLE: &'static [u16] = &[BITS];
const SAMPLE_FORMAT: &'static [tiff::tags::SampleFormat] = &[tiff::tags::SampleFormat::Uint];
const BITS_PER_SAMPLE: &[u16] = &[BITS];
const SAMPLE_FORMAT: &[tiff::tags::SampleFormat] = &[tiff::tags::SampleFormat::Uint];
fn horizontal_predict(row: &[u8], result: &mut Vec<u8>) {
result.extend_from_slice(row);
}
+12 -7
View File
@@ -81,6 +81,16 @@ impl Mode {
/// The merged composite as straight RGBA8, `None` when the file is damaged or unsupported (32-bit,
/// Multichannel, ZIP-compressed composite).
/// Whether the header's depth, channel count and size are all within what the decoder supports.
fn header_in_range(mode: Mode, depth: u16, channels: usize, (w, h): (u32, u32), max_side: u32) -> bool {
let depth_ok = match mode {
Mode::Bitmap => depth == 1,
Mode::Indexed => depth == 8,
_ => depth == 8 || depth == 16,
};
(1..=56).contains(&channels) && channels >= mode.colour_channels() && (1..=max_side).contains(&w) && (1..=max_side).contains(&h) && depth_ok
}
pub(crate) fn decode(bytes: &[u8]) -> Option<image::RgbaImage> {
let mut r = Reader { b: bytes };
if r.take(4)? != b"8BPS" {
@@ -97,13 +107,8 @@ pub(crate) fn decode(bytes: &[u8]) -> Option<image::RgbaImage> {
let depth = r.u16()?;
let mode = Mode::parse(r.u16()?)?;
let max_side = if psb { 300_000 } else { 30_000 };
let depth_ok = match mode {
Mode::Bitmap => depth == 1,
Mode::Indexed => depth == 8,
_ => depth == 8 || depth == 16,
};
let colours = mode.colour_channels();
if !(1..=56).contains(&channels) || channels < colours || !(1..=max_side).contains(&w) || !(1..=max_side).contains(&h) || !depth_ok {
if !header_in_range(mode, depth, channels, (w, h), max_side) {
return None;
}
let (wu, hu) = (usize::try_from(w).ok()?, usize::try_from(h).ok()?);
@@ -298,7 +303,7 @@ impl<'a> ImageData<'a> {
}
let mut counts = self.counts.chunks_exact(self.count_size).map(|c| c.iter().fold(0usize, |n, &b| n << 8 | usize::from(b)));
// This channel starts after every earlier channel's rows.
let mut at = counts.by_ref().take(ch.checked_mul(self.height)?).try_fold(0usize, |at, n| at.checked_add(n))?;
let mut at = counts.by_ref().take(ch.checked_mul(self.height)?).try_fold(0usize, usize::checked_add)?;
for _ in 0..self.height {
let n = counts.next()?;
let end = at.checked_add(n)?;
+2 -2
View File
@@ -51,7 +51,7 @@ fn confined(tag: &str, read: bool, write: bool) -> (Server, PathBuf) {
std::fs::write(dir.join("tags.xml"), format!("<root><p>{text}</p></root>")).unwrap();
}
let (in_dir, out_dir) = (base.join("in"), base.join("out"));
let roots = AutomationRoots::new(read.then_some(in_dir.as_path()), write.then_some(out_dir.as_path())).unwrap();
let roots = AutomationRoots::new(read.then_some(in_dir.as_path()).as_slice(), write.then_some(out_dir.as_path()).as_slice()).unwrap();
let mut server = Server::new(Box::new(Headless::with_document().with_automation_roots(roots)));
ok(&mut server, "new_document", json!({}));
(server, base)
@@ -339,7 +339,7 @@ fn without_roots_nothing_is_confined() {
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
designcraft_file(&base.join("a.designcraft"), 3, "Anywhere");
let mut server = Server::new(Box::new(Headless::with_document().with_automation_roots(AutomationRoots::new(None, None).unwrap())));
let mut server = Server::new(Box::new(Headless::with_document().with_automation_roots(AutomationRoots::new(&[], &[]).unwrap())));
ok(&mut server, "open_document", json!({"path": s(&base.join("a.designcraft"))}));
assert_eq!(pages(&mut server), 3);
ok(&mut server, "save_document", json!({"path": s(&base.join("b.designcraft"))}));
+43 -35
View File
@@ -134,6 +134,25 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions)
write::guard(|| export_pdf_with_report_inner(doc, cache, opts))
}
/// Form fields (Buttons and Forms) and, for interactive PDF, video and sound; not in PDF/X
/// or PDF/A: this update comes after krilla's validator ran, and its NeedAppearances fields and
/// Screen annotations aren't allowed in PDF/A.
fn add_form_fields(bytes: &mut Vec<u8>, warnings: &mut Vec<String>, doc: &Document, sheets: &[Sheet], opts: &PdfOptions) {
let fields = crate::forms::collect(doc, sheets);
let media = if opts.media { crate::forms::collect_media(doc, sheets) } else { Vec::new() };
if fields.is_empty() && media.is_empty() {
return;
}
match opts.standard {
Standard::PdfX4 => warnings.push("PDF/X-4: form fields and media were left out".into()),
Standard::PdfA2b => warnings.push("PDF/A-2b: form fields and media were left out".into()),
_ => match crate::forms::add_fields(&*bytes, &fields, &media) {
Some(b) => *bytes = b,
None => warnings.push("form fields and media couldn't be added to this PDF".into()),
},
}
}
fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions) -> Result<ExportReport> {
let sheets = sheets(doc, opts)?;
let warnings = Vec::new();
@@ -235,23 +254,7 @@ fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions
// group that got past it, with its page.
crate::pdfx::cmyk_group_spaces(&mut bytes);
}
// Form fields (Buttons and Forms) and, for interactive PDF, video and sound; not in PDF/X
// or PDF/A: this update comes after krilla's validator ran, and its NeedAppearances fields and
// Screen annotations aren't allowed in PDF/A.
let fields = crate::forms::collect(doc, &sheets);
let media = if opts.media { crate::forms::collect_media(doc, &sheets) } else { Vec::new() };
if !fields.is_empty() || !media.is_empty() {
if opts.standard == Standard::PdfX4 {
warnings.push("PDF/X-4: form fields and media were left out".into());
} else if opts.standard == Standard::PdfA2b {
warnings.push("PDF/A-2b: form fields and media were left out".into());
} else {
match crate::forms::add_fields(&bytes, &fields, &media) {
Some(b) => bytes = b,
None => warnings.push("form fields and media couldn't be added to this PDF".into()),
}
}
}
add_form_fields(&mut bytes, &mut warnings, doc, &sheets, opts);
if opts.standard == Standard::PdfX4 {
// Output intent and PDF/X identification, then check the result.
match crate::pdfx::make_pdfx4(&bytes, &title, created) {
@@ -654,6 +657,27 @@ impl Exporter<'_> {
self.doc.resolve_color(name, tint).map(|c| device(&c, self.rgb_only))
}
/// The parent pages' items on `layer` shown on page `pi` of spread `si`: the parent's bases
/// first, then the parent.
fn parent_items(&mut self, s: &mut Surface, si: usize, pi: usize, page: &designcraft_doc::Page, layer: &designcraft_doc::Layer, first: usize) {
let doc = self.doc;
for (ppi, ppage) in doc.shown_parent_pages(SpreadRef::Doc(si), pi) {
let Some(parent) = doc.parents.get(ppi) else { continue };
let Some(ppg) = parent.pages.get(ppage) else { continue };
let dx = page.x - ppg.x;
for it in &parent.items {
if it.layer != layer.id || page.overridden.contains(&it.id) {
continue;
}
// Parent items belong to the parent page they sit on.
if parent.page_at_x(it.bounds().center().x) != Some(ppage) && parent.pages.len() > 1 {
continue;
}
self.top_item(s, it, Affine::translate((dx, 0.0)), Some(first + pi), true);
}
}
}
fn spread(&mut self, s: &mut Surface, si: usize) {
let doc = self.doc;
let Some(sp) = doc.spreads.get(si) else { return };
@@ -663,24 +687,8 @@ impl Exporter<'_> {
continue;
}
for (pi, page) in sp.pages.iter().enumerate() {
if !page.show_parent_items {
continue;
}
// The parent's bases first, then the parent.
for (ppi, ppage) in doc.shown_parent_pages(SpreadRef::Doc(si), pi) {
let Some(parent) = doc.parents.get(ppi) else { continue };
let Some(ppg) = parent.pages.get(ppage) else { continue };
let dx = page.x - ppg.x;
for it in &parent.items {
if it.layer != layer.id || page.overridden.contains(&it.id) {
continue;
}
// Parent items belong to the parent page they sit on.
if parent.page_at_x(it.bounds().center().x) != Some(ppage) && parent.pages.len() > 1 {
continue;
}
self.top_item(s, it, Affine::translate((dx, 0.0)), Some(first + pi), true);
}
if page.show_parent_items {
self.parent_items(s, si, pi, page, layer, first);
}
}
for it in &sp.items {
+1 -1
View File
@@ -56,7 +56,7 @@ pub enum Standard {
impl Standard {
/// `none`, `x4` / `pdfx4` / `PDF/X-4`, `a2b` / `pdfa2b` / `PDF/A-2b`.
pub fn parse(s: &str) -> Option<Self> {
let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase();
let k: String = s.chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase();
match k.as_str() {
"" | "none" => Some(Self::None),
"x4" | "pdfx4" | "pdfx42008" | "pdfx42010" => Some(Self::PdfX4),
+16 -13
View File
@@ -285,7 +285,7 @@ fn device_rgb(pdf: &[u8]) -> Vec<String> {
found
.into_iter()
.map(|(what, pages)| {
let list: Vec<String> = pages.iter().take(10).map(|p| p.to_string()).collect();
let list: Vec<String> = pages.iter().take(10).map(ToString::to_string).collect();
let more = if pages.len() > 10 { ", …" } else { "" };
let s = if pages.len() == 1 { "" } else { "s" };
format!("{what} on page{s} {}{more}: under a CMYK output intent RGB needs an ICC profile", list.join(", "))
@@ -392,18 +392,7 @@ impl RgbScan {
}
b"BI" | b"EI" if !managed => {
if let Some(Object::Stream(img)) = last {
let cs = img.dict().get::<Object<'_>>(b"CS").or_else(|| img.dict().get::<Object<'_>>(b"ColorSpace"));
let cs = match cs {
Some(Object::Name(n))
if !matches!(n.as_ref(), b"DeviceRGB" | b"RGB" | b"G" | b"DeviceGray" | b"CMYK" | b"DeviceCMYK") =>
{
res.get_color_space(&n)
}
cs => cs,
};
if cs.is_some_and(|cs| is_device_rgb(&cs)) {
self.add("an RGB inline image".into());
}
self.inline_image(&img, res);
}
}
_ => {}
@@ -411,6 +400,20 @@ impl RgbScan {
}
}
/// An inline image (`BI … EI`) in an RGB space, named directly or through the resources.
fn inline_image(&mut self, img: &Stream<'_>, res: &Resources<'_>) {
let cs = img.dict().get::<Object<'_>>(b"CS").or_else(|| img.dict().get::<Object<'_>>(b"ColorSpace"));
let cs = match cs {
Some(Object::Name(n)) if !matches!(n.as_ref(), b"DeviceRGB" | b"RGB" | b"G" | b"DeviceGray" | b"CMYK" | b"DeviceCMYK") => {
res.get_color_space(&n)
}
cs => cs,
};
if cs.is_some_and(|cs| is_device_rgb(&cs)) {
self.add("an RGB inline image".into());
}
}
fn image(&mut self, img: &Stream<'_>, managed: bool) {
let id = img.obj_id();
if managed || !self.seen.insert(id) || img.dict().get::<bool>(b"ImageMask") == Some(true) {
+1 -1
View File
@@ -33,7 +33,7 @@ pub(super) fn unembedded_fonts(pdf: &[u8]) -> Vec<String> {
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 list: Vec<String> = pages.iter().take(10).map(ToString::to_string).collect();
let more = if pages.len() > 10 { ", …" } else { "" };
let s = if pages.len() == 1 { "" } else { "s" };
format!("the font {name} isn't embedded (page{s} {}{more}): PDF/X-4 needs every font used for visible text embedded", list.join(", "))
+20 -15
View File
@@ -303,6 +303,25 @@ impl Exporter<'_> {
});
}
/// One table cell's graphic, or its text composed in the cell's own area.
fn table_cell(&mut self, s: &mut Surface, c: &designcraft_compose::PlacedCell) {
if let Some(g) = &c.graphic {
if let Some(clip) = to_path(&c.clip.to_path(0.1)) {
s.push_clip_path(&clip, &krilla::paint::FillRule::NonZero);
s.push_transform(&tf(Affine::translate((c.clip.x0, c.clip.y0))));
self.graphic(s, g);
s.pop();
s.pop();
}
} else if let Some(cft) = c.text.frames.first() {
s.push_transform(&tf(Affine::translate(c.origin.to_vec2())));
let area = std::mem::replace(&mut self.text_area, lines_area(cft));
self.frame_text(s, &c.text, cft, &c.source);
self.text_area = area;
s.pop();
}
}
/// Table fragments: cell fills, cell text (real text), edges and the border.
fn tables(&mut self, s: &mut Surface, ft: &FrameText) {
for t in &ft.tables {
@@ -314,21 +333,7 @@ impl Exporter<'_> {
}
}
for c in &t.cells {
if let Some(g) = &c.graphic {
if let Some(clip) = to_path(&c.clip.to_path(0.1)) {
s.push_clip_path(&clip, &krilla::paint::FillRule::NonZero);
s.push_transform(&tf(Affine::translate((c.clip.x0, c.clip.y0))));
self.graphic(s, g);
s.pop();
s.pop();
}
} else if let Some(cft) = c.text.frames.first() {
s.push_transform(&tf(Affine::translate(c.origin.to_vec2())));
let area = std::mem::replace(&mut self.text_area, lines_area(cft));
self.frame_text(s, &c.text, cft, &c.source);
self.text_area = area;
s.pop();
}
self.table_cell(s, c);
}
for seg in &t.strokes {
let st = &seg.stroke;
+1 -1
View File
@@ -240,7 +240,7 @@ fn srgb_table() -> std::sync::Arc<Vec<[f32; 3]>> {
type Lut = (designcraft_color::cms::ColorSettings, std::sync::Arc<Vec<[f32; 3]>>);
static LUT: std::sync::Mutex<Option<Lut>> = std::sync::Mutex::new(None);
let settings = designcraft_color::cms::active_settings();
let mut g = LUT.lock().unwrap_or_else(|e| e.into_inner());
let mut g = LUT.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
match g.as_ref() {
Some((s, t)) if *s == settings => t.clone(),
_ => {
+1 -1
View File
@@ -56,7 +56,7 @@ fn size_of(pm: &Pixmap) -> usize {
}
fn with<R>(f: impl FnOnce(&mut Cache) -> R) -> R {
let mut g = CACHE.lock().unwrap_or_else(|e| e.into_inner());
let mut g = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let c = g.get_or_insert_with(Cache::default);
// Decoding converts CMYK through the working space: new colour settings, new pixels.
let colour = designcraft_color::cms::generation();

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