Compare commits

...

125 Commits

Author SHA1 Message Date
aiongg 0f4f382b0e
feat(text): improve editing, snapping, and threading
Add multi-click text selection, pending caret formatting and clipboard support, text-aware colour controls, guide/grid snapping, and frame threading with loaded-port interactions.

Include the layout, drop-cap and IDML updates carried from #83, with safeguards for generated and linked stories, external text references, local IDs, locked frames and atomic colour edits.

Fixes #23.

Co-authored-by: Sean Hammond <sean.hammond@und.edu>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:41:46 -06:00
Brandon Thomas e862e0a613 fix: integrate batched community PRs (#572 with #662's fallible spread_param; #524's test helper back in its module)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 02:28:50 -04:00
Brandon Thomas 777d8c3251 Merge branch 'pr/572' into batch1
# Conflicts:
#	crates/tools/src/tests.rs
#	crates/ui-egui/src/i18n.rs
#	crates/ui-egui/src/i18n/ar.rs
#	crates/ui-egui/src/i18n/it.rs
#	crates/ui-egui/src/i18n/ja.rs
#	crates/ui-egui/src/i18n/pt_br.rs
#	crates/ui-egui/src/i18n/uk.rs
2026-10-13 02:24:24 -04:00
Brandon Thomas d1452a3e3f Merge branch 'pr/524' into batch1
# Conflicts:
#	crates/ui-egui/src/panels/properties.rs
2026-10-13 02:23:58 -04:00
Brandon Thomas 99c229486d Merge branch 'pr/575' into batch1
# Conflicts:
#	crates/ui-egui/src/i18n.rs
#	crates/ui-egui/src/i18n/ar.rs
#	crates/ui-egui/src/i18n/it.rs
#	crates/ui-egui/src/i18n/ja.rs
#	crates/ui-egui/src/i18n/pt_br.rs
#	crates/ui-egui/src/i18n/uk.rs
2026-10-13 02:23:57 -04:00
Brandon Thomas 89e9abbc8d Merge branch 'pr/662' into batch1
# Conflicts:
#	crates/engine/src/cmd/object.rs
2026-10-13 02:23:56 -04:00
Brandon Thomas a390905beb Merge branch 'pr/647' into batch1
# Conflicts:
#	crates/tools/src/lib.rs
#	crates/tools/src/tests.rs
#	crates/ui-egui/src/canvas.rs
2026-10-13 02:23:33 -04:00
Brandon Thomas bd9974a420 Merge branch 'pr/552' into batch1 2026-10-13 02:23:12 -04:00
Brandon Thomas ea8dc959d1 Merge branch 'pr/573' into batch1 2026-10-13 02:23:12 -04:00
Brandon Thomas 41ce560ce3 Merge branch 'pr/541' into batch1 2026-10-13 02:23:12 -04:00
Brandon Thomas 0a14d9fb97 Merge branch 'pr/539' into batch1 2026-10-13 02:23:12 -04:00
Brandon Thomas 2103af784a Merge branch 'pr/537' into batch1 2026-10-13 02:23:11 -04:00
Brandon Thomas 055da9f788 Merge branch 'pr/536' into batch1 2026-10-13 02:23:11 -04:00
Brandon Thomas 160ae04b8b Merge branch 'pr/527' into batch1 2026-10-13 02:23:11 -04:00
Brandon Thomas d5613411ca Merge branch 'pr/522' into batch1 2026-10-13 02:23:11 -04:00
Brandon Thomas d6d26d2b38 Merge branch 'pr/519' into batch1 2026-10-13 02:23:11 -04:00
Brandon Thomas 1f6ceea657 Merge branch 'pr/634' into batch1 2026-10-13 02:23:11 -04:00
Brandon Thomas eddf681df1 Merge branch 'pr/677' into batch1 2026-10-13 02:23:11 -04:00
Brandon Thomas 6e551af3e7 Merge branch 'pr/663' into batch1 2026-10-13 02:23:11 -04:00
Brandon Thomas b758c6180a Merge branch 'pr/649' into batch1 2026-10-13 02:23:11 -04:00
Brandon Thomas 9bd099c0ae Merge branch 'pr/639' into batch1 2026-10-13 02:23:11 -04:00
Brandon Thomas bff290c9bd Merge branch 'pr/607' into batch1 2026-10-13 02:23:11 -04:00
Brandon Thomas b70b586a16 fix(path-type): NaN-safe bracket clamping (f64::clamp panics on a NaN path length)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 02:22:34 -04:00
Brandon Thomas 878169130e Merge branch 'pr/600' into batch1 2026-10-13 02:21:53 -04:00
Brandon Thomas 8a36af3af8 Merge branch 'pr/630' into batch1 2026-10-13 02:21:53 -04:00
Brandon Thomas a0330b03a0 Merge branch 'pr/585' into batch1 2026-10-13 02:21:39 -04:00
Brandon Thomas b095274498 Merge branch 'pr/680' into batch1 2026-10-13 02:21:39 -04:00
Brandon Thomas 528f5138df Merge branch 'pr/678' into batch1 2026-10-13 02:21:39 -04:00
Brandon Thomas eb855a1964 Merge branch 'pr/672' into batch1 2026-10-13 02:21:39 -04:00
Brandon Thomas fc0a304750 Merge branch 'pr/671' into batch1 2026-10-13 02:21:39 -04:00
Brandon Thomas aae8ff2138 Merge branch 'pr/670' into batch1 2026-10-13 02:21:39 -04:00
Brandon Thomas f95d58c726 Merge branch 'pr/669' into batch1 2026-10-13 02:21:39 -04:00
Brandon Thomas 392a5c7ab7 Merge branch 'pr/668' into batch1 2026-10-13 02:21:39 -04:00
Brandon Thomas 8255d58c2e Merge branch 'pr/667' into batch1 2026-10-13 02:21:39 -04:00
Brandon Thomas e009a43d10 Merge branch 'pr/666' into batch1 2026-10-13 02:21:39 -04:00
sanyamsingh001 481b7d2f3b PARITY-EFFECTIVE-PPI: check transformed image resolution
Co-Authored-By: Codex <codex@openai.com>
2026-10-13 13:17:58 +10:00
sanyamsingh001 fa9c7b08b2 PARITY-DISTRIBUTE-LOCKS: preserve locked objects during distribution
Co-Authored-By: Codex <codex@openai.com>
2026-10-13 12:43:50 +10:00
Marco Arnulfo 966b637796 fix(ui): open the dock's panel menu from its three-line button (part of #676) 2026-10-13 04:27:41 +02:00
sanyamsingh001 5ca6a474b9 PARITY-TABLE-SPLIT: split each original selected cell once
Co-Authored-By: Codex <codex@openai.com>
2026-10-13 10:59:32 +10:00
sanyamsingh001 8f48869e58 PARITY-IDML-INDEX: retain index reference source sort keys
Co-Authored-By: Codex <codex@openai.com>
2026-10-13 10:56:09 +10:00
sanyamsingh001 b3ec8dc908 PARITY-EPUB-STYLE: match CSS to exported style mappings
Co-Authored-By: Codex <codex@openai.com>
2026-10-13 10:53:54 +10:00
sanyamsingh001 e507b98601 PARITY-TAB-LEADERS: draw right-indent tab leaders
Co-Authored-By: Codex <codex@openai.com>
2026-10-13 10:51:50 +10:00
sanyamsingh001 b4db428608 PARITY-ARRANGE: step past same-layer siblings in Arrange
Co-Authored-By: Codex <codex@openai.com>
2026-10-13 10:48:56 +10:00
sanyamsingh001 f7adb11cdc PARITY-CHARACTER-STYLE: capture selected character formatting
Co-Authored-By: Codex <codex@openai.com>
2026-10-13 10:46:10 +10:00
sanyamsingh001 7abcbc745f PARITY-DATA-SOURCE: honor quoted delimiters and record boundaries
Co-Authored-By: Codex <codex@openai.com>
2026-10-13 10:44:20 +10:00
Jason Brashear 91263c912f
fix(textimport): preserve leading empty Tagged Text paragraphs
Preserve paragraph breaks after a ParaStyle tag starts the story so leading empty paragraphs and their styles survive Tagged Text import.

Validated individually and after #638 with all six cargo xtask ci gates (1,278 tests passed, four existing tests ignored, 15 WASM compilation checks), six independent paragraph/header/Unicode probes, and an actual export-to-placement command smoke test.

Refs #641
2026-10-12 17:21:52 -06:00
Jason Brashear cc5b4a1f0f
fix(color): mirror gradient midpoints when reversing
Move and mirror segment midpoints when reversing gradient stops so reversed gradients preserve color and opacity interpolation.

Validated locally on macOS with all six cargo xtask ci gates (1,277 tests passed, four existing tests ignored, 15 WASM compilation checks), plus independent regressions, undo/redo, native and IDML round trips, and PNG/PDF rendering.

Refs #638
2026-10-12 17:20:54 -06:00
Marco Arnulfo 9e15d5a17e fix(cli): accept a UTF-8 BOM in scripts and document the syntax in script --help (fixes #654) 2026-10-13 00:41:46 +02:00
Marco Arnulfo 3d59dd5d97 fix(cmd): reject unknown spread values instead of falling back to page 1 (part of #651) 2026-10-13 00:38:54 +02:00
Marco Arnulfo fc01666ac2 feat(text): select sentence, paragraph and story on 3, 4 and 5 clicks (fixes #622) 2026-10-12 23:58:11 +02:00
moppeeer f96895f400 feat(text): Edit type on a path along the curve
The Type tool's clicks, drags and double-clicks on type on a path went to
the character at the same spot of the straightened line, and the caret
and highlight were drawn straight. A click now finds the character where
its nearest point along the path is (compose::PathTextMap, from the line
to the path and back, as path_glyphs places the glyphs); the caret stands
across the path there and the highlight bends with the text. A click on
the letters, above the path, hits the frame.

Depends on #636 (PathWarp::nearest).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:35:58 +02:00
Marco Arnulfo de31bd5647 fix(fonts): fall back to a font made for Malayalam (part of #628)
Malayalam took the first installed file mapping it: on macOS Arial Unicode MS, which has no Malayalam shaping, so conjuncts like ക്ഷ fell apart. Fonts made for Malayalam are tried first, loaded on demand through the CJK chain lookup.
2026-10-12 23:03:22 +02:00
skylar taylor-barrick 64f0ca8fa6
fix(macos): make Dock Quit cancelable
Route AppKit termination through the existing unsaved-document close transaction using the immutable winit patch. Restore pending prompts from hidden or minimized windows and preserve answers across repeated requests.

Validation: all six local gates, native cancellation/retry/save smoke, and hosted platform checks.

Fixes #544.

PR #623.
2026-10-12 14:52:12 -06:00
moppeeer cded7d997e feat(tools): Drag type-on-a-path start and end brackets
Selected type on a path shows its start and end brackets across the path,
as in InDesign, and the Selection and Direct Selection tools drag them
along it (Move Bracket, one undo step): the text starts and ends where
they are, and text past the end bracket is overset.

- doc: PathType.end (None: the path's end)
- geom: PathWarp::nearest, the arc length of the nearest point
- engine: type.pathOptions {end}; brackets stay on the path and can't cross
- compose: the line runs from the start to the end bracket
- idml: EndBracket read and written
- tools: Cursor::PathBracket

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:32:56 +02:00
moppeeer c35515c54d feat(tools): The Pen continues an open path from either end point
As in InDesign, the Pen over an end point of an open path (selected, or
under the pointer) shows a continue cursor; clicking it carries on drawing
that path, new anchors going on its subpath, and clicking its other end
closes it. Dragging from the end point first reshapes its direction
lines. From the first anchor the subpath is reversed first.

- engine: path.reverse (Object > Paths > Reverse Path), path.setAnchor;
  path.appendAnchor and path.close take a subpath, and appendAnchor
  refuses a closed subpath
- tools: Cursor::PenContinue

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:18:14 +02:00
skylar taylor-barrick 17cf5b4e91
test(recovery): make failed republishing fixtures deterministic
Force actual failed recovery publication at the reserved destination so privileged runners exercise the intended retention path. Keep the normal writer, publication order and existing assertions; enable these fixtures across platforms.

Validation: all six local gates and the FreeBSD root VM workspace build/tests.

PR #627.
2026-10-12 13:57:37 -06:00
aiongg 84158e36d3 Document tabs: long names are shortened so the close button stays visible
A tab is at most 260 px wide. A longer document name ends in an ellipsis
(cut at a grapheme boundary, RTL names included), the dirty marker and
zoom stay readable, and the full name shows in the tab's tooltip. Tabs
and their close buttons carry accessible labels.

Fixes #603
2026-10-12 19:32:28 +00:00
skylar taylor-barrick c3859c828b
fix(recovery): preserve documents on orderly app exit
Checkpoint the latest document state during App::on_exit after honoring current quit approvals. Keep incomplete or stale plans, clear recovery for clean documents and report unavailable or failed storage.

Follow-up to #545; cancelable macOS Dock Quit remains open in #544.

PR #617.
2026-10-12 12:18:32 -06:00
skylar taylor-barrick 8e151a2fd4
fix(recovery): retain recovered checkpoints until save or close
Keep recovered snapshots available through a second crash before autosave. Track checkpoint ownership across folders and restarted IDs; Save, Close and Revert clean only checkpoints owned by that document.

Follow-up to #506 and the retention concern in #380.

PR #616.
2026-10-12 12:15:46 -06:00
skylar taylor-barrick 9509b2bcea
fix(book): stage chapter updates before saving
Prepare and stage every chapter before a book update writes files. Preserve imported IDML sources, reject open native chapters and report completed paths after a later write failure.

Follow-up to #485.

PR #615.
2026-10-12 12:12:01 -06:00
skylar taylor-barrick e198eb5d1d
fix(export): replace desktop and CLI outputs atomically
Route desktop writes and CLI raster exports through the engine atomic writer. Add actual-caller regressions for reader visibility, private modes, symlinks and valid long filenames.

Follow-up to #485.

PR #614.
2026-10-12 12:04:39 -06:00
skylar taylor-barrick b9048a5b08
fix(save): preserve live state when persistence fails
Preserve document paths, revisions and collection contents when persistence fails. Rebase Save a Copy paths without changing the open document.

Follow-up to #485.

PR #613.
2026-10-12 11:53:46 -06:00
moppeeer 5eda7cebbc fix(geom): Trim straight sides exactly for Corner Options
Straight sides were trimmed through inv_arclen at 1e-3 accuracy, so a
rectangle's corner points landed up to about 1e-4 pt off. Lines are now
measured and trimmed along the chord, exactly; curves still use arc length.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:29:13 +02:00
moppeeer a175245f95 fix(geom): Corner Options keep curves and round every corner point
Corners were applied only where two straight segments meet, and every
other segment was redrawn as a straight line: a Pathfinder quarter
circle with rounded corners rendered as a triangle with one round
corner. Corner points are now where the path turns; the sides next to a
corner are trimmed along their length, curves included, and stay
curves. Smooth points are left alone; rectangles are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:29:13 +02:00
moppeeer 0022b3a89b feat(ui): Pathfinder section in Properties
With several objects selected, InDesign's Properties panel shows
Pathfinder: Add, Subtract, Intersect, Exclude Overlap and Minus Back.
DesignCraft had them only in Object › Pathfinder and the Pathfinder
panel. The section sits after Align, with code-drawn icons.

Section titles and icon buttons in Properties now have accessible names
(the title; the button's tooltip), so screen readers can tell them apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:29:12 +02:00
moppeeer 9ac6f522bb fix(ui): Screen Mode checks the current mode; Japanese View submenu paths
View › Screen Mode showed no check mark (checked() had no view.screenMode
arm), so with the Normal/Preview row gone nothing showed the current
mode. ja.rs still translated the old Type/Proof Setup, Type/Rotate Spread
and Type/Separations Preview paths; it now has the View/… paths,
Screen Mode, Extras and Structure included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:29:10 +02:00
moppeeer fb296eacac fix(ui): View items in the View menu, laid out like InDesign's
Rotate Spread, Proof Setup, Proof Colors, Flattener Preview, Show Tag
Markers, Separations Preview and Show/Hide Tagged Frames were at the end
of the Type menu. They are in View now, which follows InDesign's order:
Overprint Preview and proofing, zoom, Rotate Spread, Screen Mode,
Display Performance, rulers, Extras (frame edges, text threads), Grids &
Guides and Structure (tagged frames, tag markers). Show Hidden
Characters stays in Type, as in InDesign; W still toggles Preview.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:29:10 +02:00
moppeeer 1dc33dd280 fix(render): One underline bar per stretch of text, no seams
The canvas drew an underline (and a strikethrough) as one rectangle per
glyph; where two anti-aliased edges met at a fractional pixel they left
a lighter seam between the letters. Contiguous bars of the same colour
and position are merged into one, as the PDF export already does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:29:09 +02:00
moppeeer 8fd67c8674 feat(ui): Rotation, shear, scale, rotate and flip in Properties › Transform
InDesign's Properties › Transform shows, under X/Y/W/H, the rotation
and shear angles beside the X and Y scale, then buttons to rotate a
quarter turn either way and to flip horizontally or vertically.
DesignCraft's showed only X/Y/W/H; the rest was in the Control bar
and Object › Transform. The fields set totals through transform.set
around the reference point; the buttons run transform.rotate and
transform.flip. Rotate and flip icons are original drawings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:29:08 +02:00
moppeeer 03d8b1e61b fix(ui): Double-clicking the Fill or Stroke box opens the Color Picker
In InDesign a double-click on the Tools panel's Fill or Stroke box opens
the Color Picker; DesignCraft opened the Swatches panel. It now opens
the Color Picker for that box, with the selection's colour (the selected
text's when text is selected). The Stroke box also reports its name to
screen readers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:29:05 +02:00
moppeeer 7ee66df165 feat(ui): Step and Repeat dialog with Create as a grid and Preview
Edit > Step and Repeat opened the generic command form (Count 1..1000, Dx, Dy, Rows, Columns, Ids). It now has InDesign's dialog: Repeat (Count, or Rows and Columns when Create as a grid is on), Offset (Vertical, Horizontal, in the document's ruler units or any unit typed) and Preview, which shows the copies on the page while the dialog is open. Cancel, Esc and ui.dialog.cancel take the preview away; OK makes the copies once, as one undo step. A value that isn't a number keeps the dialog open and says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:29:02 +02:00
moppeeer 6880042c6a fix(compose): Hyphenation Zone applies to the Single-line Composer only
InDesign's Hyphenation Zone ("the white space allowed at the end of a
line of unjustified text before hyphenation begins") applies only with the
Single-line Composer. The Paragraph Composer, the default, weighs every
break of the paragraph instead. DesignCraft applied the default 3p0 zone
to both, so ragged paragraphs under the Paragraph Composer refused
hyphens InDesign makes. In LYH Studio's poster tutorial the placeholder
text breaks "aper-natem" and "mosticsia-tur"; ours ran a line longer.

The breaker now gets the zone only for unjustified Single-line Composer
paragraphs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:29:01 +02:00
moppeeer 3651bcb10b docs: Drop a duplicate progress-log line with raw line breaks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:26:49 +02:00
moppeeer e0cf54ec2b fix(engine): note.edit reads CR as a paragraph return too
note.edit was the one story-text command still reading `text` without
text_param. The progress-log line held real CR and CRLF characters where
it meant to show the escapes; it reads `\r` and `\r\n` now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:26:49 +02:00
moppeeer 0ac04151c9 fix(engine): Every command that writes story text reads CR as a paragraph return
frame.create and text.insert turned \r and \r\n into paragraph returns,
but story.replaceRange, story.setText, note.new, footnote and endnote
text, type on a path and table cells kept the CR in the text, where it
drew as a missing glyph and joined the paragraphs. They all use
text_param now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:26:48 +02:00
moppeeer d6635f180a fix(engine): The selected-text preview reads only the 60 characters it reports
type.selectionAttrs copied the whole selection to a string before taking
the first 60 characters, so selecting a long story copied all of it on
every query. It now skips leading blanks and stops after 60 characters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:22:03 +02:00
moppeeer ef87428481 feat(engine): Report the selected text; remember recently used fonts
type.selectionAttrs reports the start of the selected text on one line
(`selectedText`, up to 60 characters), for previews such as the font
menu's Selected Text sample. The preferences keep `recentFonts`, the
fonts last picked in the font menus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:20:17 +02:00
moppeeer 6baa57b503 feat(fonts): A family's kind without keeping it loaded
ScopedFonts::family_kind reads a family's format, class, PANOSE and file
date from its Regular face. A loaded face is asked; an installed family
that isn't loaded is read from its file, which is let go afterwards,
instead of loading every family the font menu's filters look at for the
rest of the session. Files over MAX_DOCUMENT_FONT_BYTES are skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:20:16 +02:00
moppeeer 03a8d4b955 feat(fonts): Font format, classification and PANOSE for the font menus
Each face reports what InDesign's font menu shows and filters by, read
once from its tables: its format (TrueType, OpenType/CFF, variable,
colour), its class (serif, slab serif, sans serif, script, monospaced,
decorative, symbol) from PANOSE, else the OS/2 IBM family class, else
the family name, else the shape of its "l" (serifs widen the foot), its
PANOSE numbers (with a distance for similar fonts) and when its file
was added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:20:16 +02:00
moppeeer b71d469b79 fix(ui): A frame holding pasted-in content stays at the Object level
Paste Into gives a frame nested content, but it isn't a group: the Effects
panel showed "Group:" and enabled Isolate Blending and Knockout Group for
it. It now asks Item::is_group, so such a frame shows "Object:" with the
group options disabled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:17:34 +02:00
moppeeer 45a228426a feat(ui): Effects panel shows the level, Isolate Blending and Knockout Group
The Effects panel had the blend mode, opacity and Drop Shadow only. Like InDesign's it now names the level the settings apply to with its blend mode and opacity (Group: Color Dodge 100%, Object: Normal 100%), and has Isolate Blending and Knockout Group, enabled for a selected group, through object.transparencyGroup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:17:34 +02:00
KnottyDyes 3259736931 MCP: reconnect closed sockets before sending the next request
Probe reused connections before writing so an app restart does not lose the next call. Report connection failures as not reachable with nothing sent, while retaining uncertain outcomes and no replay after sending begins.

Add same-port restart and unavailable-app loopback regressions, and retain the lost-reply guard.
2026-10-12 10:04:32 -06:00
moppeeer 9ab226d741 fix(eyedropper): Sample rasters only; colour text through object.color
A placed PDF, EPS or SVG under the Eyedropper matches attributes again,
as before, instead of failing with "no placed image under the point"
(Asset::is_raster, shared by the tool and color.sample). color.sample
hands its colour to object.color, which now colours selected text, the
text of selected frames ({text: true}), or the typing colour of an empty
story; a caret in existing text is refused before a swatch or undo step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:56:17 +02:00
moppeeer d7d6f27069 feat(eyedropper): Sample a placed image's colour into the selection
The Eyedropper on a placed image now does what InDesign does: with text
selected, clicking the photo colours the text with the image's colour under
the pointer; with objects selected, it fills them. The colour is an unnamed
colour (reused when the same colour is sampled again). Images on locked
layers can be sampled, and unfilled frames over the image let it show through.

- color.sample {at, spread?, target?: fill|stroke, ids?} samples the frontmost
  visible raster image at a spread point; one undo step; a caret or a point
  with no image is refused without an edit.
- object.color shares its swatch lookup with it.
- The Eyedropper tool sends color.sample over an image, matchAttributes
  elsewhere as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:56:17 +02:00
aiongg d020b4e85d
fix(ui): preserve documents when quitting is cancelled
Prompt for unsaved documents on window close and application/menu quit. Defer discarding documents until all approvals are current, retaining every tab when a later decision cancels quitting.

Refs #544. Cancelable macOS Dock Quit remains separate.

Co-authored-by: aiongg <aiongtaigi@gmail.com>
Co-authored-by: skylar taylor-barrick <hi@skylar.cc>
2026-10-12 09:31:24 -06:00
Noah Scholz 328b4d5a52
fix(recovery): isolate recovery data between app instances
Keep each app's recovery data in an owned run folder. Validate current folder and lock ownership and disable recovery when isolation fails instead of using the shared root.

Fixes #496.

Co-authored-by: noah1400 <48360809+noah1400@users.noreply.github.com>
Co-authored-by: skylar taylor-barrick <hi@skylar.cc>
2026-10-12 09:26:34 -06:00
aiongg 5cdcac4ace
fix(save): replace files atomically
Replace native engine saves and engine/MCP exports atomically, preserving private permissions during staging and cleaning up failed writes. Add regressions for long filenames and caught writer unwinding.

Fixes #436.

Co-authored-by: aiongg <aiongtaigi@gmail.com>
Co-authored-by: skylar taylor-barrick <hi@skylar.cc>
2026-10-12 09:19:36 -06:00
Brandon Thomas 3624f48819 Release 0.7.1: rebuild with the fixed shared release workflow
The previous release build failed in craft-common-actions, not in this
repo: riscv64 packaging got --formats deb,rpm,tar (fixed in
craft-common-actions#3), Linux jobs lacked the ALSA headers
(craft-common-actions#5), and Windows signing needed the AZURE_* secrets
listed in the caller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:11:06 -04:00
Brandon Thomas ebd1d78194
Release workflow: pass the Azure signing secrets so Windows builds are signed (#534)
Since the release workflow moved to storytold/craft-common-actions, it has
passed only the Apple and WINDOWS_CERTIFICATE secrets. The signing job reads
values from the release environment, but only for names the caller lists, so
the AZURE_* names that Trusted Signing uses came through empty and Windows
builds would ship unsigned. Same fix as storytold/craft-launcher#174.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:08:17 -04:00
aiongg 4ac6e9a540 Engine: swap and default fill and stroke in one undo step
object.swapFillStroke and object.defaultFillStroke change every unlocked
target in one edit, so one undo restores them all. Swap works per object
and keeps each object's tints; a stroke that gets a colour at zero weight
gets the default 1 pt. Objects that are locked, or on a locked layer,
stay as they are. color.swapFillStroke and color.defaultFillStroke run
these commands.
2026-10-12 14:28:58 +00:00
aiongg b21111f232 Toolbox: Shift+X, D, X and / shortcuts for the Fill/Stroke proxy
The proxy's tooltips named keys that did nothing. color.defaultFillStroke
(D), color.swapFillStroke (Shift+X), color.toggleFillStroke (X) and
color.applyNone (/) are UI commands; the proxy's buttons run the same
commands. Swap keeps the fill and stroke tints.

Which of fill and stroke is in front lives in UiState, shared by the
Tools panel proxy, the Color panel and the commands. The single-key
shortcuts stay out of text being typed, like the tool shortcuts.
2026-10-12 14:21:36 +00:00
Brandon Thomas 3384224419 Release prep: 0.7.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:44:24 -04:00
moppeeer 835edae659
feat(commands): return IDs from move, duplicate and repeat
Return ordered item IDs from Move, Duplicate and Step and Repeat while preserving the existing moved and created counts, selection and undo behavior. Scripts can use the IDs to operate on copies.

Validation: eight independent probes covering both swatch validation and returned IDs, plus all six cargo xtask ci gates on main after #520 (1,188 native tests passed, 3 ignored; all 15 applicable crates passed WASM compilation checks). Both ROADMAP entries and command regression modules are retained.

Task: #521
2026-10-11 21:58:49 -06:00
moppeeer 5789caaa60
fix(objects): reject unknown fill and stroke swatches
Reject unknown object fill, stroke and gap swatch names before editing the document, and suggest an existing name where possible.

Validation: three independent atomicity and compatibility probes, plus all six cargo xtask ci gates against current main (1,187 native tests passed, 3 ignored; all 15 applicable crates passed WASM compilation checks). ROADMAP progress entries are retained.

Task: #520
2026-10-11 21:54:43 -06:00
KnottyDyes fae9bc84ce
fix(text): reject unknown character fill swatches
Reject unknown character fill swatches before modifying text or undo history. Registered swatch names remain valid, including names that resemble hex colours.

Validation: invalid-name atomicity and save/reopen regressions, plus all six cargo xtask ci gates on main after #514 and #523 (1,186 native tests passed, 3 ignored; 15 crates passed WASM compilation checks).

Task: #403
2026-10-11 21:30:21 -06:00
seanth a21e8db14f
fix(compose): key parent-page compositions by absolute page index
Parent-page text now uses the absolute page index as its cache key, so repeated page numbers and renumbering do not reuse another page's composition.

Validation: targeted canvas and PDF regressions, plus all six cargo xtask ci gates on main after #514 (1,179 native tests passed, 3 ignored; 15 crates passed WASM compilation checks).

Task: #523
2026-10-11 21:27:52 -06:00
KnottyDyes 80c2904681
fix(mcp): prevent request replay after lost replies
Do not resend a request after a transport failure. Report the uncertain outcome and reconnect on the next explicit request.

Validation: all six cargo xtask ci gates passed on the merge candidate; 1,177 native tests passed and all 15 WASM packages checked. Lost-reply, invalid UTF-8 and timeout probes reproduced duplicate edits before the fix and one edit afterward.

Task: #514
2026-10-11 21:10:01 -06:00
aiongg f70f93de44
fix(idml): report missing parts and show import warnings
Match IDML part names leniently, report missing parts, and show import warnings when opening a file. Cover the malformed URI crash with a regression test.

Validation: all six cargo xtask ci gates passed on the current main merge candidate; 1,176 native tests passed and all 15 WASM packages checked.
Hosted FreeBSD tests and Windows ARM64 packaging and install/run checks passed.

Task: #356

Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com>
2026-10-11 19:39:34 -06:00
moppeeer 95c8c9cc5b
fix(hyphenation): honor capitalized-word settings for uppercase words
Honor the capitalized-word hyphenation setting for uppercase words and retain the current roadmap entries.

Validation: all six cargo xtask ci gates passed on the current main merge candidate; 1,170 native tests passed and all 15 WASM packages checked.

Task: #538

Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com>
2026-10-11 19:32:49 -06:00
aiongg 223dedfe5a
feat(assets): deduplicate files and omit unused assets on save
Share duplicate file data while keeping distinct link paths independent, and leave unused assets out of saved documents.

Validation: all six cargo xtask ci gates passed on the merge candidate; 1,170 native tests passed and all 15 WASM packages checked. Independent regression checks cover updates, relinking, embedding, undo and save/reopen.

Task: #420
2026-10-11 19:23:58 -06:00
aiongg ca1a8a12bc
Place: an image placed into a frame leaves Auto-Fit off (#528)
* Place: an image placed into a frame fills it once and leaves Auto-Fit off

* Place: the fitting test names its fitting, since a placed image no longer auto-fits
2026-10-11 18:49:56 -06:00
japbcoelho 597f0e649f
Color parsing: one scale per colour, not per component (#510)
A CMYK component of 1 or less was read as a fraction even when the other
components were percentages, so {"c": 0.7, "m": 0, "y": 12.2, "k": 0}
(a pale cream) became C 70 % Y 12.2 % and exported as
`0.7019608 0 0.12156863 0 k`. The same applied to RGB arrays.

Now a colour is read as percentages (0..100, or 0..255 for RGB) when any
component is above 1, and as fractions only when every component is
within 0..1.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:49:46 -06:00
skylar taylor-barrick 65ef027f9f
Merge pull request #452 from storytold/codex/craft-print
M6: Adopt craft-print and support Windows PDF printing
2026-10-11 18:12:43 -06:00
aiongg 66860bd1de
UI: Return and Escape in the font menu stay in the menu, never reach the dialog under it (#447)
Every font field's menu (Control bar, Contextual Task Bar, Properties > Character,
Character and Glyphs panels, Paragraph/Character Style Options, Find/Replace Font)
keeps its search focused and open on a click and filters as you type. In a dialog,
Return picked the highlighted family and also pressed OK, and Escape closed the
menu and cancelled the dialog: the menu now consumes the keys it uses. The
drawn font fields report themselves as combo boxes to screen readers.
2026-10-11 17:40:06 -06:00
japbcoelho 2b46ca664c
Hyphenation: Portuguese text is hyphenated with hyph-utf8's Portuguese patterns (#512)
Text set in Portuguese ("Portuguese: Brazilian", "Portuguese", pt_BR) got
no hyphenation, so justified Portuguese columns could only stretch their
word spaces (up to 335 % of the normal space in a 56 mm column of 10 pt
Times).

Turn on hypher's "portuguese" feature, beside Spanish: hyph-pt.tex 1.4
(2024-07-13) from hyph-utf8, BSD-3-Clause, credited in NOTICE.
Lang::for_language already maps the Portuguese names and locale codes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:39:53 -06:00
japbcoelho 3dc0a1563a
Place: PDF pages and SVGs keep their exact size (#511)
file.place sized a placed PDF page (and SVG) from its size rounded to
whole points, while the export draws the page into that size: a page of
91.774 x 613.276 pt became 92 x 613 and was drawn 0.25 % wider and
0.3 % shorter, so pieces laid edge to edge left gaps of up to 1.9 pt.
A 100.4 x 50.4 pt page placed 100.4 pt wide came out 50.2 pt tall:
`Rect { x0: 0.0, y0: 0.0, x1: 100.4, y1: 50.2 }`.

The graphic's natural size is now the exact page (crop box) or SVG size;
the rounded pixel size stays for display and preflight. place.load (the
place cursor) gets the same exact size.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:39:40 -06:00
skylar taylor-barrick 957890223a M6: Pin craft-print with native driver capability validation 2026-10-12 01:20:53 +02:00
skylar taylor-barrick 3ec5cef6a2 M6: Adopt merged craft-print with scaling and queue duplex defaults 2026-10-11 22:14:24 +02:00
Dylan M. Taylor ca343e8985
ci: refactor workflows to use storytold/craft-common-actions (#489)
* ci: refactor workflows to use reusable artcraft-common-actions

* ci: point reusable workflows to storytold/craft-common-actions

* ci: pin storytold/craft-common-actions to commit 27a978f and pass secrets explicitly

* ci: use @main (unpinned) for craft-common-actions with explanation comment
2026-10-11 11:52:22 -04:00
skylar taylor-barrick 9f7da3f6ab M6: Adopt craft-print for CUPS and Windows PDF printing 2026-10-11 07:29:54 +02:00
skylar taylor-barrick 16a7365701
Merge pull request #409 from skylartaylor/codex/ui-suite-integration-20261010
Adopt shared panel docking and preserve workspace commands
2026-10-10 19:46:47 -06:00
skylar taylor-barrick 2bdb0ea01d UI.9: recover saved docking metadata and declare frontend MSRV 2026-10-10 18:52:20 -06:00
skylar taylor-barrick a77bf8bff5 UI.9: reconcile upstream before shared UI landing 2026-10-10 18:00:07 -06:00
Brandon Thomas a9124a0517
Release prep: version 0.6.0; Linux riscv64 tar.gz in releases (#398)
- release.yml: new linux-riscv64 job (cross-compiled on ubuntu-24.04 against
  ports.ubuntu.com riscv64 libs, tar.gz only, CLI smoke-tested under QEMU),
  ported from GridCraft; the draft release job needs it, so SHA256SUMS covers it.
- packaging/linux/package.sh: CROSS_ARCH/CROSS_TARGET/CROSS_COMPILE/EMULATOR
  support, as in GridCraft.
- cargo xtask version set 0.6.0; README, hardware parity and ROADMAP log note
  the new platform.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:33:30 -04:00
skylar taylor-barrick c62866443c UI.9: integrate shared panel docking and preserve workspace behavior 2026-10-10 14:42:40 -06:00
Brandon Thomas a127c6d5c3
Progress docs: target-app parity, gaps, alpha gate, readiness by audience (#355)
Progress docs per craftrules standards/progress-docs.md: target-app parity, gaps, roadmap with alpha gate, readiness by audience (full / mainstream / essentials) with Opus 5.5 hours, and feature, format, hardware, UI and localization parity docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:09:17 -04:00
skylar taylor-barrick 92649ffdb0
Security: validate engine inputs and package paths (#155)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 04:50:26 -04:00
aiongg fc2ce5556f
Text: Split Columns sets a paragraph in sub-columns within its column (#295)
Consecutive paragraphs with the same Split Columns settings form a split
block: their text fills the sub-columns of the column one after the
other from the block's top, inset by the outside gutter and separated by
the inside gutter, then moves on to the next column. When text follows
the block, its last part is balanced across the sub-columns and the text
continues below the deepest one at the full column width; a block that
ends the story fills its sub-columns in turn. Keep options don't apply
between a block's lines.

IDML: SplitColumnInsideGutter and SplitColumnOutsideGutter import and
export (paragraph attributes split_inside_gutter, split_outside_gutter).

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 04:27:59 -04:00
vpkoskinen c657d8545e
PDF export: options dialog with remembered settings (#196)
* PDF export: Export Adobe PDF options dialog with remembered settings

File > Export PDF... now opens an options dialog (General / Compression /
Marks and Bleeds / Advanced) that exposes only the parameters the engine's
file.exportPdf already supports: PDF standard, image compression, flattener
preset, spreads, document bleed, crop/bleed/page-info marks with weight and
offset, tagged PDF, page range, and the title. Choosing a preset fills the
dependent fields; editing any field switches the preset to Custom.

The last-used settings persist in ui.json (UiState.pdfExport) and are reused
across exports and sessions. Programmatic callers that pass explicit params
(path, options) still export directly without the dialog; cancelling the save
picker writes nothing.

Adds English and ar/pt-br strings, unit tests for the settings serde, the
dialog defaults and preset mapping, the open/confirm flow (stub writer), and
the menu dispatch, plus a ROADMAP note.

* PDF export: rename dialog title to "Export PDF"

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 04:23:32 -04:00
aiongg 0b54cc3d66
Text: a word longer than the line breaks at the column edge; a line that exactly fills the measure fits (#293)
* Text: a line that exactly fills the measure fits

A line whose natural width overruns its measure by less than 0.01 pt
(rounded frame widths and advances) fits, in the paragraph and the
single-line composer. A one-word line that exactly filled its frame was
hyphenated and lost its last letters.

* Text: a word longer than the line breaks at the column edge

The paragraph composer gets emergency break points inside any word (or
part between hyphenation points) that is wider than the narrowest
measure even fully shrunk. They cost more than a hyphen, so a legal
hyphenation point wins where one fits; justified pieces are rated by the
space they leave, so each line takes as many letters as fit. Tracking is
part of the width.

Justified lines shrink no further than the paragraph's minimum word and
letter spacing and glyph scaling: an overfull line stays overfull
instead of overlapping its glyphs, and an overfull single word shrinks
within the letter and glyph limits.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 04:18:49 -04:00
Jeremy Yao 736acffe5f
Desktop: pick the graphics backend before the window exists; DirectX 12 on Windows (#297)
* Desktop: pick the graphics backend before the window exists; DirectX 12 on Windows

On an AMD hybrid-graphics laptop (Radeon RX 6800M beside an integrated
Radeon, with two versions of amdvlk64.dll loaded into one process) the
app died a few seconds after launch with an access violation inside
AMD's Vulkan driver at the first swapchain present. Nothing reached the
log: a fault inside a driver is a native crash the engine's panic guard
never sees. wgpu's default backend set (PRIMARY | GL) enumerates Vulkan
first, so every start on that machine went down the same path, while
WGPU_BACKEND=dx12 and WGPU_BACKEND=gl ran fine and the headless CPU
renderer was never affected.

The desktop app now chooses the backend before creating the window
(apps/designcraft/src/gpu.rs): DirectX 12 first on Windows, then Vulkan,
then OpenGL; Vulkan then OpenGL on Linux; Metal on macOS. OpenGL stays
in the set as the in-process fallback when the chosen backend has no
adapter. WGPU_BACKEND still overrides everything and turns the fallback
off.

Start-up fallback: gpu.json in the settings directory records the
backend being tried until the third frame begins (the first has been
presented by then) or the app exits normally. A record left behind means
that backend never showed a frame, so the next start skips it, tries the
next candidate and says so in the status bar. The list starts over when
every candidate has failed or the app version changes;
DESIGNCRAFT_NO_PREFS records nothing. The adapter, backend and driver
version are logged at start-up for bug reports.

Verified on the affected machine: a default start runs on DirectX 12; a
simulated crashed DX12 start moved to Vulkan, which faulted for real,
and the start after that came up on OpenGL with the status line. Nine
unit tests cover the chooser and the record; cargo xtask ci passes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Merge origin/main; on Windows OpenGL isn't added to the DX12 or Vulkan instance (#167)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 04:11:47 -04:00
aiongg 70974afc66
Lists: numbering formats and expressions from IDML; an empty paragraph gets no bullet or number (#298)
* Lists: numbering formats from IDML (A, B, C; i, ii, iii; 01, 02 …)

A paragraph's numbering format, number expression, bullet character and
text after the bullet import from IDML and export back. Numbered labels
come from the expression ("^#.^t", "Tabel ^#^t") with its
metacharacters expanded, in place of a fixed "n." label. Adds 001 and
0001 number styles, keeps letter and roman labels short for huge
numbers, and counts saturate instead of overflowing.

* Lists: an empty paragraph gets no bullet or number

A list paragraph with no characters before its return draws no label,
and numbering carries on past it: in a story's own list, in named lists
(also across stories) and in cross-reference paragraph numbers.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 04:08:56 -04:00
aiongg 0b69d6856c
PDF: a placed image whose file is missing is reported as missing (#299)
A linked image that wasn't found on open has no data; the export warning said it could not be decoded. It now says it is missing and names the link.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 04:01:49 -04:00
Brandon Thomas 37803c8176
PDF: reuse image conversions and index page objects (#176)
Co-authored-by: skylar taylor-barrick <hi@skylar.cc>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 04:00:40 -04:00
skylar taylor-barrick 1bccd0c9e4
Keep menus and dialogs usable at larger UI scales (#281)
* ci: simplify testkit dependency match for Clippy

* UI: keep menus and dialogs reachable at larger scales

* UI: share viewport-aware scrolling across menus

Incorporate @aiongg's shared menu-button, anchor-aware sizing, and
reopen-reset work from #279 (717ddb6), retaining #281's clipped-submenu
and scrollbar interaction fixes.

Use the wrapper for workspace, zoom, and panel menus as well as the
menu bar. Preserve checkbox dismissal and cover anchor placement,
scroll reset, and very short viewports with regression tests.

---------

Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 03:53:23 -04:00
190 changed files with 26211 additions and 2411 deletions

View File

@ -23,38 +23,7 @@ permissions:
jobs:
freebsd:
name: FreeBSD 14 (build + test)
runs-on: ubuntu-latest
timeout-minutes: 300
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# The Japanese font tests run against craft-fonts (an optional build input; they skip without it).
- name: Check out craft-fonts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- name: Build and test in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
copyback: false
prepare: |
pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
curl -fsSL -o /tmp/rustup-init \
https://static.rust-lang.org/rustup/archive/1.28.2/x86_64-unknown-freebsd/rustup-init
test "$(sha256 -q /tmp/rustup-init)" = "4d9fef2e40731489f3186c61a0f178d54d79864fe3d34791edf5b17f70074956"
chmod 700 /tmp/rustup-init
/tmp/rustup-init -y --profile minimal
run: |
. "$HOME/.cargo/env"
# The checkout is synced in under another owner; let tests that call git read it.
git config --global --add safe.directory "*"
rustc --version
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
CRAFT_FONTS_DIR="$PWD/craft-fonts" CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695

View File

@ -19,54 +19,5 @@ concurrency:
jobs:
lint:
runs-on: ubuntu-latest
env:
ACTIONLINT_VERSION: 1.7.12
ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
archive="$RUNNER_TEMP/actionlint.tar.gz"
curl -fsSL -o "$archive" \
"https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
printf '%s %s\n' "$ACTIONLINT_SHA256" "$archive" | sha256sum -c -
tar -xzf "$archive" -C "$RUNNER_TEMP" actionlint
- name: actionlint
run: |
"$RUNNER_TEMP/actionlint" -color
- name: shellcheck
run: shellcheck packaging/*.sh packaging/*/*.sh
- name: PowerShell
shell: pwsh
run: |
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
$errors = $null
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
Write-Output "ok $($f.Name)"
}
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
xmllint --noout packaging/windows/designcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
if grep -oE '<Icon Id="[^"]*"' packaging/windows/designcraft.wxs | grep -vE '\.(ico|exe)"$'; then
echo "::error::Icon Id must end in .ico or .exe"; exit 1
fi
desktop-file-validate packaging/linux/ai.storyteller.designcraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.designcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.designcraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.designcraft.metainfo.xml"
python3 - <<'EOF'
import yaml
src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.designcraft.yml"))
bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml"))
for m in (src, bundle):
assert m["id"] == "ai.storyteller.designcraft" and m["command"] == "designcraft", m["id"]
for key in ("runtime", "runtime-version", "sdk", "finish-args"):
assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
print("Flatpak manifests ok")
EOF
- name: FreeBSD package layout (dry run)
run: bash packaging/freebsd/package.sh --dry-run
# 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/packaging-lint.yml@main

View File

@ -1,12 +1,3 @@
# Release pipeline: every push to `release` builds signed installers for every platform and
# creates or updates the draft GitHub Release "DesignCraft v<version>". See docs/releasing.md.
#
# The signing jobs (macOS, Windows) and the draft-release job run in the `release` environment
# (restricted to the `release` branch), which holds the signing secrets. Every secret is
# optional: missing ones produce unsigned artifacts and a warning, never a failed build. The jobs
# that sign nothing (version, Linux, Flatpak, FreeBSD, web) get no environment and no secrets,
# so a `workflow_dispatch` on another branch dry-runs them; the signing jobs are refused there,
# which skips the draft release.
name: Release
on:
@ -20,420 +11,33 @@ on:
default: ""
permissions:
contents: read
contents: write
concurrency:
group: release-${{ github.ref }}
# Never cancel a run mid-notarization or mid-upload; a newer push waits its turn.
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: 1
DESIGNCRAFT_BUILD_SHA: ${{ github.sha }}
INPUT_VERSION: ${{ inputs.version }}
jobs:
version:
name: version
runs-on: ubuntu-latest
# No `environment: release`: no secrets needed, and branch dry runs need this job.
outputs:
version: ${{ steps.v.outputs.version }}
date: ${{ steps.v.outputs.date }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- id: v
name: Resolve version
run: |
. packaging/env.sh
v="${INPUT_VERSION#v}"
v="${v:-$VERSION}"
if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1"
exit 1
fi
case "$v" in *-*) pre=true ;; *) pre=false ;; esac
{
echo "version=$v"
echo "date=$(date -u +%Y-%m-%d)"
echo "prerelease=$pre"
} >>"$GITHUB_OUTPUT"
echo "### DesignCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
macos:
name: macOS universal
needs: version
runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
environment: release
env:
DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }}
DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it.
- name: Check out craft-fonts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-macos-universal
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$DESIGNCRAFT_VERSION"
- name: Import signing certificate
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: packaging/macos/import-cert.sh
- name: Build, sign, notarize
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: packaging/macos/package.sh --arch universal
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-macos
path: dist/release/*
if-no-files-found: error
- name: Remove temporary keychain
if: always()
run: |
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
windows:
name: Windows ${{ matrix.arch }}
needs: version
runs-on: windows-latest
environment: release
strategy:
fail-fast: false
matrix:
include:
- { arch: x64, target: x86_64-pc-windows-msvc }
- { arch: x86, target: i686-pc-windows-msvc }
# Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above.
# .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
- { arch: arm64, target: aarch64-pc-windows-msvc }
env:
DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }}
DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it.
- name: Check out craft-fonts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-windows-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
shell: bash
run: cargo xtask version set "$DESIGNCRAFT_VERSION"
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
- name: Build, sign, package
shell: pwsh
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-windows-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
linux:
name: Linux ${{ matrix.arch }}
needs: version
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
# Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
runs-on: ${{ matrix.runner }}
# No `environment: release`: this job signs nothing, so it gets no release secrets.
strategy:
fail-fast: false
matrix:
include:
- arch: x86_64
runner: ubuntu-22.04
deb: amd64
nfpm_sha256: 3f1cf344bd0b57373ca55636a78c08b0491f7293d609a456a9ac3b0b150fda97
- arch: aarch64
runner: ubuntu-22.04-arm
deb: arm64
nfpm_sha256: 27419eb382695a7942be8ad52259f3ec1854fad001b3ae4baed34ce39a223b97
env:
DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }}
DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
NFPM_VERSION: 2.47.0
NFPM_SHA256: ${{ matrix.nfpm_sha256 }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it.
- name: Check out craft-fonts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- name: Install build and packaging tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
desktop-file-utils appstream python3-yaml zsync
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
printf '%s %s\n' "$NFPM_SHA256" "$RUNNER_TEMP/nfpm.deb" | sha256sum -c -
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-linux-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$DESIGNCRAFT_VERSION"
- name: Build and package (AppImage, deb, rpm, tar.gz)
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/linux/package.sh
- name: Check the packages
run: |
dpkg-deb --info dist/release/*.deb
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/designcraft$|metainfo|applications'
ldd target/release/designcraft
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.designcraft.yml')); assert m['id'] == 'ai.storyteller.designcraft'"
for appimage in dist/release/*.AppImage; do
chmod +x "$appimage"
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
# Update information is embedded, and the .zsync for delta updates sits beside it.
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
test -s "$appimage.zsync"
done
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
flatpak:
name: Flatpak ${{ matrix.arch }}
needs: [version, linux]
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
# build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships.
runs-on: ${{ matrix.runner }}
# No `environment: release`: this job signs nothing, so it gets no release secrets.
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-24.04 }
- { arch: aarch64, runner: ubuntu-24.04-arm }
env:
DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }}
DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
- name: Install flatpak-builder
run: |
sudo apt-get update
# librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's
# `appstreamcli compose`, which renders the scalable icon and fails without it.
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- name: Build the Flatpak bundle
run: packaging/linux/flatpak-bundle.sh "dist/linux/designcraft-$DESIGNCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-flatpak-${{ matrix.arch }}
path: dist/release/*.flatpak
if-no-files-found: error
freebsd:
name: FreeBSD x86_64
needs: version
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM, the same image and packages as
# .github/workflows/freebsd.yml.
runs-on: ubuntu-latest
timeout-minutes: 180
# No `environment: release`: this job signs nothing, so it gets no release secrets.
env:
DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }}
DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it.
- name: Check out craft-fonts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- name: Build and package in a FreeBSD VM (tar.gz)
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Passed into the VM by name; never templated into the script below.
envs: "DESIGNCRAFT_VERSION DESIGNCRAFT_BUILD_DATE DESIGNCRAFT_BUILD_SHA INPUT_VERSION"
# The FreeBSD CI packages, plus bash for the packaging script.
prepare: |
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
curl -fsSL -o /tmp/rustup-init \
https://static.rust-lang.org/rustup/archive/1.28.2/x86_64-unknown-freebsd/rustup-init
test "$(sha256 -q /tmp/rustup-init)" = "4d9fef2e40731489f3186c61a0f178d54d79864fe3d34791edf5b17f70074956"
chmod 700 /tmp/rustup-init
/tmp/rustup-init -y --profile minimal --default-toolchain stable
run: |
set -e
. "$HOME/.cargo/env"
git config --global --add safe.directory "$PWD"
# Build outside the checkout so only dist/ is copied back out of the VM (copying the
# target dir back fails on large builds). 4 jobs: more runs the VM out of memory.
export CARGO_TARGET_DIR=/var/tmp/designcraft-target CARGO_BUILD_JOBS=4
if [ -n "$INPUT_VERSION" ]; then cargo xtask version set "$DESIGNCRAFT_VERSION"; fi
# craft-fonts was checked out next to the sources and synced into the VM with them.
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
bash packaging/freebsd/package.sh
tar -tzf dist/release/designcraft-*-freebsd-x86_64.tar.gz \
| grep -E '/bin/designcraft$|/bin/designcraft-cli$|/share/applications/ai\.storyteller\.designcraft\.desktop$|/share/doc/designcraft/LICENSE-MIT$'
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-freebsd-x86_64
path: dist/release/*
if-no-files-found: error
web:
name: Web (wasm)
needs: version
runs-on: ubuntu-latest
# No `environment: release`: this job signs nothing, so it gets no release secrets.
env:
DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }}
DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
TRUNK_VERSION: 0.21.14
TRUNK_SHA256: f2b4680cd239693a646a2795e4633c625328d7b2a044fbe749fa3a2fe9e7036b
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it.
- name: Check out craft-fonts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-web
- name: Install trunk
run: |
archive="$RUNNER_TEMP/trunk.tar.gz"
curl -fsSL -o "$archive" \
"https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz"
printf '%s %s\n' "$TRUNK_SHA256" "$archive" | sha256sum -c -
tar -xzf "$archive" -C "$HOME/.cargo/bin" trunk
trunk --version
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$DESIGNCRAFT_VERSION"
- name: Build and zip
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/web/package.sh
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-web
path: dist/release/*
if-no-files-found: error
release:
name: Draft GitHub Release
needs: [version, macos, windows, linux, flatpak, freebsd, web]
runs-on: ubuntu-latest
environment: release
permissions:
contents: write
env:
VERSION: ${{ needs.version.outputs.version }}
PRERELEASE: ${{ needs.version.outputs.prerelease }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: release-*
path: artifacts
merge-multiple: true
- name: Checksums
working-directory: artifacts
run: |
sha256sum -- * | tee SHA256SUMS.txt
- name: Create or update the draft release
working-directory: artifacts
run: |
tag="v$VERSION"
title="DesignCraft v$VERSION"
pre=()
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
if [ "$draft" != "true" ]; then
echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release."
exit 1
fi
echo "Updating draft $tag: replacing its assets"
gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do
gh release delete-asset "$tag" "$asset" --yes
done
gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE"
gh release upload "$tag" --clobber -- *
else
echo "Creating draft $tag"
gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- *
fi
{
echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:"
echo
sed 's/^/ /' SHA256SUMS.txt
} >>"$GITHUB_STEP_SUMMARY"
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
app-name: designcraft
app-title: DesignCraft
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}

View File

@ -26,70 +26,10 @@ concurrency:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs:
package:
name: package (cross-compiled on x64)
runs-on: windows-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: aarch64-pc-windows-msvc
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: windows-arm64-package
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
- name: Build and package (unsigned)
shell: pwsh
env:
DIST: ${{ github.workspace }}\dist\arm64
run: ./packaging/windows/package.ps1 -Arch arm64
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: designcraft-windows-arm64
path: dist/arm64/*
if-no-files-found: error
retention-days: 7
install:
name: install and run on ARM64
needs: package
runs-on: windows-11-arm
timeout-minutes: 30
steps:
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: designcraft-windows-arm64
path: dist
- name: Install the MSI, check it, run it, uninstall it
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName
$p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" }
$dir = Join-Path $env:ProgramFiles 'DesignCraft'
foreach ($exe in 'designcraft.exe', 'designcraft-cli.exe') {
$path = Join-Path $dir $exe
if (-not (Test-Path $path)) { throw "$exe not installed in $dir" }
$bytes = [System.IO.File]::ReadAllBytes($path)
$machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4)
if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" }
Write-Output "ok $path (ARM64)"
}
# The installed CLI runs natively on ARM64.
$cli = Join-Path $dir 'designcraft-cli.exe'
$out = & $cli --version 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "designcraft-cli --version exited $LASTEXITCODE" }
Write-Output "ok designcraft-cli --version: $($out.Trim())"
$p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
if (Test-Path (Join-Path $dir 'designcraft.exe')) { throw 'uninstall left designcraft.exe behind' }
Write-Output 'ok uninstalled'
windows-arm64:
# 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/windows-arm64.yml@main
with:
app-name: designcraft
app-dir: DesignCraft

View File

@ -38,7 +38,7 @@ People trust DesignCraft with their layouts; a crash loses their work. **This ou
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk).
## Roadmap
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a milestone task lands.
`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It follows craftrules' [`standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md). Details: `docs/target-app-parity.md` (assessment), `docs/gaps.md` (ranked work list: pick from the top), `docs/roadmap.md` (milestones, current focus), `docs/parity-checklist.md` (presence checklist, `cargo xtask parity`), and the typography, file-format, UI, hardware and localization parity docs in `docs/`. When work lands, update the affected docs and their status lines, delete or shrink the closed gap, and add a progress-log line to ROADMAP.md.
## Contributor credits (About window)

View File

@ -38,4 +38,4 @@ People trust DesignCraft with their layouts; a crash loses their work. **This ou
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk).
## Roadmap
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a milestone task lands.
`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It follows craftrules' [`standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md). Details: `docs/target-app-parity.md` (assessment), `docs/gaps.md` (ranked work list: pick from the top), `docs/roadmap.md` (milestones, current focus), `docs/parity-checklist.md` (presence checklist, `cargo xtask parity`), and the typography, file-format, UI, hardware and localization parity docs in `docs/`. When work lands, update the affected docs and their status lines, delete or shrink the closed gap, and add a progress-log line to ROADMAP.md.

101
Cargo.lock generated
View File

@ -635,6 +635,15 @@ dependencies = [
"wayland-client",
]
[[package]]
name = "caseless"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8"
dependencies = [
"unicode-normalization",
]
[[package]]
name = "cast"
version = "0.3.0"
@ -787,6 +796,23 @@ dependencies = [
"libm",
]
[[package]]
name = "craft-print"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=d991bf6792f8ade99c51358bf3c1d902c8cd96b5#d991bf6792f8ade99c51358bf3c1d902c8cd96b5"
dependencies = [
"tempfile",
]
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
dependencies = [
"egui",
"serde",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@ -861,8 +887,9 @@ dependencies = [
[[package]]
name = "designcraft"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"arboard",
"designcraft-engine",
"designcraft-ui-egui",
"eframe",
@ -870,6 +897,7 @@ dependencies = [
"log",
"muda",
"rfd",
"serde",
"serde_json",
"winit",
"winresource",
@ -877,7 +905,7 @@ dependencies = [
[[package]]
name = "designcraft-cli"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"designcraft-color",
"designcraft-compose",
@ -893,7 +921,7 @@ dependencies = [
[[package]]
name = "designcraft-color"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"kurbo",
"moxcms",
@ -905,7 +933,7 @@ dependencies = [
[[package]]
name = "designcraft-compose"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"designcraft-color",
"designcraft-doc",
@ -918,11 +946,12 @@ dependencies = [
"regex",
"unicode-bidi",
"unicode-bidi-mirroring",
"unicode-segmentation",
]
[[package]]
name = "designcraft-doc"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"designcraft-color",
"designcraft-geom",
@ -934,8 +963,10 @@ dependencies = [
[[package]]
name = "designcraft-engine"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"caseless",
"craft-print",
"designcraft-color",
"designcraft-compose",
"designcraft-doc",
@ -960,13 +991,14 @@ dependencies = [
"serde_json",
"tempfile",
"thiserror 2.0.21",
"unicode-segmentation",
"wasm-bindgen-test",
"zip",
]
[[package]]
name = "designcraft-epub"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"designcraft-color",
"designcraft-doc",
@ -975,7 +1007,7 @@ dependencies = [
[[package]]
name = "designcraft-fonts"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"harfrust 0.13.3",
"kurbo",
@ -987,7 +1019,7 @@ dependencies = [
[[package]]
name = "designcraft-format"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"designcraft-doc",
"serde",
@ -998,7 +1030,7 @@ dependencies = [
[[package]]
name = "designcraft-geom"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"flo_curves",
"kurbo",
@ -1011,7 +1043,7 @@ dependencies = [
[[package]]
name = "designcraft-idml"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"designcraft-color",
"designcraft-compose",
@ -1024,7 +1056,7 @@ dependencies = [
[[package]]
name = "designcraft-images"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"designcraft-color",
"designcraft-fonts",
@ -1035,7 +1067,7 @@ dependencies = [
[[package]]
name = "designcraft-mcp"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"designcraft-engine",
"designcraft-geom",
@ -1048,7 +1080,7 @@ dependencies = [
[[package]]
name = "designcraft-pdf"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"designcraft-color",
"designcraft-compose",
@ -1070,7 +1102,7 @@ dependencies = [
[[package]]
name = "designcraft-render"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"designcraft-color",
"designcraft-compose",
@ -1088,7 +1120,7 @@ dependencies = [
[[package]]
name = "designcraft-textimport"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"designcraft-doc",
"quick-xml 0.38.4",
@ -1097,7 +1129,7 @@ dependencies = [
[[package]]
name = "designcraft-tools"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"designcraft-color",
"designcraft-compose",
@ -1109,8 +1141,9 @@ dependencies = [
[[package]]
name = "designcraft-ui-egui"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"craft-ui",
"designcraft-color",
"designcraft-compose",
"designcraft-doc",
@ -1126,15 +1159,17 @@ dependencies = [
"regex",
"serde",
"serde_json",
"tempfile",
"toml",
"unicode-bidi",
"unicode-bidi-mirroring",
"unicode-segmentation",
"zip",
]
[[package]]
name = "designcraft-web"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"designcraft-engine",
"designcraft-format",
@ -1451,7 +1486,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -2625,7 +2660,7 @@ dependencies = [
"once_cell",
"png",
"thiserror 2.0.21",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@ -2708,7 +2743,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -3817,7 +3852,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -4232,7 +4267,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -4467,7 +4502,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@ -4506,6 +4541,15 @@ version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "unicode-normalization"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-properties"
version = "0.1.4"
@ -5195,7 +5239,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -5476,8 +5520,7 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winit"
version = "0.30.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d"
source = "git+https://github.com/skylartaylor/winit.git?rev=3d51077fd1a966f84df9ef287839dc84fd5b4ca7#3d51077fd1a966f84df9ef287839dc84fd5b4ca7"
dependencies = [
"ahash",
"android-activity",
@ -5645,7 +5688,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"serde_json",
]

View File

@ -4,13 +4,14 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"]
[workspace.package]
version = "0.5.0"
version = "0.7.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
rust-version = "1.95"
repository = "https://github.com/storytold/designcraft"
[workspace.dependencies]
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" }
# internal
designcraft-geom = { path = "crates/geom" }
designcraft-color = { path = "crates/color" }
@ -42,6 +43,7 @@ unicode-bidi = "0.3"
unicode-script = "0.5"
unicode-bidi-mirroring = "0.4"
unicode-segmentation = "1"
caseless = "0.2"
regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] }
tempfile = "3"
@ -66,6 +68,8 @@ quick-xml = "0.38"
zip = { version = "3", default-features = false, features = ["deflate"] }
# 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 }
@ -93,3 +97,7 @@ opt-level = 3
lto = "thin"
codegen-units = 16
debug = 0
# Opt-in cancelable macOS termination on the pinned winit 0.30 line.
[patch.crates-io]
winit = { git = "https://github.com/skylartaylor/winit.git", rev = "3d51077fd1a966f84df9ef287839dc84fd5b4ca7" }

20
NOTICE
View File

@ -32,6 +32,26 @@ list (author, source, license) is in ASSETS.md.
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.
- Portuguese hyphenation patterns, embedded through the hypher crate (feature "portuguese"):
hyph-pt.tex 1.4 from hyph-utf8, https://github.com/hyphenation/tex-hyphen.
Copyright (C) 1987, 1994, 1996, 2015 Pedro J. de Rezende, 1996, 2015 J. Joao Dias Almeida,
2024 Leonardo Araujo and Aline Benevides. BSD 3-clause licence:
Redistribution and use in source and binary forms, with or without modification, are permitted
provided that the following conditions are met: * Redistributions of source code must retain the
above copyright notice, this list of conditions and the following disclaimer. * Redistributions in
binary form must reproduce the above copyright notice, this list of conditions and the following
disclaimer in the documentation and/or other materials provided with the distribution. * Neither
the name of the University of Campinas, of the University of Minho nor the names of its
contributors may be used to endorse or promote products derived from this software without
specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO
EVENT SHALL PEDRO J. DE REZENDE OR J.JOAO DIAS ALMEIDA BE LIABLE FOR ANY DIRECT, INDIRECT,
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
- Japanese fonts (craft-fonts): builds made with craft-fonts (CRAFT_FONTS_DIR; all official
releases) embed the fonts listed in
https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic, BIZ UDMincho,

View File

@ -98,12 +98,15 @@ by its own renderer. Try it yourself with `File → New → Sample Document`, or
## Quick start
The egui frontend requires Rust 1.95 or newer (including its shared `craft-ui` components).
```sh
cargo run --release -p designcraft # desktop app (start screen)
cargo run --release -p designcraft -- --sample # open the sample magazine
cargo run --release -p designcraft -- --sample --control 7979 # + JSON control channel
cargo run --release -p designcraft-cli -- run --sample --all-pages out/ # headless: render every page to PNG
cargo run --release -p designcraft-cli -- commands # list every command
cargo run --release -p designcraft-cli -- script --help # command script syntax (docs/agents.md)
cargo xtask ci # fmt, clippy, tests, assets, layering, wasm
```
@ -137,9 +140,24 @@ The file stops growing at 16 MiB. `--version` writes no file.
| Variable | Effect |
|---|---|
| `RUST_LOG` | Log levels for standard error and the log file. Default: `info` for DesignCraft's own crates, `warn` for everything else. env_logger-style directives replace that, e.g. `RUST_LOG=debug`, `RUST_LOG=warn,designcraft_render=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`designcraft*=debug`). |
| `WGPU_BACKEND` | The graphics backend that composites the window: `dx12`, `vulkan`, `gl` or `metal` (a comma-separated list lets wgpu choose among them). Default: DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, with OpenGL as the fallback. Setting it also turns off the start-up fallback described below. |
The logger is `apps/designcraft/src/logging.rs`; the web build logs to the browser console instead.
### Graphics backend
The canvas is rendered on the CPU; the GPU only composites the interface, through wgpu. A
graphics driver that faults takes the process down before any Rust code can catch it, so the
backend is chosen before the window exists, and a start that never showed a frame is remembered:
`gpu.json` in the settings directory records the backend being tried until the first frame has
been presented. A record left behind means that backend crashed (or the app was killed) at
start-up, so the next start tries the next one (DirectX 12, Vulkan, then OpenGL on Windows;
Vulkan, then OpenGL on Linux) and says so in the status bar. The list starts over once every
backend has failed, or with a new DesignCraft version; delete the file or set `WGPU_BACKEND` to
start afresh. Seen in the wild: on an AMD hybrid-graphics laptop (a Radeon RX 6800M beside an
integrated Radeon) the AMD Vulkan driver faulted at the first present, which is why DirectX 12 is
the Windows default. The code is `apps/designcraft/src/gpu.rs`.
### Web
```sh
@ -168,7 +186,7 @@ egui frontend is a separate crate, so the UI can be swapped without touching the
| L6 | `ui-egui` (InDesign-style UI, control channel) |
| L7 | `apps/designcraft`, `apps/designcraft-cli`, `apps/designcraft-web` |
- **Status and milestones:** [ROADMAP.md](ROADMAP.md)
- **Status and milestones:** [ROADMAP.md](ROADMAP.md) (stage: pre-alpha); how close to InDesign: [`docs/target-app-parity.md`](docs/target-app-parity.md); known gaps: [`docs/gaps.md`](docs/gaps.md)
- **Contributor and agent rules** (clean-room, asset policy, quality gates): [`AGENTS.md`](AGENTS.md)
- **Bundled assets:** every one is listed with its licence in [`ASSETS.md`](ASSETS.md)
- **App icon and colour:** a calico cat in a polka-dot scarf on DesignCraft green `#7bb51c`; see [`assets/app-icon/`](assets/app-icon/README.md)
@ -206,6 +224,8 @@ Installers and executables are code-signed.
| Fedora/RHEL/openSUSE | `designcraft-<ver>-linux-x86_64.rpm` | `designcraft-<ver>-linux-aarch64.rpm` | |
| Tarball | `designcraft-<ver>-linux-x86_64.tar.gz` | `designcraft-<ver>-linux-aarch64.tar.gz` | Unpack anywhere |
RISC-V (riscv64): `designcraft-<ver>-linux-riscv64.tar.gz` only, cross-compiled; needs glibc 2.39+ (Ubuntu 24.04 or newer).
### FreeBSD
| Build | File |

View File

@ -1,227 +1,210 @@
# DesignCraft roadmap
- FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Linear/radial, midpoint, archival and print policies have synthetic regression coverage.
**Stage: pre-alpha** · next: alpha, ~5–7 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%)
- AR1.1: Kashida justification tests verify space fallback without Tatweel fonts using an isolated font database, while retaining elongation assertions when fonts support it.
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text threads by hand; the alpha gate waits on Primary Text Frame page fixes) · **Target:** Adobe InDesign 2026
- AR1: Arabic IDML controls now retain character direction, kashida switches, diacritic offsets/forms, paragraph policy values and independent story/table directions. Shaping preserves joining context and script/language selection; paragraph-level bidi, contextual digit conversion, mark-safe elongation, RTL columns/tables and physical column insertion have regression coverage. Vendor-specific justification and diacritic presets remain explicit preflight warnings; see docs/arabic-typography.md.
- CJK1: IDML composite fonts now retain character mappings and metrics; explicit aki, tsume, jidori, em alignment/leading, named/custom kinsoku boundaries, hanging punctuation, tate-chu-yoko offsets and kenten symbols flow through composition and IDML export. Automatic kerning no longer imports inactive numeric values as enormous manual spacing. Composite definitions are editable through commands with undo/cache invalidation. Mojikumi definitions are retained and unsupported spacing-table / push-in-push-out policies are reported by preflight. Full InDesign CJK composition parity, frame-grid layout and detailed ruby remain unfinished. Warichu sets the selected run in smaller lines inside the line after the line is broken.
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.
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.
## Headline numbers
## Status (2026-10-01)
**Working today**
- Document model: facing/non-facing spreads, parent pages with page-number markers, sections, layers, frames (text / graphic / unassigned), threaded stories, paragraph/character/object styles with based-on, swatches (process/spot/tints/gradients), corner options, text wrap, drop shadow, opacity/blend.
- Text engine: Knuth–Plass paragraph composer and single-line composer, dictionary hyphenation (public-domain Moby list + our own trained Liang patterns), justification with word/letter/glyph-scaling ranges, keep options (keep with next, keep lines together, widows/orphans), balance ragged lines, optical margin alignment, hyphenation zone/limit, columns, threading across frames and spreads, tabs, bullets/numbering, rules, shading, baseline grid, first-baseline options, vertical justification, wrap exclusions, overset detection, caret/hit testing.
- FID2: IDML underline/strikethrough Text Color resets survive style inheritance and roundtrip. Raster and PDF paint underlines behind glyphs, retain strikethrough above, and center rules on their offsets; synthetic overlap tests cover both backends.
- Rendering: vello_cpu (SIMD, multithreaded) — pages, items, images with mip levels, gradients, composed text.
- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts, document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches, Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes.
- Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows.
- Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom.
- PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`.
- IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`).
- Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu, Table panel, Create Table dialog, caret/typing/Tab navigation and cell selection in cells; `table.*` commands.
- Hyperlinks (text or frames → URL / e-mail / page) and bookmarks, exported as PDF link annotations and outline.
- EPUB 3 (reflowable) export: stories in reading order, CSS from paragraph/character styles, images, navigation (`file.exportEpub`, CLI `--export x.epub`).
- Data Merge (one linked CSV, TSV, text, or Excel file; text, image, QR, and hyperlink placeholders; preview; a new document with one record per page or records tiled on a single page), spell checking (public-domain Moby list + document dictionary, suggestions), Step and Repeat (count or grid), snippets, object styles, Numbering & Section Options.
- Footnotes: Type › Insert Footnote (caret moves into the note), Document Footnote Options (numbering style incl. symbols, start/restart per page/spread/section, prefix/suffix, reference position/character style, paragraph style, separator, spacing, first baseline, rule above); notes composed at the bottom of the referencing column with the body text making room; edited in place on the canvas; rendered, exported to PDF (real text) and IDML (InDesign's structure, verified opening in InDesign 2026) and imported from IDML; `footnote.*` commands.
- Paragraph Rules: Rule Above / Rule Below (on, weight, colour, tint, column or text width, offset, left/right indent) set in Type › Paragraph Rules…, the Paragraph panel menu and Paragraph Style Options › Paragraph Rules; inherited style values shown, and `type.para` / `style.paragraph.edit` change only the rule fields named.
- Cross-references and text anchors: Insert Cross-Reference (paragraph by style, or text anchor), 11 formats with InDesign's building blocks (full/partial paragraph, paragraph text/number, page number, anchor name, chapter, file name) plus user formats; resolved live at composition — never out of date, no Update step; unresolved destinations show `??`; PDF link annotations to the destination page; IDML export/import as InDesign cross-reference sources, hyperlinks, text destinations and formats (verified in InDesign 2026); `xref.*` / `anchor.*` commands.
- Index: page references (topic from the selection or up to 4 levels, sort keys; current page, to end of story, next n paragraphs, suppressed, See / See also), Generate / Update Index (section headings, nested or run-in, page ranges merged, Index Title / Section Head / Level 1–4 styles); IDML export/import as InDesign topics, page references and topic cross-references (verified in InDesign 2026); `index.*` commands.
- Text clipboard keeps formatting (character/paragraph formats, footnotes, cross-references, index markers, tables) and falls back to plain text when the system clipboard changed; Paste without Formatting (⇧⌘V); Change Case (UPPERCASE, lowercase, Title Case, Sentence case — formatting kept); Type › Insert Special Character / White Space / Break Character submenus.
- Anchored objects: items flow in the text inline (on the baseline, with Y offset; lines grow to fit) or above the line (left/center/right, space before/after); paste copied items into text to anchor them; Anchored Object Options and Release (back onto the page where shown); rendered, exported to PDF and to/from IDML (InDesign anchored object settings; verified in InDesign 2026); copy/paste and threading carry them; `anchored.*` commands.
- Ruler guides: drag out of the rulers (page guides, or spread guides on the pasteboard), drag to move, drop on a ruler to delete; Layout › Create Guides (rows/columns with gutters, fit to margins or page); Delete All Guides on Spread; `guide.*` commands.
- Links panel: every placed graphic with status (OK / Modified / Missing / Embedded, checked against the file on disk), page and effective PPI (low resolution flagged); Relink…, Go To, Update, Embed; `links.*` commands.
- Missing fonts: highlighted pink on screen (never in output), listed by Preflight; Type › Find/Replace Font (fonts in the document, missing first; replace in text, cells, footnotes and styles); `font.list` / `font.replace`.
- Missing glyphs, as in InDesign: a character the applied font lacks is drawn as that font's missing-glyph box (its .notdef; a drawn box when the font's is empty), on screen and in PDF (as a path, so PDF/A and PDF/UA exports take it), and Preflight lists the characters by font. Preferences › Composition › Draw Missing Glyphs from Fallback Fonts (`document.preferences {glyphFallback}`, stored with the document) draws them from other fonts instead; it is off in new documents and IDML imports, on in documents saved before it existed. A missing font's substitute always uses fallback fonts.
- Damage-region repaint: after an edit the canvas re-renders only the frames whose composed text (or the items that) changed and patches its texture — pixel-identical to a full render; typing in a frame repaints in under a millisecond instead of a full-viewport render (22 ms measured on the sample).
- Crash recovery: unsaved documents are written to the recovery folder every 30 s and reopened (unsaved, remembering their files) after a crash; File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`.
- Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool).
- Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`.
- Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`).
- Gradient Feather (⇧G tool, `object.gradientFeather`): objects fade along a linear or radial opacity gradient, on screen and as a soft mask in PDF.
- Script Label (`object.label`, `object.findByLabel`), alt text (`object.altText`), Isolate Blending / Knockout Group (`object.transparencyGroup`; on screen, PDF isolates), Table ▸ Sort (`table.sortRows`, numeric-aware, header/footer fixed).
- File ▸ Export Text (`file.exportText`): a story as Text Only or RTF (fonts, styles, alignment, indents, tables as tab-separated rows); Export EPUB now asks for a path in the UI.
- Tagged PDF (`file.exportPdf {tagged}`, on by default from the UI): structure tree with stories as paragraphs in reading order, figures carrying their alt text, parent-page items and printer's marks as artifacts.
- Global Light (`object.globalLight`, `globalLight` on drop/inner shadows; Use Global Light in the Effects panel) and Generate Static Caption (`object.caption`: template over name, path, alt text, label, effective ppi, dimensions, format; any side, offset, style).
- Control panel scale X/Y, rotation and shear fields (`transform.info`, absolute `transform.set {scaleX, scaleY, rotation, shear}` about the reference point) and Preferences › Transformations are Totals for nested objects and placed graphics.
- Variable fonts: each named instance is a style (Font menu, shaping with feature variations, metrics, outlines) and embeds at its axis settings in PDF; free axis sliders still to come.
- Object ▸ Select (first/next above, next/last below, container, content, previous/next in group), full Object ▸ Convert Shape (`object.convertShape`), Table ▸ Split Cell Horizontally/Vertically.
- Attributes panel (`object.attributes`: Overprint Fill/Stroke/Gap, Nonprinting), with Overprint Gap in Overprint Preview and IDML.
- Page Numbering View (Preferences: section or absolute): page labels everywhere follow it; Go to Page takes section names, numbers or `+n`.
- Preferences › Advanced Type (document `advancedType`: superscript/subscript size and position; IDML TextPreference); subscripts now drop 33.3% like InDesign.
- Preferences › Composition › Highlight: keep violations, H&J violations (three shades), custom tracking/kerning, substituted fonts.
- File ▸ Export HTML (`file.exportHtml`): one self-contained page in reading order, styles as CSS, images inline, alt text from Object Export Options (also in EPUB).
- Swatches panel colour groups (`swatch.newColorGroup`, `moveToGroup`, `ungroupColorGroup`, `renameColorGroup`; folders and a row context menu; IDML ColorGroup).
- Object ▸ Generate QR Code (`object.qrCode`: web link, text, SMS, email, business card; vector modules as a compound path, into a frame or re-encoded in place).
- Conditional text (`condition.new/options/delete/apply/list`, Conditional Text panel): hidden conditions drop their text from layout and exports, indicators underline on screen, IDML Condition/AppliedConditions.
- Endnotes (`endnote.insert/edit/delete/list/options`, Type ▸ Insert Endnote): references number through the document, the endnote frame on a new last page lists them under a heading; IDML keeps the listed text only.
- Editorial notes (`note.new/edit/delete/convertToText/list`, Notes panel): anchored in text, flagged on screen, never printed or exported.
- File ▸ Print Booklet (`file.printBooklet`): saddle-stitch or 2-up consecutive printer spreads as PDF, padded with blanks, with a gap between pages.
- Edit ▸ Transparency Blend Space (`edit.transparencyBlendSpace`; RGB for web/mobile documents; IDML TransparencyPreference) — exported PDFs give pages with transparency a DeviceCMYK or DeviceRGB page group, so viewers and RIPs blend in that space. On screen, spreads with transparency in a CMYK blend space are shown through the working CMYK profile (their colours keep to its gamut, as when composited in CMYK).
- High Contrast interface theme (fifth Interface Color Theme: black chrome, white text, yellow selection).
- Place Excel workbooks (.xlsx): the first worksheet's used range becomes a table (shared/inline strings, numbers, booleans).
- Object Library (`library.new/open/add/place/remove/list/json/close`, Library panel): `.dclib` files of snippets that keep stories, styles, swatches and images.
- Ink Manager (`ink.list`, `ink.options`; Swatches panel menu): All Spots to Process, per-ink conversion and ink aliases in PDF output; IDML ConvertToProcess/AliasInkName.
- Appearance of Black complete: Printing/Exporting rich black for RGB output (PNG), Overprint [Black] Swatch at 100% (document, IDML OverprintBlack).
- Hyperlinks and Bookmarks panels (Window ▸ Interactive): new from the selection, edit (`hyperlink.edit`), go to source (`hyperlink.goToSource`), rename bookmarks (`bookmark.rename`), go to page, delete.
- Nested line styles (`nestedLineStyles` paragraph attribute, Paragraph Style Options › Drop Caps and Nested Styles, IDML AllNestedLineStyles): the first lines restyle until the line breaks settle.
- User dictionary hyphenation exceptions (`hyphenation.addException/removeException/list`, Edit ▸ Spelling ▸ User Dictionary): `ex~am~ple` breaks only at `~`, a plain word never hyphenates.
- Frame Fitting Options (`object.fittingOptions`, Object ▸ Fitting ▸ Frame Fitting Options…): Auto-Fit refits on resize, fitting, Align From reference point, crop amounts; IDML FrameFittingOption.
- Paragraph borders (per-side weights, colour, tint, offsets) and shading offsets, drawn per column part of split paragraphs (also when overset); Paragraph panel controls; IDML ParagraphBorder*/ParagraphShading*Offset.
- Font menu: search, favourites (stars, Show Favorites Only; `favoriteFonts` preference) and each family previewed in its own face. Western (and other-script) families come first, then Japanese, Simplified Chinese, Traditional Chinese and Korean ones, each group alphabetical and set off by a separator; the group comes from the font's own data (`meta` languages, family names in a CJK language, OS/2 code pages, else what it covers), read once (installed fonts by the font scan, without loading them). CJK families show their native names, the English name on hover; Preferences › Type › Show Font Names in English (`showFontNamesInEnglish`) shows English names throughout. Search matches either name; documents store the English name. Every family list (Character panel, Control bar, Properties, style dialogs, Find Font, Glyphs) follows this order.
- Application preferences (engine `Prefs`) now persist across launches (desktop: prefs.json beside ui.json).
- View ▸ Fit Selection in Window (⌥⌘=) and Find/Change ▸ Object (`find.objects`, `find.changeObjects`: by fill, stroke, weight, opacity, kind, layer, label).
- Custom workspaces (`window.newWorkspace/deleteWorkspace/resetWorkspace`; workspace switcher shows the current one): bars and panel arrangement saved by name.
- Word/RTF Import Options (File ▸ Place with Import Options…, `place.styles`, `file.place {styleMap, styleConflicts}`): map imported styles onto the document's, use existing / redefine / auto-rename on conflicts, or remove formatting.
- Custom anchored objects (`anchored.insert/options {position: custom}`): placed relative to the anchor, frame, column, page margins or page edge with reference points and offsets, no space in the text, keep within column; IDML Anchored position.
- Variable font axes: Character panel sliders; any axis values (`fontStyle: "Bold {wght:650}"`) make that instance for layout, screen and PDF.
- Image Import Options › Crop to for placed PDFs (`file.place {pdfCrop: crop|trim|bleed|art|media}`, Place PDF dialog): the frame shows that page box.
- Stroke styles: striped (thick-thin… and custom bands), wavy and straight-hash strokes now draw on screen and in PDF; named custom stripe/dash/dot styles (`strokeStyle.new/delete/list`, Stroke panel Type list; IDML Striped/Dashed/DottedStrokeStyle).
- Object ▸ Primary Text Frame (`object.primaryTextFrame`): any text frame's story can become (or stop being) the primary story Smart Text Reflow follows.
- Column rules (Text Frame Options › Column Rules, `object.textFrameOptions {columnRule, columnRuleWeight, columnRuleColor, columnRuleTint, columnRuleOffset, columnRuleTopInset, columnRuleBottomInset}`, reported by `document.inspect`): a bar centred in each gutter of a multi-column frame, inside the insets and between the columns of right-to-left and vertical frames too, broken around paragraphs that span columns, with weight, colour, tint, horizontal offset and top/bottom insets; on screen, in PDF (tagged as an artifact) and in IDML (`ColumnRule…` attributes of `TextFramePreference`, also in object styles). The dialog shows the frame's options and changes only the fields edited. Rule stroke types and overprint aren't there yet.
- Place a page of an IDML or DesignCraft document (`file.place {layoutPage}`): its objects, stories, styles and swatches as one group (INDD files are not readable).
- Articles panel (`article.new/add/remove/options/delete/list`): reading order and content of EPUB and HTML exports.
- Object ▸ Clipping Path (`object.clippingPath {type: alpha|edges, threshold, tolerance}`): traces the placed image and makes the outline the frame.
- Gridify: arrow keys while dragging a frame or shape tool add columns/rows (`frame.grid`), one undo step.
- Gap tool (U, `gap.move`): drag the space between objects (or an object and the page edge) and both sides resize.
- XML basics (`xml.newTag/deleteTag/tag/mapStyle/structure`, `file.exportXml`, `file.importXml`; Tags panel with structure; View ▸ Show Tagged Frames): tagged frames export in reading order, mapped paragraph styles become elements, imported elements flow into frames with matching tags. Inline text tagging (`xml.tagText`, an `xmlTag` character attribute, exported as nested elements) and tag markers (View ▸ Structure ▸ Show Tag Markers) too. DTDs (`xml.loadDtd`, `xml.validate`, `xml.deleteDtd`; Tags panel): loading one adds its elements as tags and names the root; validation checks content models (sequences, choices, `?*+`, mixed content, EMPTY/ANY, parameter entities) and required attributes, and lists each problem with its element path.
- Adjust Layout / Layout Adjustment (`adjustLayout` on Document Setup and Margins and Columns): objects follow each page's margin box to its new size and position.
- Content Collector and Placer tools (B; `conveyor.collect/place/list/clear`): copies of objects (with stories, styles, images) ride a conveyor between pages and documents; Alt-click keeps them on it.
- Linked stories (Edit ▸ Place and Link, `story.placeAndLink/links/updateLink/unlink`): child stories copy a parent, show out of date in the Links panel and update.
- Edit ▸ Menus (`window.hideMenuItem`): hide menu items; menus with hidden items end in Show All Menu Items.
- View ▸ Separations Preview (`view.separations`): one process plate as ink density, or areas over an ink limit in red (a GCR preview of the rendered view; spot plates and the flattener preview aren't there).
- Separations Preview plates now come from each object's own colour (CMYK as authored; RGB and images through the colour settings, images via a cached 17³ table), so pure black shows only on the black plate.
- Language (Character panel ▸ Language, Properties ▸ Character, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). Spanish is hyphenated with Javier Bezos's Liang patterns (`hyph-es.tex` 5.0, MIT) through the `hypher` crate; hyphenation for other languages isn't bundled yet.
- Books (`book.new/open/add/remove/list/styleSource/paginate/syncStyles/exportPdf`, Book panel): `.dcbook` files, continuous page numbering, style sync from a style source, one merged PDF.
- Track Changes (`changes.track/list/acceptAll/rejectAll`, Type ▸ Track Changes): typing is marked added (highlighted on screen), deletions are kept but hidden from layout and exports until accepted or rejected. Per-change accept/reject and authors aren't there yet.
- Track Changes: per-change Accept / Reject (`changes.accept`, `changes.reject`) and a Track Changes panel listing every change.
- Move table rows and columns (`table.moveRow`, `table.moveColumn`, Table panel buttons; with whole rows or columns selected, drag them with the Type tool — `table.dropCells`).
- Distribute Rows Evenly (`table.distributeRows`, Table menu and panel): selected rows receive equal fixed heights preserving their composed total height, with undo/redo. Row-spanning merges and rows outside the composed frames are rejected; shrinking tall cells can overset their text.
- Object States (`states.create/show/rename/release/list`, Object States panel): multi-state objects show one state on screen and in PDF.
- Buttons (`button.set/clear/list`, Buttons and Forms panel): go to page / next / previous / first / last / URL as PDF link annotations. Form fields too (`form.set/clear/list`: text fields, check boxes, combo and list boxes, signature fields; required, multiline, defaults) exported as AcroForm widgets through an incremental update (not in PDF/X).
- Power Zoom (Hand tool: press and hold still for half a second, or Alt-press): zoom out to the spread, aim the red view rectangle, release to zoom back in there.
- Graphic cells (`table.placeGraphic`, `table.textCell`, Table › Convert Cell Type, Table panel): images in table cells, fitted or filled, clipped to the cell on screen and in PDF.
- Live Distribute (Space while dragging a selection handle; `transform.resize {distribute}`): objects keep their size and spread with the bounds.
- Liquid Layout (`liquid.pageRule/object`, `guide.liquid`, `layout.createAlternate`, Liquid Layout panel): scale / re-center / guide-based / object-based page rules applied on page resize and Document Setup; dashed liquid guides; alternate layouts as resized page copies in a named section. Text in an alternate layout stays linked to the original stories (Links panel shows them out of date; `story.updateLink`).
- Split Window / New Window (`window.split`, `window.newWindow`, Window ▸ Arrange): two side-by-side views of the document with their own zoom, scroll and render cache. New Window puts the second view in its own OS window (an egui viewport; a floating window on the web).
- Transparency flattener (`file.exportPdf {flatten: high|medium|low|ppi}`, Edit ▸ Transparency Flattener Presets): each object involving transparency becomes an opaque image of its area over what's beneath it, so everything else stays vector (spreads with transparent parent items are rasterised whole).
- Video and sound (place .mp4/.mov/.webm/.mp3/.wav…, `media.options/get`, Media panel): media frames show a poster or placeholder on screen, in print and in PDF, and export as HTML5 `<video>`/`<audio>` in EPUB. Interactive PDF export (`file.exportPdf {media: true}`, File ▸ Export PDF (Interactive)) embeds each file and plays it in a Screen annotation through a rendition action (controls, loop, play on page load). No in-app playback.
- Right-to-left text (World-Ready basics): right-to-left runs are shaped right to left, lines are reordered by the Unicode Bidirectional Algorithm (matched against the reference implementation), Paragraph Direction (`type.para {direction}`, Paragraph panel, IDML ParagraphDirection, EPUB `dir`). Digits (`digits`: Default, Arabic, Hindi, Farsi, Native by language; Character panel for right-to-left text; IDML DigitsType) draw 0–9 in another script. Kashidas (`kashidas` paragraph attribute, on by default): justified Arabic lines stretch at one join per word (after seen/sad when possible, never inside lam–alef) with a drawn tatweel before word spaces widen. Carets follow the drawing: the caret before a right-to-left glyph sits at its right edge, arrow keys move as drawn (by words in the line's direction), clicks find the nearest drawn caret position and selections highlight the selected glyphs. Binding: Right to Left (Document Setup, `layout.documentSetup {binding}`, IDML PageBinding) makes page 1 a left page and lays spreads out right to left.
- Interface language (Edit ▸ Interface Language, `app.language`): German, French, Spanish, Italian, Japanese, Simplified Chinese and Arabic menu titles, common menu items and panel names (our own translations). Common dialog, panel and tooltip labels are also localized, with English fallback for untranslated strings. Arabic translations cover every key in the shared interface translation table, including menu commands and utility labels. The Type menu's Story Direction, Horizontal/Vertical, Tate-Chu-Yoko, Ruby… and Kenten are translated too. Arabic labels use Unicode bidi run ordering and contextual shaping; welcome and new-document form layouts follow RTL direction independently of document binding. CJK and Arabic glyphs come from [craft-fonts](https://github.com/storytold/craft-fonts) when built with `CRAFT_FONTS_DIR` (all releases): BIZ UDPGothic and Noto Sans CJK SC for the UI (Chinese forms first in the Chinese interface), Noto Sans Arabic, and Mincho for Japanese documents (the web build embeds BIZ UDPGothic only); otherwise from system fonts. Ukrainian covers all 1,430 existing catalog keys, including publishing dialogs and built-in names, with persisted selection, grammatical count captions, English fallback and bundled Cyrillic UI glyph coverage.
- Page transitions (`page.transition/transitions`, Page Transitions panel): twelve transition types with speed and direction per spread, written into exported PDFs as `/Trans` entries through an incremental update.
- PDF/X-4 export (`file.exportPdf {standard: "x4"}`): an output intent with our own Generic CMYK ICC profile (lut16 tables generated from the parametric press model), GTS_PDFXVersion in Info and XMP, `pdf:Trapped` in XMP as in Info, RGB images without a profile tagged sRGB, no image interpolation (none in PDF/A either), and built-in PDF/X-4 checks reported as warnings. qpdf finds no errors in the result.
- EPS place: placed at its (high-resolution) bounding box and shown, printed and exported through its TIFF preview, or a placeholder when it has none. PostScript itself isn't interpreted.
- Preferences: Dictionary (user dictionary; `spelling.setWords/words`), Spelling (Dynamic Spelling: red squiggles on the canvas, also Edit ▸ Spelling), Autocorrect (word list, applied as you type), Notes, Track Changes, Story Editor Display and File Handling (recovery interval) sections.
- Colour management exposed: Edit ▸ Color Settings (`color.settings`: RGB/CMYK working spaces, intent, black-point compensation; `color.loadProfile` for ICC files; `color.convert` through the working spaces, with gamut checks) and View ▸ Proof Setup / Proof Colors (press, sRGB, legacy Mac RGB, colour-blindness simulations, simulate paper) on the canvas.
- Keyboard: Tab / Shift+Tab hide and show all panels / all but Tools (`window.hidePanels`, `window.hidePanelsExceptTools`); Cmd+F6 / Cmd+Shift+F6 cycle documents; panel fields are reached with Tab and Escape returns to the layout.
- Smart guides and snapping (View ▸ Snap to Guides / Snap to Document Grid / Smart Guides, `view.snapPreferences` and Preferences ▸ Guides & Pasteboard): moving, drawing, resizing, rotating and pen/anchor drags snap in order to the document and baseline grids, ruler guides, margins and columns, other objects' edges and centres (parent-page items too, by visible stroked bounds), equal spacing, matching dimensions and matching rotation, with guides, gap marks and measurements drawn on the canvas. Shift and Cmd constrain the gesture.
- View ▸ Rotate Spread (`view.rotateSpread`, `layout.rotateSpreadView`): the spread in view turns in quarter turns on screen (stored with the document; output is unaffected). The canvas maps each spread through its own transform, so tools, snapping, resize handles, guides, overlays and rendering all follow the turned spread.
- Vertical type (Vertical Type Tool, Type ▸ Story Direction `type.storyDirection`, IDML StoryOrientation): the direction belongs to the story, so every frame of a thread is vertical or horizontal together and a frame threaded onto a story takes its direction (`object.textFrameOptions {vertical}` sets the frames' stories); lines run top to bottom and follow each other right to left; the arrow keys follow them (Down and Up along the line, Left and Right to the next and previous line, keeping the position along it); CJK characters stay upright and other text turns, on screen and in PDF; CJK line breaking with basic kinsoku (also in horizontal text); Korean breaks at spaces, word by word (Hangul in any language, hanja in Korean text), or between syllables when the paragraph asks for it (`type.para {koreanCharBreaks}`, Paragraph Style Options ▸ Justification; not written to IDML). Upright text is shaped top to bottom: punctuation and brackets take their vertical forms (OpenType `vert`; `vkrn` and `vpal` through OpenType features), glyphs advance by their vertical metrics (`vmtx`) and hang from their vertical origins (`VORG`) on the centre of the ideographic em box (`BASE`, else OS/2), which the first line's ascent follows; turned text keeps its horizontal forms. Tate-chu-yoko (Type ▸ Tate-Chu-Yoko, `type.tateChuYoko`, IDML `Tatechuyoko`) sets a run such as "12" side by side within one em of the line. Ruby (Type ▸ Ruby…, `type.ruby`, IDML RubyString; group ruby spread 1-2-1 or centred) and kenten emphasis dots (`type.kenten`, IDML KentenKind) are set over their text — to its right in vertical frames — without changing line breaks. Warichu and ruby options (per-character ruby, positions, overhang) aren't there yet.
- Math expressions (Type ▸ Insert Math Expression…, `math.insert`, `math.svg`): a LaTeX subset (fractions, scripts, roots, big operators with limits, Greek, relations and operators with math spacing, `\left…\right`, `\text`) typeset by our own engine into SVG, placed inline at the cursor or as a frame; the LaTeX is kept as alt text and can be edited.
- Object Layer Options for placed PDFs (`object.pdfLayers`, `object.layerOptions`, Object ▸ Object Layer Options…): the file's optional-content layers listed and shown or hidden per frame on screen; in exported PDFs such graphics go out as 300 ppi images of their visible layers.
- Effects: Inner Glow (edge or centre), Bevel and Emboss (inner bevel lit from the light angle), Satin (`object.innerGlow`, `object.bevel`, `object.satin`); PDF export now keeps soft effects: objects with shadows, glows, feathers, bevels or satin go out as 300 ppi transparent images of their appearance (threaded text frames excepted).
- Directional Feather (`object.directionalFeather {widths: [top, left, bottom, right]}`): each side fades over its own width.
- View ▸ Flattener Preview (`view.flattenerPreview`): objects involving transparency highlighted in red; knockout groups export as images (PDF output has no knockout groups).
- Live captions (`object.caption {live: true}`, Object ▸ Captions ▸ Generate Live Caption): the caption text is re-filled from the source object's metadata after every edit (same undo step).
- Style Export Tagging (`style.exportTag`, Paragraph Style Options ▸ Export Tagging): paragraph and character styles choose their HTML element and class in EPUB/HTML.
- Tagged Text: export (`file.exportText {format: "tagged"}`) and place (detected by its header; ASCII, UTF-8 or UTF-16) with paragraph and character styles, font, typeface and size, and `<0x…>` characters.
- Tagged PDF: one structure element per paragraph (H1–H6 and BlockQuote from Export Tagging, else P) in reading order, frame fills, strokes, rules and tables as artifacts, figures with alt text.
- EPUB: cover image from the first page (`file.exportEpub {cover}`), and Fixed Layout export (`file.exportFixedEpub`, File ▸ Export EPUB (Fixed Layout)…): pre-paginated pages as 144 ppi images with their text kept for search and read-aloud.
- Interactive PDF options (`file.exportPdf {fullScreen, bookmarksPanel, pageLayout, view, advanceSeconds}`, File ▸ Export PDF (Interactive)…): page mode, layout, opening view and page timing in the catalog and pages.
- Object Export Options (`object.exportOptions`): tagged-PDF artifact, EPUB/HTML rasterize (the object as an image), alignment and page break before, alt text.
- Scripts panel (Window ▸ Utilities ▸ Scripts, `script.run`): saved command scripts (with `$N.path` references to earlier results) run as one undo step; edit, add, load and delete them in the panel. Scripts use DesignCraft's command language, not ExtendScript.
- Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`).
- Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export.
- Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override.
- View › Overprint Preview (⌥⇧⌘Y): overprinting fills, strokes and 100% [Black] text mix with what's beneath on screen.
- Type on a Path (⇧T tool, `type.onPath`): a story along any line, curve or shape on screen and in PDF; Type on a Path Options (start, flip, baseline/ascender/descender/centre) and Delete Type from Path. Round-trips through IDML as TextPath; on-path caret drawing is still to come.
- Preferences › Appearance of Black: 100% K on screen accurately (dark grey) or as rich black (`window.richBlack`); the printing/export side is still to come.
- Spreads of up to 10 pages: Add to Previous Spread and Allow Spread to Shuffle (Pages panel; `layout.pages.toSpread`, `layout.spreadShuffle`); inserted pages go around kept spreads.
- Page tool (⇧P) and `layout.pageSize`: pages of their own size (or a preset); the spine stays put and objects keep their place on their page.
- File › Print (⌘P): printer, copies, page range, spreads, marks, bleed; printed as PDF through the system spooler (`file.print`, `file.printers`, `dryRun` for agents). Shortcuts now act like choosing the menu item (dialogs open), and UI commands win shortcut ties (⌘N opens New Document).
- Nested styles (through/up to N sentences, words, characters, letters, digits, tabs, breaks, spaces or given characters) and GREP styles (regex → character style), edited in Paragraph Style Options and composed under local formatting; `ui.dialog.open` for agents. Nested line styles are still to come; nested and GREP styles round-trip through IDML.
- Table and cell styles (round-trip through IDML): cell styles (fill, insets, vertical justification, strokes, paragraph style) and table styles (region cell styles, border, alternating rows, spacing); apply from the Table panel, edits re-apply to every user; `style.cell.*`, `style.table.*`.
- Style groups: paragraph and character styles shown in group folders, Move to Group (new, existing, none); `style.group` renames every use (styles, stories, table cells, footnotes, object styles).
- Scrubby zoom: drag the Zoom tool left or right to zoom continuously around the press point.
- Guides belong to the active layer: hidden with it (or with its Show Guides off) and locked with it.
- Pencil tool (N): freehand strokes simplified to smooth paths (Alt closes); Smooth and Erase tools re-fit or remove the stretch you drag along (`path.smooth`, `path.erase`).
- Edit › Keyboard Shortcuts: every command, click and press the new keys, conflicts shown, per-command and global reset; `window.setShortcut`.
- Primary Text Frame (New Document) and Smart Text Reflow: pages are added while the primary story oversets and empty ones at the end removed, in the same undo step (Preferences › Type).
- Glyphs panel: every character of any font (rendered by our renderer), search by character or U+code, click to insert, recently used.
- UI scaling (Preferences › Interface, 50–200%; `window.uiScale`); the window title steps aside when the bar is crowded.
- Underline / Strikethrough Options: weight, offset, colour and tint (Character panel; screen, PDF and IDML).
- Relink to Folder and Relink File Extension (`links.relinkFolder`; missing links found by name).
- File › Package (⌥⇧⌘P): the document relinked to a Links folder, the placed files, the font files that draw its text in a Document Fonts folder (the fonts it names and any fallback fonts that draw characters those lack; except fonts whose licence restricts it, OS/2 `fsType`), an IDML copy, an optional PDF and a report (fonts, fallback fonts, links, preflight, instructions); Copy Links To (`links.copyTo`).
- Document fonts: opening a `.designcraft` or `.idml` file loads the fonts in the `Document Fonts` folder beside it (as Package writes it), ahead of installed fonts with the same name, so a packaged document opens with its fonts on another machine. Damaged, oversized (over 256 MB per file, 512 MB in all) and surplus (over 200) font files are skipped and listed in `file.open`'s `warnings`; `font.list` gives each font's `source`. Document fonts belong to their document, as in InDesign: other open documents don't see them (each document with a same-named font draws with its own), and closing the document (or reverting it, or a book chapter's export ending) makes them unavailable. Their files stay in memory until DesignCraft quits, so reopening a document doesn't read or load them again; on the web there is no folder to read.
- Load Swatches / Save Swatches for Exchange (.ase: CMYK, RGB, Lab, Gray; spot or process) from the Swatches panel menu; `swatch.load` / `swatch.save`.
- Pathfinder (Object › Pathfinder and a Pathfinder panel): Add, Subtract, Intersect, Exclude Overlap, Minus Back; curves stay curves (flo_curves); `object.pathfinder`.
- Make / Release Compound Path (⌘8; nested paths become holes), Create Outlines (⇧⌘O; one path per text colour), Scissors tool (C) with `path.split`.
- Transform Again / Individually / Sequence Again (⌥⌘3; a moved copy repeats like Step and Repeat), Clear Transformations; Layers: Merge, Delete Unused, Hide/Lock Others, Show/Unlock All; Break Link to Style (paragraph and character).
- Floating panels: tear a panel off the dock (its float button, or drag its header away) into a movable, resizable window; Dock puts it back; positions persist; `window.floatPanel` / `window.dockPanel`.
- Place SVG (vector in PDF export via krilla-svg, resvg on screen, text in the bundled fonts), Photoshop (.psd/.psb composite in its colour mode — Bitmap, Grayscale, Duotone, Indexed, RGB, CMYK, Lab, 8/16-bit — with merged transparency; extra alpha and spot channels don't change the image), BMP, and Illustrator files saved with PDF compatibility (.ai). New `designcraft-images` crate (L2) for sniffing, sizes and decoding. EPS is still missing.
- View › Display Performance: Fast (grey boxes, no effects, ⌥⇧⌘Z), Typical (72 ppi proxies, ⌥⌘Z), High Quality (⌥⌘H, our default — the renderer is fast enough).
- OpenType menu (Character panel): discretionary ligatures, fractions, ordinal, swash, titling, contextual alternates, slashed zero, the four figure styles and stylistic sets 1–20; `type.openType`; mapped to IDML's OTF attributes.
- Add Anchor Point (=), Delete Anchor Point (-) and Convert Direction Point (⇧C) tools (click toggles smooth/corner, drag pulls out handles), with `path.addAnchor`, `path.deleteAnchor`, `path.convertAnchor`.
- Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel sets type, angle, reverse and edits stops on a ramp (drag, add, drag off to remove, location, colour). Edited gradients are unnamed swatches; `object.gradient`; the vector round-trips through IDML.
- Document Setup (⌥⌘P): intent, number of pages, start page # (an even start begins with a left page), facing pages, size presets and orientation, bleed and slug per edge; `layout.documentSetup {}` reports the setup.
- Stroke panel: cap, join, miter limit, alignment, type, Start/End arrowheads (12 kinds, drawn on screen and in PDF; the path is shortened under pointed heads) and gap colour under dashes and dots.
- Preferences (⌘K: General, Type, Units & Increments, Grids, Guides & Pasteboard, Display Performance) backed by `prefs.set` (application) and `document.preferences` (units, increments, grids — undoable, saved with the document; right-click a ruler to pick its units, the horizontal and vertical rulers each have their own); scaling applies to content and scales stroke weights (Include Stroke Weight), and X/Y/W/H measure the stroke's outer edge (Dimensions Include Stroke Weight). Quick Apply (⌘Return) finds paragraph, character and object styles as well as commands.
- Parent item overrides: Cmd+Shift-click a parent item on a page (or Override All Parent Page Items, ⌥⇧⌘L) makes an editable local copy that hides the parent's; Remove All Local Overrides and Detach All Objects from Parent in the Pages panel menu.
- Place PDF: placed PDF pages are sized by their crop box, drawn on screen through hayro (with mip levels) and embedded in exported PDFs as vector form XObjects (krilla), `<PDF>` in IDML.
- Menus expose the engine's features (TOC, numbering & sections, text variables, hyperlinks, cross-references, footnotes, step and repeat, spelling, fitting/content, effects, Data Merge, Preflight, EPUB); any menu command with parameters gets a dialog generated from its parameter documentation.
- Table of contents (generate/update, dot leaders) and text variables (running headers, last page number, chapter number, file name, dates, custom) resolved per page.
- Soft effects: blurred drop shadow, inner shadow, outer glow, basic feather.
- ~200 commands, all reachable through the JSON control channel; headless CLI rendering to PNG.
- MCP server (`designcraft-cli mcp [--connect PORT]`, docs/mcp.md): headless engine or the running app; commands, batch, document/story inspection, page renders as images, window screenshots, pointer/keyboard/dialog input.
- Web build (`apps/designcraft-web`, trunk): the same UI on WebGPU with a WebGL2 fallback; open/place via the browser file picker or drag-and-drop, save/export as downloads.
**Next (in order):** books · New Window / split view · Ink Manager and Separations Preview · XML structure, tags and export · buttons and forms · PDF/X-4 output intent (needs krilla support) · EPS place.
## How far from full parity (estimate, 2026-10-04)
**Breadth: ~99% weighted** (P0 core 99%, P1 100%, P2 97%) over the 275 features of the InDesign catalogue, scored
row by row in [docs/parity.md](docs/parity.md) (`cargo xtask parity` recomputes it). Many features scored done still
lack some of InDesign's options or dialog details, so **overall parity including depth is about 83%**.
**Remaining work: about 170 wall-clock hours of a single Claude Opus 5.5 agent** (±30%), or roughly 60–85 hours with
four agents in parallel on separate crates:
| Work | Estimate |
|---|---|
| Open P0 (2: PDF/X-4 validation against a certified checker, PostScript interpretation for EPS) | 8 h |
| Open P1 (none partial) | 0 h |
| Open P2 (3 partial: localization breadth, INDD pages (not readable clean-room), in-app media playback) | 14 h |
| Depth and pixel fidelity of every dialog, panel and menu against InDesign 2026 | 90 h |
| Performance (incremental composition, GPU raster) and interchange hardening (IDML/PDF corpus) | 55 h |
Basis: since the first estimate this effort landed about 45 arcs (from parent overrides, Preferences, Stroke panel,
gradients and Pathfinder to table/cell styles, nested/GREP styles and Print) in roughly 14 hours on a heavily loaded
machine — about 1–2 hours per catalogue row.
## Agents: CLI and MCP
Every command is reachable from `designcraft-cli` (`run`, `script` with `$N.path` result references, `app` for the
running window, `describe`, `commands`), from MCP (`designcraft-cli mcp`, tools including `execute` and `batch` with the
same references), and from the app's JSON control channel. See [docs/agents.md](docs/agents.md).
## Milestones
| # | Milestone | State |
| | Value | Kind |
|---|---|---|
| M0 | Skeleton + vertical slice | ✅ |
| M1 | Selection, transform, layers, pages, MCP | in progress |
| M2 | Type I (Type tool, threading, Character/Paragraph, composer) | in progress |
| M3 | Styles (nested/GREP, bullets, keeps, span columns) | started |
| M4 | Color & effects | started |
| M5 | Graphics & links | started |
| M6 | Files & export (native, IDML, PDF, PNG/JPEG) | in progress (IDML ✅, PDF export) |
| M7 | Long documents (sections, TOC, index, footnotes, books) | in progress (sections, TOC, text variables/running heads, hyperlinks, bookmarks, footnotes, cross-references, index) |
| M8 | Tables | ✅ core (table/cell styles, rotation, diagonal lines pending) |
| M9 | Performance (MT composition, tiles) | in progress (glyph path cache, bounded RTL label layout cache, culling, parallel compose 6.7× faster, perf harness) |
| M10 | Find/Change, spelling, Preflight | in progress (Find/Change ✅, spelling ✅, Preflight) |
| M11 | Layout power features (liquid/alternate layouts, data merge) | started (data merge phase 1, step & repeat, snippets) |
| M12 | Interactive & digital (EPUB, HTML, interactive PDF) | started (EPUB, PDF links/bookmarks) |
| M13 | Automation (scripts, batch) | |
| M14 | 1.0 polish & packaging | |
| **Feature breadth** | **~80%** (69% of InDesign 21.6's in-scope menu items matched; 99% of our 275-row checklist) | measured bounds, estimate between |
| **Ready for real work** | **~47%** (range 42–52%) | estimated: weighted sum over the dimensions below |
| Mainstream practitioner | **~42%** (weekly core 60.7% × 0.88 interaction × 0.92 stability × 0.85 file exchange) | estimated |
| Essentials user | **~54%** (core features 72% × 0.90 launch × 0.88 discoverability × 0.95 files received) | estimated |
| Remaining to **beta** | **420–750 Opus 5.5 agent-hours** (~120–200 wall-clock hours with 4–5 agents) | estimated |
| Remaining to **full parity** | **1,150–1,950 Opus 5.5 agent-hours** | estimated |
| Audience | Ready | Opus 5.5 agent-hours to ~95% | Work that dominates |
|---|---|---|---|
| Full target (ready for real work) | ~47% | 1,150–1,950 (~70% parallel) | depth across all areas, scripting compatibility, localization, typography fidelity, INDD/IDML |
| Mainstream practitioner | ~42% | 500–850 (~65% parallel) | INDD import and IDML corpus, composition fidelity, weekly-area depth, PDF/X-1a and printing |
| Essentials user | ~54% | 135–250 (~50% parallel) | start-up robustness, context menus and copy/paste, threading, fonts, Windows printing |
**Why pre-alpha:** it fails one of InDesign's core workflows, the alpha gate in
[docs/roadmap.md](docs/roadmap.md#alpha-gate). Text threads by hand (click a port, then click or
draw a frame; thread and unthread commands), but Primary Text Frame autoflow adds pages users
can't delete (#165). Everything else in the gate works end to end on macOS (document setup,
styles, images, object editing, PDF export, save and reopen), and ready for real work (~47%) is
above the alpha bar, so the Primary Text Frame page fixes are the whole distance to alpha.
Beyond that, beta needs INDD opening, IDML proven on real files, composition compared with
InDesign's, and basic editing fixed on Windows and Linux. Details:
[docs/target-app-parity.md](docs/target-app-parity.md).
## By dimension
| Dimension | Ready | Hours | Doc |
|---|---|---|---|
| Features (depth) | 55% | 250–400 | [target-app-parity.md](docs/target-app-parity.md) |
| Typography and composition fidelity | 45% | 120–200 | [typography-parity.md](docs/typography-parity.md) |
| File formats | 40% | 150–250 + 20–60 (INDD) | [file-format-parity.md](docs/file-format-parity.md) |
| UI/UX fidelity | 45% | 120–180 | [ui-parity.md](docs/ui-parity.md) |
| Stability | 45% | 60–100 | [gaps.md](docs/gaps.md) |
| Performance | 65% | 40–80 | [gaps.md](docs/gaps.md) |
| Localization | 45% | 120–180 | [localization-parity.md](docs/localization-parity.md) |
| Platforms | 55% | 40–80 | [hardware-parity.md](docs/hardware-parity.md) |
| Hardware | 45% | 20–40 | [hardware-parity.md](docs/hardware-parity.md) |
| Ecosystem (scripting, plug-ins) | 15% | 150–250 | [gaps.md](docs/gaps.md) |
| AI features | 15% | 60–120 | [gaps.md](docs/gaps.md) |
All "Ready" figures are estimates; the weights and evidence are in target-app-parity.md.
## Features
Breadth from [docs/parity-checklist.md](docs/parity-checklist.md) (`cargo xtask parity`,
measured); Ready estimated.
| Area | Breadth | Ready | Hours |
|---|---|---|---|
| Application shell & workspace | 99% | 55% | 40–60 |
| Documents, pages, spreads | 100% | 65% | 20–35 |
| Layers | 100% | 75% | 5–10 |
| Frames, shapes & paths | 100% | 60% | 25–40 |
| Transform | 100% | 70% | 10–20 |
| Fill, stroke, colour | 100% | 60% | 20–35 |
| Effects & transparency | 100% | 50% | 20–30 |
| Placing & links | 93% | 55% | 20–35 |
| Type & text frames | 99% | 45% | 40–60 |
| Typography | 100% | 45% | see typography-parity.md |
| Styles | 100% | 60% | 15–25 |
| Tables | 100% | 55% | 25–40 |
| Long documents | 100% | 45% | 30–50 |
| Interactivity & digital | 100% | 40% | 30–50 |
| Output & production | 92% | 55% | 40–70 |
| XML & automation | 100% | 35% | see Ecosystem |
| View & navigation | 100% | 65% | 10–20 |
| Undo, history, saving | 100% | 70% | 5–10 |
| Accessibility | 100% | 50% | 15–25 |
## Languages
Interface strings translated, of 1,724 measured. Detail: [docs/localization-parity.md](docs/localization-parity.md).
| Language | Code | Status | Translated |
|---|---|---|---|
| English | en | full | 100% |
| Simplified Chinese | zh | partial (no canvas IME) | 83% |
| Spanish | es | partial | 83% |
| Hindi | hi | none | 0% |
| Arabic | ar | partial (RTL UI and composition) | 85% |
| French | fr | partial | 83% |
| Portuguese (Brazil) | pt-br | partial | 61% |
| Indonesian | id | none | 0% |
| Japanese | ja | partial (no canvas IME) | 95% |
| German | de | partial | 83% |
| Korean | ko | none | 0% |
| Vietnamese | vi | none | 0% |
Also shipped: Italian (85%), Ukrainian (83%).
## Upcoming
Ranked; detail and estimates in [docs/roadmap.md](docs/roadmap.md), the full list in
[docs/gaps.md](docs/gaps.md).
1. **Alpha gate:** Primary Text Frame page fixes: autoflow adds pages users can't delete (#165) (5–7 h)
2. Basic editing on every platform: copy/paste, context menus, caret formatting (30–50 h)
3. CI running `cargo xtask ci` on pull requests (4–8 h)
4. Open INDD through a clean-room converter, owner decision on #114 (20–60 h)
5. IDML real-file corpus and fixes (60–100 h)
6. Composition oracle against InDesign; optical kerning; overset rule (80–140 h)
7. Start on any GPU; find every installed font (25–50 h)
## Documents
[target-app-parity.md](docs/target-app-parity.md) (assessment) · [gaps.md](docs/gaps.md) (work
list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.md](docs/parity-checklist.md)
(presence checklist) · [typography-parity.md](docs/typography-parity.md) ·
[file-format-parity.md](docs/file-format-parity.md) · [ui-parity.md](docs/ui-parity.md) ·
[hardware-parity.md](docs/hardware-parity.md) · [localization-parity.md](docs/localization-parity.md) ·
[architecture.md](docs/architecture.md) · agents: [agents.md](docs/agents.md), [mcp.md](docs/mcp.md),
[control-protocol.md](docs/control-protocol.md) · CJK: [cjk.md](docs/cjk.md),
[cjk-typography.md](docs/cjk-typography.md) · Arabic: [arabic-typography.md](docs/arabic-typography.md)
## Progress log
Newest first.
- **2026-10-12** · Text threads by hand: click a selected text frame's in or out port to load
the text cursor, then click a frame to thread it, click empty space for a column-wide frame or
drag one (it snaps like frame drawing); Shift-click autoflows, Alt-click stays loaded on the
frame just added; click the linked frame or double-click a port to break the thread.
`text.thread`, `text.unthread`, `text.removeFromThread` with undo; locked frames and layers
refuse threads. The alpha gate now waits only on the Primary Text Frame page fixes (#165). No
readiness percentage change.
- **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change.
- **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change.
- **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change.
- **2026-10-12** · Book numbering and style updates prepare every chapter before saving. Only closed native chapters are changed; imported IDML remains a read-only style/PDF source. If a later disk write fails, the error names the completed chapters; the batch is not a whole-book transaction. No readiness percentage change.
- **2026-10-12** · Desktop write callbacks and CLI raster exports use the atomic writer too; open readers retain the original bytes, while private Unix modes and symlink targets survive replacement. Permanent tests cover both actual callers. No readiness percentage change.
- **2026-10-12** · Save As, library edits and book collection edits persist prospective values before changing live state; failed writes preserve the document and existing collections. Save a Copy rebases data-source paths for its destination without changing the open document. No readiness percentage change.
- **2026-10-12** · The Pen tool continues an open path from either end point, as in InDesign: the cursor shows it over an end point, a click there carries on drawing that path, a drag first reshapes the end point's direction lines, and the other end closes it. Object › Paths › Reverse Path (`path.reverse`); `path.setAnchor`; `path.appendAnchor` and `path.close` take a subpath. No readiness percentage change.
- **2026-10-12** · Editing type on a path: the Type tool clicks, drags and double-clicks along the curve (the character under the pointer, not one on the straightened line), a click on the letters themselves hits the frame, and the caret and highlight bend with the path. No readiness percentage change.
- **2026-10-12** · Type on a path has start and end brackets, as in InDesign: selected path type shows them across the path, and dragging either with the Selection or Direct Selection tool moves where the text starts or ends (text past the end bracket is overset). `type.pathOptions {end}`; IDML reads and writes EndBracket. No readiness percentage change.
- **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change.
- **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change.
- **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change.
- **2026-10-12** · Hyphenation Zone applies to the Single-line Composer only, as in InDesign. With the default Paragraph Composer, ragged text hyphenates where InDesign does (aper-natem), giving the same line breaks. No readiness percentage change.
- **2026-10-12** · The Tools panel's Fill/Stroke keys work: D (default fill and stroke), X (fill or stroke in front), Shift+X (swap per object, tints included) and / (Apply None), as `color.*` commands; swap and default are one undo step (`object.swapFillStroke`, `object.defaultFillStroke`); the Color panel follows the proxy's fill/stroke choice. No readiness percentage change.
- **2026-10-11** · Corner Options keep curved sides curved and round every corner point, including where an arc meets a line (a Pathfinder quarter circle with rounded corners stayed a triangle). No readiness percentage change.
- **2026-10-11** · `story.replaceRange`, `story.setText`, notes, footnotes, endnotes, type on a path and table cells read `\r` and `\r\n` as paragraph returns, as `frame.create` and `text.insert` already did. No readiness percentage change.
- **2026-10-11** · The View menu follows InDesign's: proofing first, then zoom, Rotate Spread, Screen Mode, Display Performance, rulers, Extras, Grids & Guides and Structure; the view items that sat at the end of the Type menu moved there. No readiness percentage change.
- **2026-10-12** · Underlines and strikethroughs are drawn as one bar per stretch of text on screen, as in PDF export: a bar per letter left faint seams between the letters. No readiness percentage change.
- **2026-10-12** · Properties › Transform shows rotation, shear and X/Y scale, and Rotate 90° and Flip Horizontal/Vertical buttons, as InDesign's does. No readiness percentage change.
- **2026-10-12** · Edit › Step and Repeat has its InDesign dialog: Repeat (Count, or Rows and Columns with Create as a grid), Offset (Vertical, Horizontal, in ruler units) and Preview, which shows the copies while the dialog is open; Cancel takes them away and OK makes them as one undo step. No readiness percentage change.
- **2026-10-12** · Double-clicking the Fill or Stroke box in the Tools panel opens the Color Picker with the selection's colour, as in InDesign (it opened the Swatches panel). No readiness percentage change.
- **2026-10-12** · The Effects panel shows the level its settings apply to ("Group: Color Dodge 100%") and, as in InDesign, Isolate Blending and Knockout Group for a selected group. No readiness percentage change.
- **2026-10-11** · Properties gains InDesign's Pathfinder section (Add, Subtract, Intersect, Exclude Overlap, Minus Back) with several objects selected; section titles and icon buttons have accessible names. No readiness percentage change.
- **2026-10-12** · The Eyedropper samples a placed image: clicking the photo with text (or objects) selected fills them with the image's colour under the pointer, as an unnamed colour (`color.sample`), as in InDesign. Images on locked layers can be sampled. No readiness percentage change.
- **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change.
- **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change.
- **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change.
- **2026-10-11 (printing):** `file.printers` and `file.print` use craft-libs/craft-print for CUPS and Windows. DesignCraft retains PDF export, page selection, spreads, marks and bleed; dry runs create no temporary PDF or print job. Windows selects matching or default driver paper and shrinks oversized sheets without enlarging smaller ones; queue duplex defaults are preserved. Headless tests exercise exported bytes, job settings, dry runs and refusals; shared native Windows CI passes, including paused-queue submission and cleanup. PostScript, production print controls and physical output validation remain open. No readiness percentage change.
- **2026-10-10 (UI.4–UI.5):** Panel tabs can move into other groups, split the dock, float, resize and return to their saved locations through shared docking transactions. Layouts persist in custom workspaces; UI and control commands share the same validated action path. Shared pane geometry and widget-state theme application retain all five palettes and local fonts. Split panes send keyboard input only to the focused canvas and scope contextual toolbar IDs per pane. No readiness percentage change.
- **2026-10-10** · Version 0.6.0; releases add a Linux riscv64 tar.gz (cross-compiled, CLI
smoke-tested under QEMU).
- **2026-10-10** · Readiness per audience: full ~47% (method aligned with the standard, was
42%), mainstream practitioner ~42%, essentials user ~54%, each with hours to ~95%.
- **2026-10-10** · Stage re-normalized to **pre-alpha** under craftrules' core-workflow gate: text
can't be threaded by hand (no out-port click, no thread command).
- **2026-10-10** · Full re-measure against InDesign 2026 21.6 (menu dump, bundle formats and
localizations, 124 issues): breadth ~80%, ready ~42% (down from the ~83% "including depth" of
2026-10-04, on user evidence and the INDD gap); progress docs restructured to the craftrules
standard (`docs/parity.md` → `docs/parity-checklist.md`; new target-app-parity, gaps, roadmap,
architecture, typography, file-format, UI, hardware and localization docs). Landed today:
split columns (#295), PDF export options dialog (#196), column rules (#274), Paragraph Rules
dialog (#263), Character Style Options (#144), PDF/X-4 RGB handling (#223), Spanish
hyphenation (#222), Japanese (#272), Italian (#218) and Ukrainian (#236) interfaces, relocated packages relink (#244), graphics backend chosen
before the window (#297), IDML table styles (#288), warichu (#280), list numbering from IDML
(#298), security hardening (#153, #155), many text-composition fixes (#282–#293).
- **2026-10-09** · Version 0.5.0; many user reports triaged into issues (#160–#265).
- **2026-10-08** · Version 0.4.0.
- **2026-10-07** · Community PRs: data merge, Brazilian Portuguese, PDF/X-4 flags, Office fonts
on macOS; CJK 1.x (Korean line breaking, fallback fonts in Package); PrintCraft renamed PdfCraft.
- **2026-10-06** · Snapping to parent items, Windows packaging, ARM64 workflow, control-channel
hardening.
- **2026-10-05** · FreeBSD build; caret-boundary fixes.
- **2026-10-04** · Never-crash pass across importers and the engine; rotate spread view; kashida
in styles; previous estimate: breadth 99%, "~83% including depth", ~170 h remaining.
- **2026-10-03** · Release 0.1.1; separations, XML tagging, track changes, PDF/X-4 output
intent, split window, buttons, menu customization.
- **2026-10-02** · Hyperlinks/Bookmarks panels, conditional text, tagged PDF, lists across
stories, style groups, Glyphs panel, High Contrast theme.
- **2026-10-01** · First commit; skeleton, IDML, PDF, tables, footnotes, cross-references, index,
spelling and parallel composition on the first day.
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | Manual text threading landed; alpha distance 15–25 h → 5–7 h (Primary Text Frame page fixes, #165) |
| 2026-10-10 | minor | Full number 42% → 47% (method aligned with the standard: plain weighted sum, no new evidence); readiness table with hours per audience |
| 2026-10-10 | minor | Headline adds mainstream practitioner (~42%) and essentials user (~54%); method in docs/target-app-parity.md |
| 2026-10-10 | minor | Stage alpha → pre-alpha under the new core-workflow gate: manual text threading is missing; alpha gate table added to docs/roadmap.md |
| 2026-10-10 | major | Restructured to the progress-docs standard; full re-measure; the "Working today" inventory moved to docs/target-app-parity.md, milestones to docs/roadmap.md, the CLI/MCP section left to docs/agents.md (which already covered it) |
| 2026-10-04 | major | Parity estimate: breadth 99%, depth ~83%, ~170 h |
| 2026-10-01 | major | First roadmap: status, milestones |

View File

@ -38,6 +38,37 @@ 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]\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\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]
Runs a command script from FILE (stdin when FILE is `-` or missing), headless or, with
--connect PORT, in the running app (designcraft --control PORT).
Script text, one step per line:
command.id {"param": 1} also `command.id={json}`, or `command.id` alone
{"command": "command.id", "params": {...}} JSON lines, or a JSON array of them
# comment, // comment skipped, like blank lines
UTF-8 with or without a byte order mark; LF or CRLF line endings.
References to earlier results (in string parameters):
"$N.path" the value at `path` in the result of step N. N is the 0-based step index:
comments and blank lines are not steps. `$last` is the previous step.
Paths go through result fields and array indices: "$1.story", "$3.ids.0".
"${N.path}" inside a longer string, replaced by the value's text: "Frame ${0.id}".
Text parameters: "\n" starts a new paragraph, "\u2028" is a forced line break, "\t" a tab.
Most commands ignore parameters they do not know instead of rejecting them: check names
with `designcraft-cli describe COMMAND`.
Options:
--in FILE / --sample open a document (.designcraft or .idml) or the sample first
--save OUT save as .designcraft after the script succeeds
--export OUT export after the script succeeds (.pdf, .idml, .epub, .png, .jpg); repeatable
--keep-going run every step, recording failures as {"error": ...} results
Prints {"completed": n, "results": [...]}, plus failedIndex / failedCommand / error and a
non-zero exit when a step fails."#;
/// The links line under the usage block.
fn usage_footer() -> String {
format!(
@ -281,7 +312,7 @@ fn export(s: &mut Session, out: &str, page: usize, scale: f64) -> Result<(), Str
.render_page(&st.doc, &s.cache, page, scale, true, &designcraft_render::RenderOptions { printing_only: true, ..Default::default() })
.ok_or("no such page")?;
let bytes = if out.ends_with(".jpg") || out.ends_with(".jpeg") { img.to_jpeg(90) } else { img.to_png() };
std::fs::write(out, bytes).map_err(|e| format!("{out}: {e}"))?;
designcraft_engine::atomic::write_atomic(std::path::Path::new(out), &bytes).map_err(|e| format!("{out}: {e}"))?;
eprintln!("wrote {out} ({}×{}, {:.1} ms, {} glyphs)", img.width, img.height, t.elapsed().as_secs_f64() * 1000.0, r.stats.glyphs);
Ok(())
}
@ -335,6 +366,10 @@ fn script(args: &[String]) -> Result<(), String> {
"--save" => save = Some(val()?),
"--export" => export.push(val()?),
"--keep-going" => keep_going = true,
"--help" | "-h" => {
outln!("{SCRIPT_HELP}");
return Ok(());
}
f if !f.starts_with("--") && file.is_none() => file = Some(f.to_string()),
other => return Err(format!("unknown script option `{other}`")),
}

View File

@ -87,6 +87,21 @@ fn help_goes_to_stdout_and_unknown_args_to_stderr() {
assert!(String::from_utf8_lossy(&out.stderr).starts_with("usage: designcraft-cli"));
}
/// `script --help` was "unknown script option `--help`"; the line syntax was only in
/// `describe script.run` (#654).
#[test]
fn script_help_documents_the_syntax() {
for flag in ["--help", "-h"] {
let out = cli().args(["script", flag]).output().unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(out.status.success(), "{flag}: {:?} {}", out.status, String::from_utf8_lossy(&out.stderr));
assert!(stdout.starts_with("usage: designcraft-cli script"), "{flag}: {stdout:?}");
for needle in ["0-based", "$1.story", "$3.ids.0", "\\u2028", "ignore"] {
assert!(stdout.contains(needle), "{flag}: no {needle:?} in {stdout:?}");
}
}
}
/// `designcraft-cli commands | head -1` panicked with "failed printing to
/// stdout: Broken pipe (os error 32)" and exit status 101.
#[test]
@ -122,3 +137,35 @@ fn run_export_options_come_before_their_export() {
assert!(err.contains("wrote") && std::path::Path::new(a).exists() && std::path::Path::new(b).exists(), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn raster_export_preserves_an_existing_reader_and_private_symlink_target() {
use std::io::Read;
use std::os::unix::fs::{PermissionsExt, symlink};
struct Fixture(std::path::PathBuf);
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("designcraft-cli-atomic-{}-{stamp}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
let fixture = Fixture(dir);
let target = fixture.0.join(format!("{}.png", "a".repeat(243)));
let old = b"previous private export";
std::fs::write(&target, old).unwrap();
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600)).unwrap();
let alias = fixture.0.join("linked.png");
symlink(target.file_name().unwrap(), &alias).unwrap();
let mut reader = std::fs::File::open(&target).unwrap();
let out = cli().args(["run", "--sample", "--scale", "0.1", "--export", alias.to_str().unwrap()]).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let mut observed = Vec::new();
reader.read_to_end(&mut observed).unwrap();
assert!(observed == old, "CLI export changed bytes under an existing reader");
assert!(std::fs::read(&target).unwrap().starts_with(b"\x89PNG\r\n\x1a\n"));
assert_eq!(std::fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o600);
assert!(std::fs::symlink_metadata(alias).unwrap().file_type().is_symlink());
}

View File

@ -3,6 +3,7 @@ name = "designcraft-web"
description = "DesignCraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false

View File

@ -2,6 +2,7 @@
name = "designcraft"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
default-run = "designcraft"
@ -14,6 +15,7 @@ designcraft-engine = { workspace = true }
designcraft-ui-egui = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] }
egui = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
@ -23,10 +25,13 @@ winresource = "0.1"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd = { workspace = true }
arboard = { workspace = true }
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
# The same winit eframe uses: to pick the X11 backend (file drag-and-drop) in main.rs.
winit = { version = "0.30", default-features = false, features = ["x11"] }
[target.'cfg(target_os = "macos")'.dependencies]
# The same winit as eframe; only the native termination opt-in is used.
winit = { version = "0.30", default-features = false }
muda = { version = "0.21", default-features = false }

398
apps/designcraft/src/gpu.rs Normal file
View File

@ -0,0 +1,398 @@
//! Which wgpu backend draws the window, and the fallback when one of them crashes at start-up.
//!
//! The canvas is rendered on the CPU (`designcraft-render`); the GPU only composites the
//! interface, so every backend shows the same pixels and the one that never crashes is the right
//! one. wgpu's default backend set (`PRIMARY | GL`) enumerates Vulkan first, and on an AMD
//! hybrid-graphics laptop (a Radeon RX 6800M beside an integrated Radeon, two versions of
//! `amdvlk64.dll` loaded into the process) AMD's Vulkan driver faulted with an access violation
//! at the first swapchain present. A fault inside a driver can't be caught (the crash guard never
//! sees it), so the backend is chosen before the window exists: on Windows DirectX 12 first (the
//! backend browsers' WebGPU uses), then Vulkan, then OpenGL; on Linux Vulkan, then OpenGL; on
//! macOS Metal. `WGPU_BACKEND` — wgpu's own variable: `dx12`, `vulkan`, `gl`, `metal`, or a
//! comma-separated list — overrides the choice and turns the fallback off.
//!
//! The fallback: before the window is created, `gpu.json` in the settings directory records the
//! backend being tried; once a frame has been presented, that entry is cleared. An entry still
//! there at the next start means the previous start never got that far — the driver crashed, or
//! the app was killed meanwhile — so that backend joins `failed`, the next candidate is tried and
//! the status bar says so. When every candidate has failed the list starts over, and so does a
//! new DesignCraft version (drivers and wgpu move on). `DESIGNCRAFT_NO_PREFS` records nothing.
use std::path::{Path, PathBuf};
use eframe::wgpu;
use serde::{Deserialize, Serialize};
/// The record's file name inside the settings directory.
pub const FILE: &str = "gpu.json";
/// A graphics backend wgpu can draw the window with.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Backend {
Dx12,
Vulkan,
Metal,
Gl,
}
impl Backend {
/// The name the status bar and the log use.
pub fn name(self) -> &'static str {
match self {
Backend::Dx12 => "DirectX 12",
Backend::Vulkan => "Vulkan",
Backend::Metal => "Metal",
Backend::Gl => "OpenGL",
}
}
/// wgpu's bit for this backend.
pub fn bit(self) -> wgpu::Backends {
match self {
Backend::Dx12 => wgpu::Backends::DX12,
Backend::Vulkan => wgpu::Backends::VULKAN,
Backend::Metal => wgpu::Backends::METAL,
Backend::Gl => wgpu::Backends::GL,
}
}
/// The backend of the adapter wgpu picked; `None` for the web and no-op backends.
pub fn of(backend: wgpu::Backend) -> Option<Backend> {
match backend {
wgpu::Backend::Dx12 => Some(Backend::Dx12),
wgpu::Backend::Vulkan => Some(Backend::Vulkan),
wgpu::Backend::Metal => Some(Backend::Metal),
wgpu::Backend::Gl => Some(Backend::Gl),
_ => None,
}
}
}
/// The backends to try on this platform, best first.
pub fn candidates() -> &'static [Backend] {
if cfg!(windows) {
&[Backend::Dx12, Backend::Vulkan, Backend::Gl]
} else if cfg!(target_os = "macos") {
&[Backend::Metal]
} else {
&[Backend::Vulkan, Backend::Gl]
}
}
/// What `gpu.json` holds.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Record {
/// The DesignCraft version that wrote the record; another version starts afresh.
#[serde(default)]
pub version: String,
/// The backend of a start that has not presented a frame yet.
#[serde(default)]
pub trying: Option<Backend>,
/// Backends whose start never presented a frame.
#[serde(default)]
pub failed: Vec<Backend>,
}
/// The backend for a start, from [`choose`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Choice {
pub backend: Backend,
/// The record to write before the window is created.
pub record: Record,
/// The backend the previous start never finished with, when this start moves away from it.
pub switched_from: Option<Backend>,
}
/// Pick the backend for this start from the candidates (best first) and the previous start's
/// record; `None` without candidates.
pub fn choose(candidates: &[Backend], previous: &Record, version: &str) -> Option<Choice> {
let first = *candidates.first()?;
let same_version = previous.version == version;
let mut failed: Vec<Backend> = if same_version { previous.failed.clone() } else { Vec::new() };
failed.retain(|b| candidates.contains(b));
let unfinished = previous.trying.filter(|_| same_version);
if let Some(b) = unfinished
&& !failed.contains(&b)
{
failed.push(b);
}
let backend = match candidates.iter().copied().find(|b| !failed.contains(b)) {
Some(b) => b,
// Every candidate has failed: start over rather than never starting.
None => {
failed.clear();
first
}
};
let switched_from = unfinished.filter(|b| *b != backend);
Some(Choice { backend, record: Record { version: version.to_owned(), trying: Some(backend), failed }, switched_from })
}
/// The record in `path`; a missing or unreadable file is an empty record.
pub fn read(path: &Path) -> Record {
std::fs::read(path).ok().and_then(|bytes| serde_json::from_slice(&bytes).ok()).unwrap_or_default()
}
/// Write `record` to `path` (creating the directory).
pub fn write(path: &Path, record: &Record) -> std::io::Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let bytes = serde_json::to_vec_pretty(record).map_err(std::io::Error::other)?;
std::fs::write(path, bytes)
}
/// A start in progress: the backend chosen for it and the record that says so until a frame has
/// been presented.
#[derive(Debug)]
pub struct Startup {
pub backend: Backend,
pub switched_from: Option<Backend>,
/// `gpu.json`, when the record is persisted.
path: Option<PathBuf>,
record: Record,
}
impl Startup {
/// Choose the backend for this start and record it in `path` (nothing is recorded without
/// one). `None` when `WGPU_BACKEND` is set — wgpu follows it and the fallback is off — or the
/// platform has no candidates.
pub fn begin(path: Option<PathBuf>, version: &str) -> Option<Startup> {
if wgpu::Backends::from_env().is_some_and(|b| !b.is_empty()) {
log::info!("graphics: WGPU_BACKEND is set, wgpu picks the backend and the start-up fallback is off");
return None;
}
Self::begin_unforced(path, version)
}
fn begin_unforced(path: Option<PathBuf>, version: &str) -> Option<Startup> {
let previous = path.as_deref().map(read).unwrap_or_default();
let choice = choose(candidates(), &previous, version)?;
if let Some(p) = &path
&& let Err(e) = write(p, &choice.record)
{
log::warn!("graphics: {}: {e}", p.display());
}
match choice.switched_from {
Some(from) => log::warn!(
"graphics: the last start with {} never showed a frame; trying {} (failed so far: {:?})",
from.name(),
choice.backend.name(),
choice.record.failed
),
None => log::info!("graphics: {} (WGPU_BACKEND overrides)", choice.backend.name()),
}
Some(Startup { backend: choice.backend, switched_from: choice.switched_from, path, record: choice.record })
}
/// The backends wgpu may use: the chosen one, with OpenGL as the in-process fallback when it
/// has no adapter (unless OpenGL itself has failed). Not on Windows: some OpenGL drivers
/// (AMD's) crash while the instance is created (#167), before any fallback could help, so
/// there OpenGL is only ever tried as its own candidate.
pub fn backends(&self) -> wgpu::Backends {
let mut backends = self.backend.bit();
if !cfg!(windows) && !self.record.failed.contains(&Backend::Gl) {
backends |= wgpu::Backends::GL;
}
backends
}
/// wgpu picked an adapter of `backend`: record that one, so a crash is blamed on the backend
/// that is actually drawing.
pub fn started_with(&mut self, backend: Backend) {
if self.record.trying == Some(backend) {
return;
}
self.backend = backend;
self.record.trying = Some(backend);
self.save();
}
/// A frame has been presented (or the app exited normally): this start did not crash.
pub fn presented(&mut self) {
if self.record.trying.take().is_some() {
self.save();
}
}
/// The status-bar line for a start that moved away from a backend that crashed.
pub fn status_line(&self) -> Option<String> {
let from = self.switched_from?;
Some(format!(
"Graphics: switched from {} to {} — the last start with {} never showed a frame (set WGPU_BACKEND to choose).",
from.name(),
self.backend.name(),
from.name()
))
}
fn save(&self) {
if let Some(p) = &self.path
&& let Err(e) = write(p, &self.record)
{
log::warn!("graphics: {}: {e}", p.display());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
const V: &str = "0.5.0";
const WIN: &[Backend] = &[Backend::Dx12, Backend::Vulkan, Backend::Gl];
fn temp_path(tag: &str) -> PathBuf {
static N: AtomicUsize = AtomicUsize::new(0);
let d = std::env::temp_dir().join(format!("designcraft-gpu-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
d.join(FILE)
}
fn record(trying: Option<Backend>, failed: &[Backend]) -> Record {
Record { version: V.into(), trying, failed: failed.to_vec() }
}
#[test]
fn every_platform_has_candidates_without_duplicates() {
let c = candidates();
assert!(!c.is_empty());
for (i, b) in c.iter().enumerate() {
assert!(!c[..i].contains(b), "{b:?} twice in {c:?}");
}
if cfg!(windows) {
assert_eq!(c[0], Backend::Dx12, "DirectX 12 is the Windows default (AMD's Vulkan driver faulted at the first present)");
assert!(!c.contains(&Backend::Metal));
}
if cfg!(target_os = "macos") {
assert_eq!(c, &[Backend::Metal]);
}
}
#[test]
fn a_fresh_start_takes_the_first_candidate() {
let c = choose(WIN, &Record::default(), V).unwrap();
assert_eq!(c.backend, Backend::Dx12);
assert_eq!(c.record, record(Some(Backend::Dx12), &[]));
assert_eq!(c.switched_from, None);
assert!(choose(&[], &Record::default(), V).is_none());
}
#[test]
fn a_start_that_never_presented_moves_to_the_next_candidate() {
let c = choose(WIN, &record(Some(Backend::Dx12), &[]), V).unwrap();
assert_eq!(c.backend, Backend::Vulkan);
assert_eq!(c.record, record(Some(Backend::Vulkan), &[Backend::Dx12]));
assert_eq!(c.switched_from, Some(Backend::Dx12));
let c = choose(WIN, &record(Some(Backend::Vulkan), &[Backend::Dx12]), V).unwrap();
assert_eq!(c.backend, Backend::Gl);
assert_eq!(c.record.failed, &[Backend::Dx12, Backend::Vulkan]);
assert_eq!(c.switched_from, Some(Backend::Vulkan));
}
#[test]
fn a_finished_start_keeps_the_failed_list_and_switches_silently() {
// `trying` was cleared by a presented frame; the next start still avoids what failed.
let c = choose(WIN, &record(None, &[Backend::Dx12]), V).unwrap();
assert_eq!(c.backend, Backend::Vulkan);
assert_eq!(c.record.failed, &[Backend::Dx12]);
assert_eq!(c.switched_from, None);
// The same backend failing again is listed once.
let c = choose(WIN, &record(Some(Backend::Dx12), &[Backend::Dx12]), V).unwrap();
assert_eq!(c.record.failed, &[Backend::Dx12]);
}
#[test]
fn when_every_candidate_failed_the_list_starts_over() {
let c = choose(WIN, &record(Some(Backend::Gl), &[Backend::Dx12, Backend::Vulkan]), V).unwrap();
assert_eq!(c.backend, Backend::Dx12);
assert_eq!(c.record.failed, &[]);
assert_eq!(c.switched_from, Some(Backend::Gl));
}
#[test]
fn another_version_or_platform_forgets_what_failed() {
let old = Record { version: "0.4.0".into(), trying: Some(Backend::Dx12), failed: vec![Backend::Vulkan] };
let c = choose(WIN, &old, V).unwrap();
assert_eq!(c.backend, Backend::Dx12);
assert_eq!(c.record, record(Some(Backend::Dx12), &[]));
assert_eq!(c.switched_from, None);
// A backend that is no candidate here (a record copied from another machine) is dropped.
let c = choose(&[Backend::Vulkan, Backend::Gl], &record(None, &[Backend::Metal, Backend::Vulkan]), V).unwrap();
assert_eq!(c.backend, Backend::Gl);
assert_eq!(c.record.failed, &[Backend::Vulkan]);
}
#[test]
fn the_record_round_trips_and_garbage_reads_as_empty() {
let path = temp_path("roundtrip");
assert_eq!(read(&path), Record::default());
let r = record(Some(Backend::Vulkan), &[Backend::Dx12]);
write(&path, &r).expect("write");
assert_eq!(read(&path), r);
let text = std::fs::read_to_string(&path).expect("read");
assert!(text.contains("\"trying\": \"vulkan\"") && text.contains("\"dx12\""), "{text}");
std::fs::write(&path, b"{not json").expect("garbage");
assert_eq!(read(&path), Record::default());
// Unknown fields and missing ones are tolerated.
std::fs::write(&path, br#"{"failed":["gl"],"later":1}"#).expect("partial");
assert_eq!(read(&path), Record { version: String::new(), trying: None, failed: vec![Backend::Gl] });
let _ = std::fs::remove_dir_all(path.parent().expect("dir"));
}
#[test]
fn a_start_records_its_backend_until_a_frame_is_presented() {
let path = temp_path("startup");
let mut s = Startup::begin_unforced(Some(path.clone()), V).expect("candidates");
let first = candidates()[0];
assert_eq!(s.backend, first);
assert_eq!(read(&path).trying, Some(first));
assert!(s.backends().contains(first.bit()));
// OpenGL is the in-process fallback, except on Windows where it is only its own candidate.
assert_eq!(s.backends().contains(wgpu::Backends::GL), !cfg!(windows) || first == Backend::Gl);
assert_eq!(s.status_line(), None);
// wgpu actually picked OpenGL (the chosen backend had no adapter): blame that one.
s.started_with(Backend::Gl);
assert_eq!(read(&path).trying, Some(Backend::Gl));
s.presented();
assert_eq!(read(&path), record(None, &[]));
s.presented();
assert_eq!(read(&path), record(None, &[]));
// The next start after one that never presented: switches, says so, and leaves OpenGL
// out of the set once it failed.
write(&path, &record(Some(Backend::Gl), &[])).expect("write");
let s = Startup::begin_unforced(Some(path.clone()), V).expect("candidates");
// This synthetic prior start records OpenGL even on Metal-only macOS.
// Switching depends on the chosen backend, not the candidate count.
if s.backend != Backend::Gl {
assert_eq!(s.switched_from, Some(Backend::Gl));
let line = s.status_line().expect("status");
assert!(line.contains("OpenGL") && line.contains(s.backend.name()) && line.contains("WGPU_BACKEND"), "{line}");
assert!(!s.backends().contains(wgpu::Backends::GL));
} else {
assert_eq!(s.switched_from, None);
}
let _ = std::fs::remove_dir_all(path.parent().expect("dir"));
}
#[test]
fn without_a_path_nothing_is_written_and_a_bad_path_is_not_fatal() {
let mut s = Startup::begin_unforced(None, V).expect("candidates");
s.started_with(Backend::Gl);
s.presented();
// A file where the directory should be: every write fails, nothing panics.
let blocked = temp_path("blocked");
let dir = blocked.parent().expect("dir");
std::fs::create_dir_all(dir.parent().expect("tmp")).expect("tmp");
std::fs::write(dir, "a file where the directory should be").expect("block");
let mut s = Startup::begin_unforced(Some(blocked.clone()), V).expect("candidates");
s.started_with(Backend::Gl);
s.presented();
let _ = std::fs::remove_file(dir);
}
}

View File

@ -5,68 +5,108 @@
//! `--control <port>` (or `DESIGNCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
//! See `designcraft_ui_egui::control` for the methods.
//!
//! The graphics backend (DirectX 12, Vulkan, Metal or OpenGL) is chosen in `gpu` before the window
//! exists, with a fallback for a driver that crashes at start-up; `WGPU_BACKEND` overrides it.
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod control_server;
mod gpu;
mod logging;
#[cfg(target_os = "macos")]
mod native_menu;
mod profile;
use designcraft_engine::Session;
use designcraft_ui_egui::{DesignApp, Services};
struct App(DesignApp, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
struct App {
app: DesignApp,
#[cfg(target_os = "macos")]
menu: Option<native_menu::NativeMenu>,
/// The graphics start in progress (`gpu.json`) until a frame has been presented; see `gpu`.
startup: Option<gpu::Startup>,
/// Frames begun so far.
frames: u32,
}
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// 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
&& let Some(s) = self.startup.as_mut()
{
s.presented();
self.startup = None;
}
self.frames = self.frames.saturating_add(1);
#[cfg(target_os = "macos")]
{
if self.1.is_none() && std::env::var_os("DESIGNCRAFT_NO_NATIVE_MENU").is_none() {
self.1 = Some(native_menu::NativeMenu::install(&mut self.0));
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));
}
if let Some(m) = &mut self.1 {
m.poll(&mut self.0, ctx);
if let Some(m) = &mut self.menu {
m.poll(&mut self.app, ctx);
}
}
self.0.logic(ctx);
self.app.logic(ctx);
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw);
self.app.raw_input_hook(raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.0.ui(ui);
self.app.ui(ui);
}
fn on_exit(&mut self) {
save_prefs(&self.0);
// A normal exit before the third frame is not a crash.
if let Some(s) = self.startup.as_mut() {
s.presented();
}
if let Err(e) = preserve_documents_on_exit(&mut self.app) {
log::error!("Couldn't preserve documents on exit: {e}");
eprintln!("designcraft: couldn't preserve documents on exit: {e}");
}
save_prefs(&self.app);
}
}
/// The per-user settings directory: `ui.json`, `prefs.json` and `logs/` live here.
fn config_dir() -> Option<std::path::PathBuf> {
if cfg!(target_os = "macos") {
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support/DesignCraft"))
} else if cfg!(windows) {
std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join("DesignCraft"))
} else {
std::env::var_os("XDG_CONFIG_HOME")
.map(std::path::PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
.map(|c| c.join("designcraft"))
/// Orderly OS termination can bypass egui's cancelable close event. Finish only a complete,
/// current quit plan; otherwise preserve every live document, including edits after the timer.
fn preserve_documents_on_exit(app: &mut DesignApp) -> Result<(), String> {
app.finish_confirmed_quit()?;
if app.session.documents().is_empty() {
return Ok(());
}
if app.session.recovery_dir.is_none() {
return if app.session.documents().iter().any(|d| d.is_dirty()) {
Err("Recovery is unavailable; unsaved documents could not be preserved on exit".into())
} else {
Ok(())
};
}
// Also discard stale recovery for clean documents (for example, Undo back to the saved
// state after the last timer tick). Never choose a fallback recovery path here.
app.run("file.recovery.save", serde_json::json!({})).map(|_| ())
}
/// `DESIGNCRAFT_NO_PREFS`: read and write nothing in the settings directory (tests, demos).
fn no_prefs() -> bool {
std::env::var_os("DESIGNCRAFT_NO_PREFS").is_some()
}
fn prefs_path() -> Option<std::path::PathBuf> {
config_dir().map(|b| b.join("ui.json"))
profile::config_dir().map(|b| b.join("ui.json"))
}
fn load_prefs(app: &mut DesignApp) {
if std::env::var_os("DESIGNCRAFT_NO_PREFS").is_some() {
if no_prefs() {
return;
}
if let Some(p) = prefs_path()
&& let Ok(bytes) = std::fs::read(&p)
&& let Ok(ui) = serde_json::from_slice::<designcraft_ui_egui::UiState>(&bytes)
&& let Ok(ui) = designcraft_ui_egui::UiState::from_preferences_json(&bytes)
{
app.ui = ui;
}
@ -80,7 +120,7 @@ fn load_prefs(app: &mut DesignApp) {
}
fn save_prefs(app: &DesignApp) {
if std::env::var_os("DESIGNCRAFT_NO_PREFS").is_some() {
if no_prefs() {
return;
}
if let Some(p) = prefs_path() {
@ -169,7 +209,8 @@ fn services() -> Services {
})),
pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))),
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),
write: Some(Box::new(|p: &str, b: &[u8]| designcraft_engine::atomic::write_atomic(std::path::Path::new(p), b).map_err(|e| e.to_string()))),
clipboard_text: Some(Box::new(|| arboard::Clipboard::new().and_then(|mut c| c.get_text()).ok())),
..Default::default()
}
}
@ -219,7 +260,7 @@ fn main() -> eframe::Result {
}
// The log file lives under the settings directory; opened after the arguments, so `--version`
// leaves no file behind. Records logged until now are written to it first.
if let (Some(logger), Some(dir)) = (logger, config_dir()) {
if let (Some(logger), Some(dir)) = (logger, profile::config_dir()) {
match logger.attach_dir(&dir.join("logs")) {
Ok(path) => log::info!("DesignCraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()),
// Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics.
@ -241,6 +282,14 @@ fn main() -> eframe::Result {
if let Some(icon) = app_icon() {
options.viewport = options.viewport.with_icon(icon);
}
// The graphics backend, decided before the window exists: a driver that faults takes the
// process down before any Rust code can catch it (see `gpu`).
let gpu = gpu::Startup::begin(if no_prefs() { None } else { profile::config_dir().map(|d| d.join(gpu::FILE)) }, env!("CARGO_PKG_VERSION"));
if let Some(s) = &gpu
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
{
create.instance_descriptor.backends = s.backends();
}
// winit has no file drag-and-drop on Wayland (only on X11), so dropping images from the file
// manager showed a "no" cursor. Run through XWayland when it's there; DESIGNCRAFT_WAYLAND=1
// keeps the native Wayland backend.
@ -251,15 +300,37 @@ fn main() -> eframe::Result {
b.with_x11();
}));
}
// AppKit Dock Quit otherwise terminates before egui can cancel the close. Route it through
// ordinary CloseRequested events, preserving the same transactional Save/Don't Save flow.
#[cfg(target_os = "macos")]
{
options.event_loop_builder = Some(Box::new(|builder| {
use winit::platform::macos::EventLoopBuilderExtMacOS;
builder.with_close_requested_on_terminate(true);
}));
}
eframe::run_native(
"DesignCraft",
options,
Box::new(move |cc| {
let mut gpu = gpu;
if let Some(rs) = &cc.wgpu_render_state {
// What a bug report needs to know about the graphics driver.
let info = rs.adapter.get_info();
log::info!("graphics: {} on {:?} ({:?}; driver {} {})", info.name, info.backend, info.device_type, info.driver, info.driver_info);
if let (Some(s), Some(backend)) = (gpu.as_mut(), gpu::Backend::of(info.backend)) {
s.started_with(backend);
}
}
let mut session = Session::new();
// Crash recovery: reopen what a previous run left unsaved, then keep it current.
session.recovery_dir = designcraft_engine::recovery::default_dir();
session.recovery_dir = profile::recovery_dir();
let recovered = session.execute("file.recovery.open", &serde_json::json!({})).ok();
let mut app = DesignApp::new(session, services());
if let Some(line) = gpu.as_ref().and_then(gpu::Startup::status_line) {
app.status(line);
}
// Recovered documents matter more than the graphics note; both are in the log.
if let Some(n) = recovered.as_ref().and_then(|r| r["opened"].as_array()).map(Vec::len).filter(|n| *n > 0) {
app.status(format!("Recovered {n} unsaved document{} from the last session.", if n == 1 { "" } else { "s" }));
}
@ -277,11 +348,13 @@ fn main() -> eframe::Result {
eprintln!("designcraft: {f}: {e}");
}
}
Ok(Box::new(App(
Ok(Box::new(App {
app,
#[cfg(target_os = "macos")]
None,
)))
menu: None,
startup: gpu,
frames: 0,
}))
}),
)
}
@ -313,3 +386,168 @@ mod tests {
assert!(documents.iter().any(|filter| filter.extensions.contains(&"designcraft")));
}
}
#[cfg(all(test, unix))]
mod atomic_writer_tests {
#[test]
fn desktop_write_preserves_an_existing_reader_and_private_symlink_target() {
use std::io::Read;
use std::os::unix::fs::{PermissionsExt, symlink};
struct Fixture(std::path::PathBuf);
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("designcraft-desktop-atomic-{}-{stamp}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
let fixture = Fixture(dir);
let target = fixture.0.join(format!("{}.png", "a".repeat(243)));
let old = b"previous private export";
std::fs::write(&target, old).unwrap();
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600)).unwrap();
let alias = fixture.0.join("linked.png");
symlink(target.file_name().unwrap(), &alias).unwrap();
let mut reader = std::fs::File::open(&target).unwrap();
let mut write = super::services().write.unwrap();
write(alias.to_str().unwrap(), b"complete new export").unwrap();
let mut observed = Vec::new();
reader.read_to_end(&mut observed).unwrap();
assert!(observed == old, "desktop export changed bytes under an existing reader");
assert_eq!(std::fs::read(&target).unwrap(), b"complete new export");
assert_eq!(std::fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o600);
assert!(std::fs::symlink_metadata(alias).unwrap().file_type().is_symlink());
}
}
#[cfg(test)]
mod exit_recovery_tests {
use super::*;
use serde_json::json;
fn dirty_app(label: &str) -> (DesignApp, std::path::PathBuf) {
let mut app = DesignApp::new(Session::new(), Services::default());
app.run("file.new", json!({})).unwrap();
app.run("frame.create", json!({"rect": [20, 20, 120, 120]})).unwrap();
let uid = app.session.active().unwrap().uid;
let dir = std::env::temp_dir().join(format!("designcraft-exit-{label}-{}-{uid}", std::process::id()));
assert!(!dir.exists());
app.session.recovery_dir = Some(dir.clone());
(app, dir)
}
fn close_frame(app: &mut DesignApp, ctx: &egui::Context, close: bool) {
let mut raw = egui::RawInput::default();
if close {
raw.viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close);
}
let mut out = ctx.run_ui(raw, |ui| app.logic(ui.ctx()));
out.textures_delta.clear();
}
#[test]
fn exit_recovery_preserves_latest_dirty_document_without_mutating_it() {
let (mut app, dir) = dirty_app("latest");
app.run("file.recovery.save", json!({})).unwrap();
app.run("frame.create", json!({"rect": [200, 200, 300, 300]})).unwrap();
let d = app.session.active().unwrap();
let (uid, revision, saved_revision, doc, selection, undo, redo) =
(d.uid, d.revision, d.saved_revision, d.doc.clone(), d.selection.items.clone(), d.history.undo.len(), d.history.redo.len());
preserve_documents_on_exit(&mut app).unwrap();
let current = app.session.active().unwrap();
assert_eq!(
(current.uid, current.revision, current.saved_revision, &current.selection.items, current.history.undo.len(), current.history.redo.len()),
(uid, revision, saved_revision, &selection, undo, redo)
);
assert!(std::sync::Arc::ptr_eq(&current.doc, &doc));
assert!(current.is_dirty());
let recovered = designcraft_engine::cmd::from_bytes(&std::fs::read(dir.join(format!("{uid}.designcraft"))).unwrap()).unwrap();
assert_eq!(serde_json::to_value(&recovered).unwrap(), serde_json::to_value(doc.as_ref()).unwrap());
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn exit_recovery_removes_stale_data_after_undo_to_saved_state() {
let (mut app, dir) = dirty_app("clean");
let uid = app.session.active().unwrap().uid;
app.run("file.recovery.save", json!({})).unwrap();
let entry = dir.join(format!("{uid}.designcraft"));
assert!(entry.exists());
app.run("edit.undo", json!({})).unwrap();
assert!(!app.session.active().unwrap().is_dirty());
let doc = app.session.active().unwrap().doc.clone();
preserve_documents_on_exit(&mut app).unwrap();
assert!(!entry.exists(), "clean exit must not resurrect the pre-Undo edits");
assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &doc));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn exit_recovery_reports_unavailable_or_failed_storage_without_changing_documents() {
let (mut app, dir) = dirty_app("failure");
let doc = app.session.active().unwrap().doc.clone();
app.session.recovery_dir = None;
assert!(preserve_documents_on_exit(&mut app).unwrap_err().contains("unavailable"));
std::fs::create_dir(&dir).unwrap();
let blocked = dir.join("not-a-directory");
std::fs::write(&blocked, b"preserve this file").unwrap();
app.session.recovery_dir = Some(blocked.clone());
assert!(preserve_documents_on_exit(&mut app).is_err());
assert_eq!(std::fs::read(&blocked).unwrap(), b"preserve this file");
assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &doc));
assert!(app.session.active().unwrap().is_dirty());
assert_eq!(app.session.documents().len(), 1);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn exit_recovery_respects_complete_incomplete_stale_and_new_document_quit_plans() {
for case in ["complete", "incomplete", "stale", "new"] {
let (mut app, dir) = dirty_app(case);
if case == "incomplete" {
app.run("file.new", json!({})).unwrap();
app.run("frame.create", json!({"rect": [20, 20, 120, 120]})).unwrap();
}
app.run("file.recovery.save", json!({})).unwrap();
let first_uid = app.session.documents()[0].uid;
let ctx = egui::Context::default();
close_frame(&mut app, &ctx, true);
app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true));
designcraft_ui_egui::dialogs::confirm(&mut app).unwrap();
close_frame(&mut app, &ctx, false);
if case == "stale" {
app.run("frame.create", json!({"rect": [200, 200, 300, 300]})).unwrap();
} else if case == "new" {
app.run("file.new", json!({})).unwrap();
app.run("frame.create", json!({"rect": [20, 20, 120, 120]})).unwrap();
}
let active = app.session.active().unwrap().uid;
let before: Vec<_> = app
.session
.documents()
.iter()
.map(|d| (d.uid, d.revision, d.doc.clone(), d.selection.items.clone(), d.history.undo.len(), d.history.redo.len()))
.collect();
preserve_documents_on_exit(&mut app).unwrap();
if case == "complete" {
assert!(app.session.documents().is_empty());
assert!(!dir.join(format!("{first_uid}.designcraft")).exists(), "accepted Don't Save must not be recovered");
} else {
assert_eq!(app.session.documents().len(), before.len(), "{case}");
assert_eq!(app.session.active().unwrap().uid, active, "{case}");
for (d, (uid, revision, doc, selection, undo, redo)) in app.session.documents().iter().zip(before) {
assert_eq!(
(d.uid, d.revision, &d.selection.items, d.history.undo.len(), d.history.redo.len()),
(uid, revision, &selection, undo, redo),
"{case}"
);
assert!(std::sync::Arc::ptr_eq(&d.doc, &doc), "{case}");
let recovered = designcraft_engine::cmd::from_bytes(&std::fs::read(dir.join(format!("{uid}.designcraft"))).unwrap()).unwrap();
assert_eq!(serde_json::to_value(&recovered).unwrap(), serde_json::to_value(doc.as_ref()).unwrap(), "{case}");
}
}
std::fs::remove_dir_all(dir).unwrap();
}
}
}

View File

@ -40,6 +40,8 @@ impl NativeMenu {
// Our own About splash (with the community links) instead of the stock panel.
let about = MenuItem::with_id("dc-about", "About DesignCraft", true, None);
let discord = MenuItem::with_id("dc-discord", "Join the ArtCraft Discord…", true, None);
// Not the predefined Quit: it terminates the app at once, past the unsaved-changes prompt.
let quit = MenuItem::with_id("dc-quit", designcraft_ui_egui::i18n::tr(&app.ui.language, "Quit DesignCraft"), true, accel("Cmd+Q"));
let _ = app_menu.append_items(&[
&about,
&discord,
@ -50,10 +52,11 @@ impl NativeMenu {
&PredefinedMenuItem::hide_others(None),
&PredefinedMenuItem::show_all(None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::quit(None),
&quit,
]);
items.insert("dc-about".into(), ("help.about".into(), serde_json::Value::Null, Handle::Plain(about)));
items.insert("dc-discord".into(), ("help.discord".into(), serde_json::Value::Null, Handle::Plain(discord)));
items.insert("dc-quit".into(), ("app.quit".into(), serde_json::Value::Null, Handle::Plain(quit)));
let _ = menu.append(&app_menu);
for (title, entries) in menus::menu_tree() {
let sub = Submenu::new(designcraft_ui_egui::i18n::tr(&app.ui.language, title), true);

View File

@ -0,0 +1,108 @@
//! Native settings and recovery paths, including isolated automation profiles.
use std::ffi::OsString;
use std::path::PathBuf;
pub fn config_dir() -> Option<PathBuf> {
config_dir_with(std::env::consts::OS, |key| std::env::var_os(key))
}
fn config_dir_with(os: &str, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
if let Some(root) = env("DESIGNCRAFT_CONFIG_DIR") {
// An explicitly empty override must not fall back to the user's profile.
return (!root.is_empty()).then(|| PathBuf::from(root));
}
match os {
"macos" => env("HOME").map(|p| PathBuf::from(p).join("Library/Application Support/DesignCraft")),
"windows" => env("APPDATA").map(|p| PathBuf::from(p).join("DesignCraft")),
_ => {
env("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| env("HOME").map(|p| PathBuf::from(p).join(".config"))).map(|p| p.join("designcraft"))
}
}
}
pub fn recovery_dir() -> Option<PathBuf> {
recovery_dir_with(std::env::var_os("DESIGNCRAFT_CONFIG_DIR"), designcraft_engine::recovery::default_dir).and_then(own_run)
}
/// This run's folder in the recovery folder `root`. A second running DesignCraft has its own, so
/// neither writes over the other's documents nor takes them for a crash's.
fn own_run(root: PathBuf) -> Option<PathBuf> {
match designcraft_engine::recovery::run_dir(&root) {
Ok(dir) => Some(dir),
Err(e) => {
log::warn!("recovery is unavailable because this run cannot be isolated: {e}");
None
}
}
}
fn recovery_dir_with(root: Option<OsString>, default: impl FnOnce() -> Option<PathBuf>) -> Option<PathBuf> {
match root {
Some(root) => (!root.is_empty()).then(|| PathBuf::from(root).join("Recovery")),
None => default(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn environment(key: &str) -> Option<OsString> {
match key {
"HOME" => Some("/user".into()),
"APPDATA" => Some("/roaming".into()),
"XDG_CONFIG_HOME" => Some("/xdg-config".into()),
_ => None,
}
}
#[test]
fn isolated_profile_overrides_every_platform_and_recovery() {
for os in ["macos", "windows", "linux"] {
let dir = config_dir_with(os, |key| if key == "DESIGNCRAFT_CONFIG_DIR" { Some("/isolated".into()) } else { environment(key) });
assert_eq!(dir, Some(PathBuf::from("/isolated")));
}
assert_eq!(recovery_dir_with(Some("/isolated".into()), || Some("/user/recovery".into())), Some(PathBuf::from("/isolated/Recovery")));
}
#[test]
fn empty_override_never_reads_or_writes_the_default_profile() {
assert_eq!(config_dir_with("macos", |key| { if key == "DESIGNCRAFT_CONFIG_DIR" { Some(OsString::new()) } else { environment(key) } }), None);
assert_eq!(recovery_dir_with(Some(OsString::new()), || Some("/user/recovery".into())), None);
}
#[test]
fn unset_override_preserves_existing_platform_paths() {
assert_eq!(config_dir_with("macos", environment), Some("/user/Library/Application Support/DesignCraft".into()));
assert_eq!(config_dir_with("windows", environment), Some("/roaming/DesignCraft".into()));
assert_eq!(config_dir_with("linux", environment), Some("/xdg-config/designcraft".into()));
assert_eq!(config_dir_with("linux", |key| if key == "HOME" { environment(key) } else { None }), Some("/user/.config/designcraft".into()));
assert_eq!(recovery_dir_with(None, || Some("/existing/recovery".into())), Some("/existing/recovery".into()));
}
#[test]
fn a_run_has_its_own_recovery_folder_when_one_can_be_made() {
let dir = std::env::temp_dir().join(format!("designcraft-profile-run-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let root = dir.join("Recovery");
assert_eq!(own_run(root.clone()).as_deref().and_then(|dir| dir.parent()), Some(root.as_path()));
// Failure must not fall back to the shared root.
let blocked = dir.join("blocked");
std::fs::write(&blocked, "a file where the folder should be").unwrap();
assert_eq!(own_run(blocked.join("Recovery")), None, "isolation failure must not select the shared root");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_exhausted_run_namespace_never_falls_back_to_shared_recovery() {
let root = std::env::temp_dir().join(format!("designcraft-profile-exhausted-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
for n in 0..64 {
std::fs::create_dir(root.join(format!("run-{}-{n}", std::process::id()))).unwrap();
}
assert_eq!(own_run(root.clone()), None, "a shared root would restore inter-process UID collisions");
let _ = std::fs::remove_dir_all(&root);
}
}

View File

@ -104,9 +104,12 @@ impl Gradient {
out
}
pub fn reverse(&mut self) {
// A midpoint belongs to the segment after its stop, so it moves with that segment and mirrors.
let mids: Vec<_> = self.stops.iter().rev().skip(1).map(|s| 1.0 - s.midpoint).collect();
self.stops.reverse();
for s in &mut self.stops {
for (s, m) in self.stops.iter_mut().zip(mids.into_iter().chain([0.5])) {
s.offset = 1.0 - s.offset;
s.midpoint = m;
}
}
pub fn sort(&mut self) {
@ -219,8 +222,24 @@ mod tests {
#[test]
fn reverse_stops() {
let mut g = Gradient::default();
g.stops[0].midpoint = 0.25;
g.reverse();
assert_eq!(g.stops[0].color.to_hex(), "#000000");
assert_eq!(g.stops[0].offset, 0.0);
// The midpoint moves to its reversed segment and mirrors.
assert!((g.sample(0.75).0.to_rgb()[0] - 0.5).abs() < 1e-5);
assert_eq!(g.stops.iter().map(|s| s.midpoint).collect::<Vec<_>>(), [0.75, 0.5]);
// With several stops, the reversed gradient is the original mirrored.
let mut g = Gradient::default();
g.stops[0].midpoint = 0.3;
g.stops.insert(1, GradientStop { offset: 0.4, color: Color::rgb(1.0, 0.0, 0.0), opacity: 0.5, midpoint: 0.8 });
let original = g.clone();
g.reverse();
for t in [0.1, 0.3, 0.5, 0.7, 0.9] {
let ((c, o), (want_c, want_o)) = (g.sample(t), original.sample(1.0 - t));
assert!(c.to_rgb().iter().zip(want_c.to_rgb()).all(|(a, b)| (a - b).abs() < 1e-5), "colour at {t}");
assert!((o - want_o).abs() < 1e-5, "opacity at {t}");
}
}
}

View File

@ -10,6 +10,7 @@ workspace = true
[dependencies]
unicode-bidi = { workspace = true }
unicode-segmentation = { workspace = true }
unicode-bidi-mirroring = { workspace = true }
designcraft-geom = { workspace = true }
designcraft-color = { workspace = true }
@ -18,7 +19,7 @@ designcraft-fonts = { workspace = true }
log = { workspace = true }
regex = { workspace = true }
miniz_oxide = "0.8"
hypher = { version = "0.1.8", default-features = false, features = ["alloc", "spanish"] }
hypher = { version = "0.1.8", default-features = false, features = ["alloc", "portuguese", "spanish"] }
[dev-dependencies]
designcraft-fonts = { workspace = true, features = ["testing"] }

View File

@ -148,24 +148,46 @@ struct Item {
hang: f64,
/// Auto hyphenation points: index of the break opportunity before the word (or `NONE`).
zone_from: usize,
/// An emergency break inside a word wider than the measure.
emergency: bool,
}
const NONE: usize = usize::MAX;
impl Item {
fn boxed(w: f64, st: [f64; 3], sh: [f64; 3]) -> Item {
Item { kind: Kind::Box, w, st, sh, p: 0.0, flagged: false, auto: false, hang: 0.0, zone_from: NONE }
Item { kind: Kind::Box, w, st, sh, p: 0.0, flagged: false, auto: false, hang: 0.0, zone_from: NONE, emergency: false }
}
fn glue(w: f64, st: f64, sh: f64) -> Item {
Item { kind: Kind::Glue, w, st: [st, 0.0, 0.0], sh: [sh, 0.0, 0.0], p: 0.0, flagged: false, auto: false, hang: 0.0, zone_from: NONE }
Item {
kind: Kind::Glue,
w,
st: [st, 0.0, 0.0],
sh: [sh, 0.0, 0.0],
p: 0.0,
flagged: false,
auto: false,
hang: 0.0,
zone_from: NONE,
emergency: false,
}
}
fn penalty(w: f64, p: f64, flagged: bool) -> Item {
Item { kind: Kind::Penalty, w, st: [0.0; 3], sh: [0.0; 3], p, flagged, auto: false, hang: 0.0, zone_from: NONE }
Item { kind: Kind::Penalty, w, st: [0.0; 3], sh: [0.0; 3], p, flagged, auto: false, hang: 0.0, zone_from: NONE, emergency: false }
}
}
const INF: f64 = 10000.0;
/// A line whose natural width exceeds its measure by no more than this (points) still fits: frame
/// widths and glyph advances are rounded, so a line that exactly fills the measure can come out a
/// hair too wide.
const FIT_EPS: f64 = 0.01;
/// Penalty for an emergency break inside a word wider than the measure: high, so a paragraph
/// takes no more of them than it must, but below [`INF`] so they are legal.
const EMERGENCY_PENALTY: f64 = 5000.0;
/// Does the glyph force a line end after it?
/// A line may break between `a` and `b` in CJK text (any break next to an ideograph, kana or
/// hangul), except where kinsoku forbids it: no line starts with closing punctuation, small
@ -274,7 +296,8 @@ fn push_inword(it: &mut Vec<Item>, ig: &mut Vec<usize>, p: Item, g: usize, sp: &
}
/// Build Knuth items. `item_glyph[k]` = glyph index of item k (penalties: the glyph *before* which the break happens).
fn items(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing) -> (Vec<Item>, Vec<usize>) {
/// `emergency_after[i]` allows an emergency break after glyph `i` (see [`overlong_words`]).
fn items(glyphs: &[Glyph], hyph_after: &[bool], emergency_after: &[bool], sp: &Spacing) -> (Vec<Item>, Vec<usize>) {
let mut it: Vec<Item> = Vec::with_capacity(glyphs.len() * 2 + 2);
let mut ig = Vec::with_capacity(glyphs.len() * 2 + 2);
let n = glyphs.len();
@ -352,6 +375,10 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing) -> (Vec<Item>, Vec
p.hang = hw * hang('-').1;
}
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);
p.emergency = true;
push_inword(&mut it, &mut ig, p, i + 1, sp);
}
}
}
@ -384,7 +411,11 @@ pub fn knuth_plass(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: &
if glyphs.is_empty() {
return vec![Break { start: 0, end: 0, next: 0, hyphen: false, forced: true }];
}
let (items, ig) = items(glyphs, hyph_after, sp);
let (mut items, mut ig) = items(glyphs, hyph_after, &[], sp);
let narrowest = (0..=glyphs.len().min(64)).map(width).fold(f64::INFINITY, f64::min).max(1.0);
if let Some(emergency_after) = overlong_words(&items, &ig, glyphs, narrowest) {
(items, ig) = self::items(glyphs, hyph_after, &emergency_after, sp);
}
for tolerance in [2.5, 12.0, f64::INFINITY] {
if let Some(b) = kp_pass(&items, &ig, glyphs, sp, width, tolerance, tolerance.is_infinite()) {
return b;
@ -393,6 +424,46 @@ pub fn knuth_plass(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: &
greedy(glyphs, hyph_after, sp, width, &|_, _, _| 0.0)
}
/// Glyphs after which a word wider than `measure` may take an emergency break, or `None` when every
/// word fits. A word is a run of boxes between two break opportunities (so a word with hyphenation
/// points only gets emergency breaks where a part between them is still too wide), measured at
/// its narrowest (fully shrunk) width. Emergency breaks respect no-break text and kinsoku.
fn overlong_words(items: &[Item], ig: &[usize], glyphs: &[Glyph], measure: f64) -> Option<Vec<bool>> {
let mut out: Option<Vec<bool>> = None;
let mut seg: Option<(usize, usize)> = None;
let mut w = 0.0;
for (k, it) in items.iter().enumerate() {
match it.kind {
Kind::Box => {
let g = ig.get(k).copied().unwrap_or(0);
seg = Some(seg.map_or((g, g), |(a, _)| (a, g)));
w += it.w - it.sh.iter().sum::<f64>();
}
Kind::Penalty if it.p >= INF => {}
Kind::Glue | Kind::Penalty => {
if let Some((a, b)) = seg.take()
&& w > measure + FIT_EPS
{
let marks = out.get_or_insert_with(|| vec![false; glyphs.len()]);
for i in a..b {
let (Some(g), Some(next)) = (glyphs.get(i), glyphs.get(i + 1)) else { continue };
let ok = !g.no_break
&& g.break_after != Some(false)
&& next.ch != SOFT_HYPHEN
&& !is_forced(next.ch)
&& kinsoku_allows(g.ch, next.ch);
if let Some(m) = marks.get_mut(i) {
*m = ok;
}
}
}
w = 0.0;
}
}
}
out
}
/// Upper bound on the hyphen-count states tracked per breakpoint.
const MAX_HYPHEN_STATES: usize = 8;
@ -454,7 +525,15 @@ fn kp_pass(
y[t] = sy[b][t] - n.ty[t];
z[t] = sz[b][t] - n.tz[t];
}
let (r, feasible) = tiered_ratio(target - l, y, z);
// A line that overruns its measure by a rounding error fits exactly.
let d = target - l;
let d = if d < 0.0 && d > -FIT_EPS { 0.0 } else { d };
let (mut r, feasible) = tiered_ratio(d, y, z);
if it.emergency && sp.justify && r > 0.0 {
// A piece of an overlong word usually has no elastic material: rate it by the
// space it leaves, so the fullest piece wins (the break at the last glyph that fits).
r = d / target;
}
if feasible && !forced {
keep.push(a);
} else {
@ -675,7 +754,7 @@ pub fn greedy(
}
let hang_r = right_hang(g, sp);
let adv = if g.ch == '\t' { tab(line, x, i) } else { g.adv };
if x + adv - hang_r > w + shrink
if x + adv - hang_r > w + shrink + FIT_EPS
&& i > start
&& (last_ok.is_some() || glyphs.get(i - 1).is_none_or(|p| p.break_after != Some(false) && !p.no_break))
{
@ -701,7 +780,7 @@ pub fn greedy(
last_ok = Some((i + 1, false));
} 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 };
if x + hy <= w + shrink {
if x + hy <= w + shrink + FIT_EPS {
last_ok = Some((i + 1, true));
}
}

View File

@ -13,7 +13,8 @@ use designcraft_doc::{Document, Item, Story, StoryId, Styles, WrapMode};
use crate::vars::RunningIndex;
use crate::{ComposeOptions, ComposedStory};
type Key = (StoryId, Option<String>);
/// A story, composed for one absolute page (parent-page items) or for its own frames (`None`).
type Key = (StoryId, Option<usize>);
struct Entry {
sig: Vec<usize>,
@ -55,11 +56,23 @@ impl Cache {
Self::default()
}
/// The composed story, from cache or freshly composed.
pub fn get(&self, doc: &Document, sid: StoryId, page_name: Option<&str>) -> Arc<ComposedStory> {
/// The composed story, from cache or freshly composed. `page` is the absolute page a
/// parent-page item is shown on (its page number markers and variables are that page's), or
/// `None` for a story's own frames. Pages are identified by index, not name: names repeat when
/// sections restart their numbering.
pub fn get(&self, doc: &Document, sid: StoryId, page: Option<usize>) -> Arc<ComposedStory> {
let Some(story) = doc.stories.get(&sid) else { return Arc::new(ComposedStory { story: sid, ..Default::default() }) };
let (mut sig, keep_items) = signature(doc, story);
let key = (sid, page_name.map(str::to_string));
let key = (sid, page);
// 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.
let page_name = page.map(|p| doc.page_name(p));
if let Some(name) = &page_name {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
name.hash(&mut h);
sig.push(h.finish() as usize);
}
let with_vars = crate::vars::has_vars(story);
let running = with_vars && crate::vars::has_running(doc, story);
if with_vars {
@ -125,23 +138,20 @@ impl Cache {
return e.out.clone();
}
}
// Parent-page items are composed per page; variables need that page's index.
let page = match page_name {
Some(n) if with_vars => (0..doc.page_count()).find(|&i| doc.page_name(i) == n),
_ => None,
};
// 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 out = Arc::new(crate::compose_story(
doc,
sid,
&ComposeOptions { page_name: page_name.map(str::to_string), page, running, label: None, xrefs: with_xrefs.then(|| self.xref_index(doc)) },
&ComposeOptions { page_name, page, running, label: None, xrefs: with_xrefs.then(|| self.xref_index(doc)) },
));
let mut g = self.map.lock().unwrap_or_else(|e| e.into_inner());
let stamp = g.1;
if g.0.len() > 4096 {
g.0.retain(|_, e| stamp - e.stamp < 64);
}
let replaced = g.0.insert(key.clone(), Entry { sig, _keep: (story.clone(), doc.styles.clone(), keep_items), out: out.clone(), stamp });
let replaced = g.0.insert(key, Entry { sig, _keep: (story.clone(), doc.styles.clone(), keep_items), out: out.clone(), stamp });
drop(g);
if key.1.is_none()
&& let Some(old) = replaced

View File

@ -1,6 +1,7 @@
//! Hyphenation. English uses the bundled dictionary and patterns below. Other languages use the
//! Liang patterns compiled into `hypher`, one cargo feature per language: Spanish is Javier Bezos's
//! `hyph-es.tex` 5.0 from hyph-utf8, MIT (see NOTICE).
//! `hyph-es.tex` 5.0 from hyph-utf8, MIT; Portuguese is `hyph-pt.tex` 1.4 from hyph-utf8 (Pedro J.
//! de Rezende, J. Joao Dias Almeida, Leonardo Araujo, Aline Benevides), BSD-3-Clause (see NOTICE).
//!
//! English lookup order for each word:
//! 1. the **dictionary** ([`Dictionary::en_us`]) — the public-domain Moby Hyphenator II word list
@ -232,9 +233,8 @@ pub fn hyphen_points_in(word: &str, lim: &Limits, lang: Lang) -> Vec<usize> {
if letters < lim.min_word.max(2) {
return false;
}
if letters > 1 && run.iter().filter(|c| c.is_alphabetic()).all(|c| c.is_uppercase()) {
return false;
}
// Words in capitals (FERRUM COMMODI) hyphenate like capitalized words, as InDesign's: Hyphenate
// Capitalized Words off keeps both, acronyms included, whole.
if !lim.capitalized && run[0].is_uppercase() {
return false;
}
@ -332,7 +332,10 @@ mod tests {
#[test]
fn case_apostrophes_and_punctuation() {
assert_eq!(h("Typography"), "Ty-pog-ra-phy");
assert_eq!(h("TYPOGRAPHY"), "TYPOGRAPHY");
assert_eq!(h("TYPOGRAPHY"), "TY-POG-RA-PHY");
assert_eq!(h("COMMODI"), "COM-MO-DI");
let whole = Limits { capitalized: false, ..Limits::default() };
assert_eq!(hyphenate_word("TYPOGRAPHY", &whole), "TYPOGRAPHY", "Hyphenate Capitalized Words off");
assert_eq!(h("typography,"), "ty-pog-ra-phy,");
assert_eq!(h("“typography”"), "“ty-pog-ra-phy”");
assert_eq!(h("typography's"), "ty-pog-ra-phy's");
@ -389,6 +392,25 @@ mod tests {
assert!(Lang::for_language("French").is_none(), "only languages built in");
}
#[test]
fn portuguese_uses_the_hyph_pt_patterns() {
let pt = Lang::for_language("Portuguese: Brazilian").unwrap();
assert_eq!(pt, Lang::for_language("Portuguese").unwrap());
assert_eq!(pt, Lang::for_language("pt_BR").unwrap());
assert_ne!(pt, Lang::for_language("Spanish").unwrap());
// hyph-pt's own minimum: three letters after the last break.
let lim = Limits { before_last: 3, ..Limits::default() };
let h = |w: &str| {
let pts = hyphen_points_in(w, &lim, pt);
w.chars().enumerate().map(|(i, c)| if pts.contains(&i) { format!("-{c}") } else { c.to_string() }).collect::<String>()
};
assert_eq!(h("matemática"), "ma-te-má-tica");
assert_eq!(h("orientação"), "ori-en-ta-ção");
assert_eq!(h("Universidade"), "Uni-ver-si-dade");
assert_eq!(h("experiência"), "ex-pe-ri-ên-cia");
assert_eq!(h("pós-graduação"), "pós-gra-du-a-ção", "each part of a compound");
}
#[test]
fn heuristic_fallback() {
let c: Vec<char> = "happen".chars().collect();

File diff suppressed because it is too large Load Diff

View File

@ -197,10 +197,15 @@ pub fn find_note(cs: &ComposedStory, id: u64) -> Option<(usize, &PlacedNote)> {
/// Footnote text hit at frame-inner point `p`: (footnote id, byte in the footnote's story).
pub fn hit_note(cs: &ComposedStory, fi: usize, p: Point) -> Option<(u64, usize)> {
hit_note_with(cs, fi, p, crate::hit)
}
/// [`hit_note`] with the hit test for the note's text (see [`crate::hit_cell_with`]).
pub fn hit_note_with(cs: &ComposedStory, fi: usize, p: Point, hit: fn(&ComposedStory, usize, Point) -> Option<usize>) -> Option<(u64, usize)> {
let ft = cs.frames.get(fi)?;
let n = ft.notes.iter().find(|n| n.rect.contains(p))?;
let local = Point::new(p.x - n.origin.x, p.y - n.origin.y);
Some((n.id, crate::hit(&n.text, 0, local).unwrap_or(0)))
Some((n.id, hit(&n.text, 0, local).unwrap_or(0)))
}
/// Caret geometry for byte `pos` of a footnote's story, in frame inner space:

View File

@ -22,12 +22,15 @@ fn is_event(u: &NestedUntil, c: char, prev: Option<char>, next: Option<char>) ->
NestedUntil::ForcedLineBreak => c == designcraft_doc::FORCED_LINE_BREAK,
NestedUntil::EmSpace => c == '\u{2003}',
NestedUntil::EnSpace => c == '\u{2002}',
// Counted by position, not by character (see `overlays`).
NestedUntil::Dropcap => false,
NestedUntil::Chars(s) => s.contains(c),
}
}
/// Character-style ranges for the paragraph `text[range]`, in application order (later wins).
pub(crate) fn overlays(text: &str, range: Range<usize>, nested: &[NestedStyle], grep: &[GrepStyle]) -> Vec<(Range<usize>, String)> {
/// The paragraph's drop cap ends at `drop_end` (`range.start` without one).
pub(crate) fn overlays(text: &str, range: Range<usize>, nested: &[NestedStyle], grep: &[GrepStyle], drop_end: usize) -> Vec<(Range<usize>, String)> {
let mut out = Vec::new();
let para = &text[range.clone()];
// The paragraph's own end mark isn't styled.
@ -37,17 +40,22 @@ pub(crate) fn overlays(text: &str, range: Range<usize>, nested: &[NestedStyle],
if pos >= end {
break;
}
let mut count = 0;
let mut stop = end;
let chars: Vec<(usize, char)> = text[pos..end].char_indices().map(|(i, c)| (pos + i, c)).collect();
for (k, &(i, c)) in chars.iter().enumerate() {
let prev = if k > 0 { Some(chars[k - 1].1) } else { None };
let next = chars.get(k + 1).map(|x| x.1);
if is_event(&ns.until, c, prev, next) {
count += 1;
if count >= ns.count.max(1) {
stop = if ns.through { i + c.len_utf8() } else { i };
break;
if ns.until == NestedUntil::Dropcap {
// Through the drop cap runs to its end; up to it (it starts the paragraph) is nothing.
stop = if ns.through { drop_end.max(pos).min(end) } else { pos };
} else {
let mut count = 0;
let chars: Vec<(usize, char)> = text[pos..end].char_indices().map(|(i, c)| (pos + i, c)).collect();
for (k, &(i, c)) in chars.iter().enumerate() {
let prev = if k > 0 { Some(chars[k - 1].1) } else { None };
let next = chars.get(k + 1).map(|x| x.1);
if is_event(&ns.until, c, prev, next) {
count += 1;
if count >= ns.count.max(1) {
stop = if ns.through { i + c.len_utf8() } else { i };
break;
}
}
}
}
@ -84,14 +92,21 @@ mod tests {
let text = "Q: what is 42? A drop of 7 words.\n".to_string();
let n = |style: &str, through: bool, count: u32, until: NestedUntil| NestedStyle { style: style.into(), through, count, until };
// Through the first ':' then up to the second word end.
let o = overlays(&text, 0..text.len(), &[n("Lead", true, 1, NestedUntil::Chars(":".into())), n("Next", false, 2, NestedUntil::Words)], &[]);
let o =
overlays(&text, 0..text.len(), &[n("Lead", true, 1, NestedUntil::Chars(":".into())), n("Next", false, 2, NestedUntil::Words)], &[], 0);
assert_eq!(&text[o[0].0.clone()], "Q:");
assert_eq!(&text[o[1].0.clone()], " what is", "up to the end of the second word");
// Digits by GREP.
let o = overlays(&text, 0..text.len(), &[], &[GrepStyle { style: "Num".into(), pattern: r"\d+".into() }]);
let o = overlays(&text, 0..text.len(), &[], &[GrepStyle { style: "Num".into(), pattern: r"\d+".into() }], 0);
let got: Vec<&str> = o.iter().map(|(r, _)| &text[r.clone()]).collect();
assert_eq!(got, ["42", "7"]);
// Through the drop cap (its first 2 bytes here); up to it is nothing; without one, nothing.
let d = |through: bool| n("Cap", through, 1, NestedUntil::Dropcap);
let o = overlays(&text, 0..text.len(), &[d(true), n("Next", true, 1, NestedUntil::Words)], &[], 2);
assert_eq!((&text[o[0].0.clone()], &text[o[1].0.clone()]), ("Q:", " what "));
assert_eq!(&text[overlays(&text, 0..text.len(), &[d(false), n("Next", true, 1, NestedUntil::Words)], &[], 2)[0].0.clone()], "Q: ");
assert!(overlays(&text, 0..text.len(), &[d(true)], &[], 0).is_empty());
// A bad pattern is ignored.
assert!(overlays(&text, 0..text.len(), &[], &[GrepStyle { style: "X".into(), pattern: "(".into() }]).is_empty());
assert!(overlays(&text, 0..text.len(), &[], &[GrepStyle { style: "X".into(), pattern: "(".into() }], 0).is_empty());
}
}

View File

@ -28,9 +28,14 @@ pub struct Glyph {
/// Baseline shift (positive = up), including super/subscript.
pub shift: f64,
pub ascent: f64,
/// The font's typographic ascender at the point size (vertical scale doesn't apply): the Ascent
/// first baseline offset.
pub typo_ascent: f64,
pub descent: f64,
/// The leading this character asks for (absolute, or auto = size × auto %).
pub leading: f64,
/// Cap height and x height, scaled like `ascent`: the Cap Height and x Height first baseline
/// offsets.
pub cap: f64,
pub xh: f64,
pub size: f64,
@ -64,6 +69,9 @@ pub struct Glyph {
/// Upright in a vertical frame: shaped top to bottom (`adv` is its vertical advance) and hung
/// from its vertical origin on the line's centre (see [`crate::PlacedGlyph::vertical_xf`]).
pub upright: bool,
/// Part of the bullet or number the paragraph's list adds: not the line's own text, so it
/// doesn't raise the Ascent first baseline.
pub list_label: bool,
}
impl Glyph {
@ -185,7 +193,8 @@ impl StyleTable<'_> {
}
}
/// Resolve and shape one paragraph.
/// Resolve and shape one paragraph. Its drop cap ends at byte `drop.0` (`range.start` without
/// one) and takes the character style `drop.1` ("" = none).
pub(crate) fn shape_para(
db: &ScopedFonts<'_>,
styles: &Styles,
@ -199,13 +208,37 @@ pub(crate) fn shape_para(
nested: &[designcraft_doc::NestedStyle],
grep: &[designcraft_doc::GrepStyle],
lines: &[(std::ops::Range<usize>, String)],
drop: (usize, &str),
) -> ShapedPara {
let _ = pi;
let mut glyphs = Vec::with_capacity(range.len());
// Nested line styles lie under nested and GREP styles (later overlays win).
// Nested line styles lie under nested and GREP styles, and the drop cap's style over them all
// (later overlays win).
let mut overlays: Vec<(std::ops::Range<usize>, String)> = lines.to_vec();
// The drop cap's style is the nested styles' leading "through 1 Dropcap" entry (the form IDML
// stores it in). Set (`[None]` included), it restyles that entry; a real one stands in for a
// missing entry. Either way the nested styles after it start after the drop cap.
let set = Some(drop.1).filter(|s| !s.is_empty());
let cap_style = set.filter(|s| *s != designcraft_doc::NO_CHAR_STYLE);
let with_cap: Vec<designcraft_doc::NestedStyle>;
let nested = match set {
Some(s) if nested.first().is_some_and(designcraft_doc::NestedStyle::is_drop_cap) => {
with_cap = std::iter::once(designcraft_doc::NestedStyle::drop_cap(s)).chain(nested.iter().skip(1).cloned()).collect();
&with_cap[..]
}
Some(s) if cap_style.is_some() && !nested.is_empty() => {
with_cap = std::iter::once(designcraft_doc::NestedStyle::drop_cap(s)).chain(nested.iter().cloned()).collect();
&with_cap[..]
}
_ => nested,
};
if !nested.is_empty() || !grep.is_empty() {
overlays.extend(crate::overlay::overlays(&story.text, range.clone(), nested, grep));
overlays.extend(crate::overlay::overlays(&story.text, range.clone(), nested, grep, drop.0));
}
if let Some(s) = cap_style
&& drop.0 > range.start
{
overlays.push((range.start..drop.0, s.to_string()));
}
// Each run, cut where nested / GREP styles start and end.
let mut segments: Vec<(usize, usize, Option<&str>, &designcraft_doc::CharFormat)> = Vec::new();
@ -273,7 +306,7 @@ pub(crate) fn shape_para(
}
let mut g = control_glyph(&face, &props, auto_leading, style, a + i, c);
g.ch = HIDDEN;
(g.ascent, g.descent, g.leading, g.cap, g.xh) = (0.0, 0.0, 0.0, 0.0, 0.0);
(g.ascent, g.typo_ascent, g.descent, g.leading, g.cap, g.xh) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
glyphs.push(g);
}
continue;
@ -597,10 +630,18 @@ fn shape_run_raw(
Some(y) => {
g.adv = o.w;
g.ascent = g.ascent.max(o.h + y);
g.typo_ascent = g.typo_ascent.max(o.h + y);
g.cap = g.cap.max(o.h + y);
g.xh = g.xh.max(o.h + y);
g.descent = g.descent.max(-y);
}
// Pushes its line down by its height and spacing.
None => g.ascent += o.h + o.space,
None => {
g.ascent += o.h + o.space;
g.typo_ascent += o.h + o.space;
g.cap += o.h + o.space;
g.xh += o.h + o.space;
}
}
if auto || o.y_offset.is_none() {
g.leading = g.leading.max(g.ascent + g.descent);
@ -771,6 +812,7 @@ fn control_glyph(face: &Arc<FontFace>, p: &CharProps, auto_leading: TypeEnv, sty
sy: k * p.v_scale,
shift,
ascent,
typo_ascent: face.typo_ascent * k,
descent,
leading,
cap,
@ -796,6 +838,7 @@ fn control_glyph(face: &Arc<FontFace>, p: &CharProps, auto_leading: TypeEnv, sty
shaping_rtl: false,
lang: designcraft_doc::language_tag(&p.language),
upright: false,
list_label: false,
}
}
@ -889,6 +932,7 @@ fn shape_segment(
sy: k * p.v_scale,
shift,
ascent,
typo_ascent: face.typo_ascent * k,
descent,
leading,
cap,
@ -918,6 +962,7 @@ fn shape_segment(
shaping_rtl: sg.rtl,
lang,
upright: up,
list_label: false,
});
}
}

View File

@ -442,6 +442,7 @@ fn emit(
spacing: 1.0,
hj: 0,
keep_violation: false,
drop_cap: None,
});
ft.tables.push(TableFrag {
table: table.id,
@ -602,6 +603,17 @@ fn fragment_strokes(
/// The cell under `p` (frame inner space) in frame `fi`: (table, row, col, story byte in the cell).
pub fn hit_cell(cs: &ComposedStory, fi: usize, p: Point) -> Option<(u64, usize, usize, usize)> {
hit_cell_with(cs, fi, p, crate::hit)
}
/// [`hit_cell`] with the hit test for the cell's text: [`crate::hit`] (caret position) or
/// [`crate::hit_char`] (character under the point).
pub fn hit_cell_with(
cs: &ComposedStory,
fi: usize,
p: Point,
hit: fn(&ComposedStory, usize, Point) -> Option<usize>,
) -> Option<(u64, usize, usize, usize)> {
let ft = cs.frames.get(fi)?;
for t in &ft.tables {
if !t.rect.contains(p) {
@ -610,7 +622,7 @@ pub fn hit_cell(cs: &ComposedStory, fi: usize, p: Point) -> Option<(u64, usize,
for c in &t.cells {
if c.rect.contains(p) {
let local = Point::new(p.x - c.origin.x, p.y - c.origin.y);
let b = crate::hit(&c.text, 0, local).unwrap_or(0);
let b = hit(&c.text, 0, local).unwrap_or(0);
return Some((t.table, c.row, c.col, b));
}
}

File diff suppressed because it is too large Load Diff

351
crates/doc/src/assets.rs Normal file
View File

@ -0,0 +1,351 @@
//! Placed files (assets): one asset per distinct file, the assets a document uses, and the
//! compaction a save applies (duplicates merged, unused assets left out).
//!
//! An asset holds what is shared by every placement of a file (bytes, link, type, the PDF page
//! shown); each placement's frame data (transform, crop, fitting) lives on its [`Graphic`].
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::sync::Arc;
use crate::story::Story;
use crate::{Asset, AssetId, Content, Document, Item};
/// Nesting limit for groups, anchored objects, table cells and notes. A document nested deeper
/// is treated as using every asset, so nothing it references is merged away or pruned.
const MAX_DEPTH: usize = 64;
impl Asset {
/// Whether `other` holds the same file: the same type, page, link and bytes. Files at
/// different paths stay apart even with identical bytes, since each can be updated or
/// relinked on its own. Assets without bytes (a link that couldn't be read) are the same only
/// when they link to the same path.
pub fn same_file(&self, other: &Asset) -> bool {
if self.mime != other.mime || self.page != other.page {
return false;
}
if self.link != other.link {
return false;
}
match (self.data.is_empty(), other.data.is_empty()) {
(false, false) => self.same_bytes(other),
(true, true) => self.link.is_some(),
_ => false,
}
}
fn same_bytes(&self, other: &Asset) -> bool {
Arc::ptr_eq(&self.data, &other.data) || (self.data.len() == other.data.len() && *self.data == *other.data)
}
}
/// The asset in `assets` holding the same file as `asset`.
pub fn find_same_asset(assets: &BTreeMap<AssetId, Arc<Asset>>, asset: &Asset) -> Option<AssetId> {
assets.values().find(|a| a.same_file(asset)).map(|a| a.id)
}
/// Add `asset` to `assets` unless they already hold the same file; returns the id placements
/// should reference. A new asset with the bytes of an existing one shares its buffer.
pub fn insert_asset(assets: &mut BTreeMap<AssetId, Arc<Asset>>, mut asset: Asset) -> AssetId {
if let Some(id) = find_same_asset(assets, &asset) {
return id;
}
if !asset.data.is_empty()
&& let Some(a) = assets.values().find(|a| a.same_bytes(&asset))
{
asset.data = Arc::clone(&a.data);
}
let id = asset.id;
assets.insert(id, Arc::new(asset));
id
}
/// Calls `f` with every asset reference in `it` and its children; false when nesting exceeded
/// [`MAX_DEPTH`] (some references weren't visited).
fn item_refs(it: &Item, f: &mut impl FnMut(AssetId), depth: usize) -> bool {
if depth > MAX_DEPTH {
return false;
}
if let Content::Graphic(g) = &it.content {
f(g.asset);
}
if let Some(p) = it.media.as_ref().and_then(|m| m.poster) {
f(p);
}
it.children().iter().all(|c| item_refs(c, f, depth + 1))
}
/// Asset references in a story's anchored objects, graphic cells and notes (recursively).
fn story_refs(st: &Story, f: &mut impl FnMut(AssetId), depth: usize) -> bool {
if depth > MAX_DEPTH {
return false;
}
let mut complete = st.objects.iter().all(|o| item_refs(&o.item, f, depth + 1));
for t in st.tables.values() {
for c in &t.cells {
if let Some(g) = &c.graphic {
f(g.asset);
}
complete &= story_refs(&c.text, f, depth + 1);
}
}
for n in st.notes.iter().chain(&st.endnotes) {
complete &= story_refs(&n.text, f, depth + 1);
}
complete
}
/// Every asset reference in a story (its anchored objects, graphic cells and notes).
pub fn story_asset_refs(st: &Story) -> Vec<AssetId> {
let mut out = Vec::new();
story_refs(st, &mut |a| out.push(a), 0);
out
}
/// Point a story's references to a key of `map` at its value.
pub fn remap_story_assets(st: &mut Story, map: &HashMap<AssetId, AssetId>) {
if !map.is_empty() {
remap_story(st, map, 0);
}
}
fn item_uses(it: &Item, map: &HashMap<AssetId, AssetId>) -> bool {
let mut hit = false;
item_refs(it, &mut |a| hit |= map.contains_key(&a), 0);
hit
}
fn story_uses(st: &Story, map: &HashMap<AssetId, AssetId>) -> bool {
let mut hit = false;
story_refs(st, &mut |a| hit |= map.contains_key(&a), 0);
hit
}
fn remap_id(id: &mut AssetId, map: &HashMap<AssetId, AssetId>) {
if let Some(n) = map.get(id) {
*id = *n;
}
}
/// Rewrites the references in `it`, copying only the shared items that change.
fn remap_item(it: &mut Arc<Item>, map: &HashMap<AssetId, AssetId>, depth: usize) {
if depth > MAX_DEPTH || !item_uses(it, map) {
return;
}
remap_item_in(Arc::make_mut(it), map, depth);
}
fn remap_item_in(it: &mut Item, map: &HashMap<AssetId, AssetId>, depth: usize) {
if let Content::Graphic(g) = &mut it.content {
remap_id(&mut g.asset, map);
}
if let Some(p) = it.media.as_mut().and_then(|m| m.poster.as_mut()) {
remap_id(p, map);
}
if let Some(kids) = it.children_mut() {
for k in kids {
remap_item(k, map, depth + 1);
}
}
}
fn remap_story(st: &mut Story, map: &HashMap<AssetId, AssetId>, depth: usize) {
if depth > MAX_DEPTH {
return;
}
for o in &mut st.objects {
if item_uses(&o.item, map) {
remap_item_in(&mut Arc::make_mut(o).item, map, depth + 1);
}
}
for t in st.tables.values_mut() {
if !t.cells.iter().any(|c| c.graphic.as_ref().is_some_and(|g| map.contains_key(&g.asset)) || story_uses(&c.text, map)) {
continue;
}
for c in &mut Arc::make_mut(t).cells {
if let Some(g) = &mut c.graphic {
remap_id(&mut g.asset, map);
}
remap_story(&mut c.text, map, depth + 1);
}
}
for n in st.notes.iter_mut().chain(st.endnotes.iter_mut()) {
if story_uses(&n.text, map) {
remap_story(&mut Arc::make_mut(n).text, map, depth + 1);
}
}
}
impl Document {
/// Add a placed file, reusing the asset of an identical file already in the document;
/// returns the id to reference.
pub fn add_asset(&mut self, asset: Asset) -> AssetId {
insert_asset(&mut self.assets, asset)
}
/// Copy asset `id` of `src` into this document (or reuse the identical asset it already
/// has); returns the id to reference here, `None` when `src` has no such asset.
pub fn adopt_asset(&mut self, src: &Document, id: AssetId) -> Option<AssetId> {
let a = src.assets.get(&id)?;
if let Some(same) = find_same_asset(&self.assets, a) {
return Some(same);
}
let fresh = AssetId(self.alloc());
Some(self.add_asset(Asset { id: fresh, ..(**a).clone() }))
}
/// Calls `f` with every asset reference: graphics on document and parent spreads (in
/// groups and multi-state objects too), media posters, anchored and inline objects, graphic
/// table cells and notes. False when nesting exceeded the depth limit.
fn asset_refs(&self, f: &mut impl FnMut(AssetId)) -> bool {
let mut complete = true;
for sp in self.spreads.iter().chain(&self.parents) {
for it in &sp.items {
complete &= item_refs(it, f, 0);
}
}
for st in self.stories.values() {
complete &= story_refs(st, f, 0);
}
complete
}
/// The assets something in the document references. When the document nests deeper than
/// the traversal limit, every asset counts as used.
pub fn used_assets(&self) -> BTreeSet<AssetId> {
let mut used = BTreeSet::new();
if !self.asset_refs(&mut |a| {
used.insert(a);
}) {
return self.assets.keys().copied().collect();
}
used
}
/// Point every reference to a key of `map` at its value.
pub fn remap_assets(&mut self, map: &HashMap<AssetId, AssetId>) {
if map.is_empty() {
return;
}
for sp in self.spreads.iter_mut().chain(self.parents.iter_mut()) {
if sp.items.iter().any(|it| item_uses(it, map)) {
for it in &mut Arc::make_mut(sp).items {
remap_item(it, map, 0);
}
}
}
for st in self.stories.values_mut() {
if story_uses(st, map) {
remap_story(Arc::make_mut(st), map, 0);
}
}
}
/// Merge assets holding the same file into the first of them (lowest id), rewriting the
/// references; returns how many assets were removed.
pub fn merge_duplicate_assets(&mut self) -> usize {
if !self.asset_refs(&mut |_| {}) {
return 0;
}
let mut reps: HashMap<(usize, &str, u32), Vec<&Arc<Asset>>> = HashMap::new();
let mut map = HashMap::new();
for a in self.assets.values() {
let bucket = reps.entry((a.data.len(), a.mime.as_str(), a.page)).or_default();
match bucket.iter().find(|r| r.same_file(a)) {
Some(r) => {
map.insert(a.id, r.id);
}
None => bucket.push(a),
}
}
self.remap_assets(&map);
self.assets.retain(|id, _| !map.contains_key(id));
map.len()
}
/// Merge duplicate assets and drop the ones nothing references (what a saved file holds).
pub fn compact_assets(&mut self) {
self.merge_duplicate_assets();
let used = self.used_assets();
self.assets.retain(|id, _| used.contains(id));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Graphic, ItemId, LayerId, Shape, StoryId};
use designcraft_geom::{Affine, Rect, shapes};
fn asset(id: u64, data: &[u8]) -> Asset {
Asset { id: AssetId(id), name: "p.png".into(), mime: "image/png".into(), link: None, data: Arc::new(data.to_vec()), pixels: None, page: 0 }
}
fn graphic_item(id: u64, asset: u64, crop: f64) -> Item {
let mut it = Item::new(ItemId(id), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)));
it.content = Content::Graphic(Graphic {
asset: AssetId(asset),
size: (10.0, 10.0),
xf: Affine::translate((crop, 0.0)),
auto_fit: Default::default(),
fit_align: 4,
crop: [crop; 4],
});
it
}
#[test]
fn identical_files_share_one_asset() {
let mut d = Document::new(&Default::default());
let a = d.add_asset(asset(1, b"same"));
let b = d.add_asset(asset(2, b"same"));
let c = d.add_asset(asset(3, b"diff"));
let p = d.add_asset(Asset { page: 1, ..asset(4, b"same") });
assert_eq!(a, b);
assert_ne!(a, c);
assert_ne!(a, p);
assert_eq!(d.assets.len(), 3);
// Unreadable links match by path only.
let m1 = d.add_asset(Asset { link: Some("/m/1.png".into()), ..asset(5, b"") });
let m2 = d.add_asset(Asset { link: Some("/m/2.png".into()), ..asset(6, b"") });
let m3 = d.add_asset(asset(7, b""));
let m4 = d.add_asset(asset(8, b""));
assert_ne!(m1, m2);
assert_ne!(m3, m4);
}
#[test]
fn files_at_different_paths_stay_separate_assets() {
let mut d = Document::new(&Default::default());
let linked = |id, path: &str| Asset { link: Some(path.into()), ..asset(id, b"same") };
let embedded = d.add_asset(asset(1, b"same"));
let a = d.add_asset(linked(2, "/p/a.png"));
let b = d.add_asset(linked(3, "/q/b.png"));
assert_eq!(d.add_asset(linked(4, "/p/a.png")), a, "the same path placed again");
assert_eq!(d.assets.len(), 3);
assert!(embedded != a && a != b && embedded != b);
assert_eq!(d.assets[&embedded].link, None);
assert_eq!(d.assets[&b].link.as_deref(), Some("/q/b.png"));
assert!(Arc::ptr_eq(&d.assets[&a].data, &d.assets[&b].data), "identical bytes share one buffer");
}
#[test]
fn compaction_merges_duplicates_and_drops_unused_assets() {
let mut d = Document::new(&Default::default());
for (id, data) in [(1, b"one"), (2, b"one"), (3, b"two"), (4, b"old")] {
d.assets.insert(AssetId(id), Arc::new(asset(id, data)));
}
let sp = Arc::make_mut(d.spreads.first_mut().unwrap());
sp.items.push(Arc::new(graphic_item(10, 1, 1.0)));
sp.items.push(Arc::new(graphic_item(11, 2, 2.0)));
// A graphic cell and an anchored object in a story.
let mut st = Story::new(StoryId(20));
st.objects.push(Arc::new(crate::AnchoredObject::new(graphic_item(12, 3, 3.0), Default::default())));
d.stories.insert(StoryId(20), Arc::new(st));
assert_eq!(d.used_assets(), [1, 2, 3].map(AssetId).into_iter().collect());
d.compact_assets();
assert_eq!(d.assets.keys().copied().collect::<Vec<_>>(), vec![AssetId(1), AssetId(3)]);
let crops: Vec<(AssetId, f64)> = d.spreads[0].items.iter().filter_map(|i| i.graphic().map(|g| (g.asset, g.crop[0]))).collect();
assert_eq!(crops, vec![(AssetId(1), 1.0), (AssetId(1), 2.0)]);
}
}

View File

@ -431,6 +431,8 @@ pub enum NestedUntil {
ForcedLineBreak,
EmSpace,
EnSpace,
/// The paragraph's drop cap characters.
Dropcap,
/// Any of these characters.
Chars(String),
}
@ -467,6 +469,18 @@ impl Default for NestedStyle {
}
}
impl NestedStyle {
/// The drop cap's character style as a nested style: `style` through 1 drop cap.
pub fn drop_cap(style: &str) -> Self {
NestedStyle { style: style.into(), through: true, count: 1, until: NestedUntil::Dropcap }
}
/// Is this the form a drop cap's character style takes at the start of a nested style list?
pub fn is_drop_cap(&self) -> bool {
self.until == NestedUntil::Dropcap && self.through && self.count == 1
}
}
/// A character style applied to every match of a regular expression in the paragraph.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
@ -530,12 +544,16 @@ pub struct TabStop {
pub align_on: String,
}
/// The colour of a paragraph rule that follows the paragraph's text colour.
pub const TEXT_COLOR: &str = "Text Color";
/// A paragraph rule (Rule Above / Rule Below).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Rule {
pub on: bool,
pub weight: f64,
/// A swatch, or [`TEXT_COLOR`].
pub color: String,
pub tint: f32,
/// Width: column (true) or text (false).
@ -547,7 +565,7 @@ pub struct Rule {
impl Default for Rule {
fn default() -> Self {
Rule { on: false, weight: 1.0, color: "[Black]".into(), tint: 1.0, column_width: true, offset: 0.0, left_indent: 0.0, right_indent: 0.0 }
Rule { on: false, weight: 1.0, color: TEXT_COLOR.into(), tint: 1.0, column_width: true, offset: 0.0, left_indent: 0.0, right_indent: 0.0 }
}
}
@ -571,15 +589,33 @@ pub enum NumberStyle {
LowerLetters,
/// 01, 02, …
ArabicLeadingZero,
/// 001, 002, …
ArabicThreeDigits,
/// 0001, 0002, …
ArabicFourDigits,
/// *, †, ‡, §, ¶, ‖, then doubled (**, ††, …): footnotes.
Symbols,
}
/// Numbers past this are written in Arabic digits by the styles that repeat characters (roman
/// numerals, letters, symbols), so a hostile start number can't build a huge label.
const MAX_REPEATED_FORM: u32 = 3999;
impl NumberStyle {
pub fn format(self, n: u32) -> String {
if n > MAX_REPEATED_FORM
&& matches!(
self,
NumberStyle::UpperRoman | NumberStyle::LowerRoman | NumberStyle::UpperLetters | NumberStyle::LowerLetters | NumberStyle::Symbols
)
{
return n.to_string();
}
match self {
NumberStyle::Arabic => n.to_string(),
NumberStyle::ArabicLeadingZero => format!("{n:02}"),
NumberStyle::ArabicThreeDigits => format!("{n:03}"),
NumberStyle::ArabicFourDigits => format!("{n:04}"),
NumberStyle::UpperRoman => roman(n),
NumberStyle::LowerRoman => roman(n).to_lowercase(),
NumberStyle::UpperLetters => letters(n),
@ -593,6 +629,58 @@ impl NumberStyle {
}
}
/// Expand the metacharacters of a list's label text (`^t` tab, `^>` en space, `^^` caret, …),
/// putting `number` in for `^#`. Level placeholders (`^1`…`^9`) and metacharacters with no
/// meaning in a label are dropped; a label never holds a paragraph break.
pub fn expand_list_text(s: &str, number: &str) -> String {
let mut out = String::with_capacity(s.len() + number.len());
let mut it = s.chars();
while let Some(c) = it.next() {
if c != '^' {
if c != '\n' && c != '\r' {
out.push(c);
}
continue;
}
let Some(m) = it.next() else {
out.push('^');
break;
};
let r = match m {
'#' => {
out.push_str(number);
continue;
}
'^' => '^',
't' => '\t',
'y' => crate::story::RIGHT_INDENT_TAB,
'i' => crate::story::INDENT_HERE,
'n' => crate::story::FORCED_LINE_BREAK,
'm' => '\u{2003}',
'>' => '\u{2002}',
'f' => '\u{2001}',
'|' => '\u{200A}',
'<' => '\u{2009}',
'/' => '\u{2007}',
'.' => '\u{2008}',
's' => '\u{A0}',
'S' => '\u{202F}',
'_' => '\u{2014}',
'=' => '\u{2013}',
'~' => '\u{2011}',
'{' => '\u{201C}',
'}' => '\u{201D}',
'[' => '\u{2018}',
']' => '\u{2019}',
'"' => '"',
'\'' => '\'',
_ => continue,
};
out.push(r);
}
out
}
fn roman(mut n: u32) -> String {
if n == 0 {
return "0".into();
@ -739,8 +827,17 @@ attr_set! {
last_line_indent: f64 = 0.0,
space_before: f64 = 0.0,
space_after: f64 = 0.0,
/// Drop cap: the paragraph's first `drop_cap_chars` characters (code points, never
/// splitting a base from its combining marks) set `drop_cap_lines` lines tall, in the
/// character style `drop_cap_style` ("" or `[None]`: the text's own formatting).
drop_cap_lines: u32 = 0,
drop_cap_chars: u32 = 0,
drop_cap_style: String = String::new(),
/// Align Left Edge: the drop cap's ink starts at the indent (its first letter's left side
/// bearing is taken off), and the lines beside it start where its advance ends.
drop_cap_align_left: bool = true,
/// Scale for Descenders (kept and round-tripped; doesn't change the layout).
drop_cap_scale_descenders: bool = false,
/// Nested styles, in order from the paragraph start.
nested_styles: Vec<NestedStyle> = Vec::new(),
/// GREP styles: a character style for every match of a pattern (applied after nested).
@ -785,11 +882,15 @@ attr_set! {
// Keeps
keep_with_next: u32 = 0,
keep_lines_together: bool = false,
keep_all_lines: bool = true,
/// All lines in paragraph (true) or at start/end of paragraph (false).
keep_all_lines: bool = false,
keep_first: u32 = 2,
keep_last: u32 = 2,
start_paragraph: StartParagraph = StartParagraph::Anywhere,
span_columns: SpanColumns = SpanColumns::Single,
/// Split Columns: the space between sub-columns and the inset from the column's edges.
split_inside_gutter: f64 = 6.0,
split_outside_gutter: f64 = 0.0,
rule_above: Rule = Rule::default(),
rule_below: Rule = Rule::default(),
tabs: Vec<TabStop> = Vec::new(),
@ -800,7 +901,10 @@ attr_set! {
start_at: Option<u32> = None,
bullet_char: String = "\u{2022}".into(),
number_style: NumberStyle = NumberStyle::Arabic,
/// Text after the number/bullet (`^t` = tab).
/// A numbered paragraph's label: `^#` is the number, other metacharacters expand as in
/// [`expand_list_text`].
number_expression: String = "^#.^t".into(),
/// Text after the bullet (metacharacters expand as in [`expand_list_text`]).
list_separator: String = "\t".into(),
balance_ragged: bool = false,
optical_margin: bool = false,
@ -852,6 +956,20 @@ impl ParaAttrs {
}
}
impl ParaProps {
/// The label of a numbered paragraph with number `n`.
pub fn number_label(&self, n: u32) -> String {
expand_list_text(&self.number_expression, &self.number_style.format(n))
}
/// The label of a bulleted paragraph.
pub fn bullet_label(&self) -> String {
let mut s: String = self.bullet_char.chars().filter(|c| *c != '\n' && *c != '\r').collect();
s.push_str(&expand_list_text(&self.list_separator, ""));
s
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -1035,5 +1153,22 @@ mod tests {
assert_eq!(NumberStyle::UpperLetters.format(27), "AA");
assert_eq!(NumberStyle::LowerLetters.format(28), "bb");
assert_eq!(NumberStyle::ArabicLeadingZero.format(3), "03");
assert_eq!(NumberStyle::ArabicThreeDigits.format(7), "007");
assert_eq!(NumberStyle::ArabicFourDigits.format(12), "0012");
// Repeating forms stay small for huge numbers.
assert_eq!(NumberStyle::UpperRoman.format(u32::MAX), u32::MAX.to_string());
assert_eq!(NumberStyle::LowerLetters.format(u32::MAX), u32::MAX.to_string());
assert_eq!(NumberStyle::Symbols.format(u32::MAX), u32::MAX.to_string());
}
#[test]
fn list_labels_expand_metacharacters() {
let p = ParaProps { number_style: NumberStyle::UpperLetters, ..Default::default() };
assert_eq!(p.number_label(2), "B.\t");
let p = ParaProps { number_expression: "Tabel ^#^t".into(), ..Default::default() };
assert_eq!(p.number_label(3), "Tabel 3\t");
assert_eq!(expand_list_text("^2.^#.^>x^^^", "4"), ".4.\u{2002}x^^");
assert_eq!(expand_list_text("a\nb^p", "1"), "ab");
assert_eq!(ParaProps::default().bullet_label(), "\u{2022}\t");
}
}

View File

@ -200,87 +200,229 @@ impl Document {
Ok((id, sid))
}
/// Thread `from` → `to`: `to` joins `from`'s story right after `from`. If `to` held its own text,
/// that text is appended to the story (as a new paragraph) and its old story is removed.
/// Thread `from` → `to`: `to` joins `from`'s story right after `from` (between `from` and the
/// frame that followed it, if any). If `to` held its own text, that text is appended to the
/// story (as new paragraphs) and its old story is removed; frames that followed `to` come
/// along, frames before it keep an empty story of their own. An empty (unassigned) `from` or
/// `to` becomes a text frame. `from`'s story keeps its id.
pub fn thread(&mut self, from: ItemId, to: ItemId) -> Result<()> {
if from == to {
return Err(DocError::Invalid("cannot thread a frame to itself".into()));
}
let fs = self.item(from).and_then(|i| i.text_frame()).map(|t| t.story).ok_or(DocError::NoItem(from))?;
// Convert unassigned/graphic-less frames into text frames.
let ts = match self.item(to).ok_or(DocError::NoItem(to))?.content.clone() {
Content::Text(t) => Some(t.story),
Content::Unassigned => None,
_ => return Err(DocError::Invalid("can only thread to text or empty frames".into())),
self.check_thread_links(from, to, false)?;
let ts = self.frame_story(to)?;
let fs = match self.frame_story(from)? {
Some(s) => s,
None => self.give_empty_story(from)?,
};
if ts == Some(fs) {
return Err(DocError::Invalid("frames are already in the same story".into()));
}
if let Some(ts) = ts {
let other = self.stories.remove(&ts).ok_or(DocError::NoStory(ts))?;
let other = Arc::unwrap_or_clone(other);
// Frames of the other story after `to` follow along; frames before it are left as empty new stories.
let pos_to = other.frames.iter().position(|f| *f == to).unwrap_or(0);
let before: Vec<ItemId> = other.frames[..pos_to].to_vec();
let after: Vec<ItemId> = other.frames[pos_to..].to_vec();
{
let st = self.story_mut(fs).ok_or(DocError::NoStory(fs))?;
if !other.text.is_empty() {
st.text.push('\n');
st.text.push_str(&other.text);
st.paras.extend(other.paras.iter().cloned());
// Char runs: '\n' takes the last run's format, then the other story's runs.
if let Some(last) = st.chars.last_mut() {
last.len += 1;
}
st.chars.extend(other.chars.iter().filter(|r| r.len > 0).cloned());
st.tables.extend(other.tables.iter().map(|(k, v)| (*k, v.clone())));
for n in &other.notes {
let id = st.next_note_id();
st.notes.push(Arc::new(crate::Footnote { id, text: n.text.clone() }));
}
st.anchors.extend(other.anchors.iter().cloned());
st.xrefs.extend(other.xrefs.iter().cloned());
st.index_refs.extend(other.index_refs.iter().cloned());
st.objects.extend(other.objects.iter().cloned());
st.rev += 1;
}
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);
}
}
for f in &after {
if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) {
t.story = fs;
}
}
if !before.is_empty() {
let nid = StoryId(self.alloc());
let mut ns = Story::new(nid);
ns.frames = before.clone();
ns.vertical = other.vertical;
ns.direction = other.direction;
for f in &before {
if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) {
t.story = nid;
}
}
self.stories.insert(nid, Arc::new(ns));
}
} else {
let Some(ts) = ts else {
let st = self.story_mut(fs).ok_or(DocError::NoStory(fs))?;
let at = st.frames.iter().position(|f| *f == from).map_or(st.frames.len(), |p| p + 1);
st.frames.insert(at, to);
st.rev += 1;
let it = self.item_mut(to).ok_or(DocError::NoItem(to))?;
it.content = Content::Text(TextFrame { story: fs, options: TextFrameOptions::default() });
}
if let Some(st) = self.story_mut(fs) {
return Ok(());
};
let mut other = Arc::unwrap_or_clone(self.stories.remove(&ts).ok_or(DocError::NoStory(ts))?);
// Frames of the other story from `to` on follow along; frames before it are left as an empty new story.
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 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);
st.append_story(other);
(st.vertical, st.direction) = 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);
}
st.rev += 1;
}
self.remap_thread_story_refs(ts, fs, offset);
for f in &after {
if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) {
t.story = fs;
}
}
if !before.is_empty() {
let nid = StoryId(self.alloc());
let mut ns = Story::new(nid);
ns.frames = before.clone();
ns.vertical = vertical;
ns.direction = direction;
for f in &before {
if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) {
t.story = nid;
}
}
self.stories.insert(nid, Arc::new(ns));
}
Ok(())
}
/// [`Document::thread`], except that an empty `from` alone in its story, threaded in front of
/// the first frame of `to`'s story, joins that story: the story with the text keeps its id
/// (and whatever refers to it).
pub fn thread_keeping_story(&mut self, from: ItemId, to: ItemId) -> Result<()> {
let fs = self.frame_story(from)?;
let from_empty = fs.is_none_or(|fs| self.story(fs).is_some_and(|s| s.text.is_empty() && s.frames == [from]));
let ts = self.frame_story(to)?;
let to_first = ts.and_then(|ts| self.story(ts)).is_some_and(|s| s.frames.first() == Some(&to));
let (Some(ts), true, true) = (ts, from_empty, to_first && from != to) else { return self.thread(from, to) };
self.check_thread_links(from, to, true)?;
if let Some(fs) = fs {
self.stories.remove(&fs);
self.remap_thread_story_refs(fs, ts, 0);
}
let st = self.story_mut(ts).ok_or(DocError::NoStory(ts))?;
st.frames.insert(0, from);
st.rev += 1;
let it = self.item_mut(from).ok_or(DocError::NoItem(from))?;
match it.text_frame_mut() {
Some(t) => t.story = ts,
None => it.content = Content::Text(TextFrame { story: ts, options: TextFrameOptions::default() }),
}
Ok(())
}
/// Story-external text addresses move with the incoming text when threading merges stories.
/// Linked children follow the surviving whole story, including for an empty removed parent;
/// their copied content stays unchanged until an explicit Update Link.
fn remap_thread_story_refs(&mut self, from: StoryId, to: StoryId, offset: usize) {
// The child's old snapshot is not a snapshot of the newly merged parent. Force it
// out of date even when the old and surviving revision numbers happen to coincide.
let stale_rev = self.story(to).map_or(0, |st| st.rev.wrapping_sub(1));
for st in self.stories.values_mut() {
if st.link.is_some_and(|(parent, _)| parent == from) {
Arc::make_mut(st).link = Some((to, stale_rev));
}
}
for link in &mut self.hyperlinks {
if let crate::HyperlinkSource::Text { story, start, end } = &mut link.source
&& *story == from
{
*story = to;
*start = start.saturating_add(offset);
*end = end.saturating_add(offset);
}
}
for placeholder in &mut self.data_merge.placeholders {
if let crate::PlaceholderAnchor::Text { story, start, end } = &mut placeholder.anchor
&& *story == from
{
*story = to;
*start = start.saturating_add(offset);
*end = end.saturating_add(offset);
}
}
if self.settings.primary_story == Some(from) {
self.settings.primary_story = Some(to);
}
}
/// A linked child is replaced wholesale on update. Only empty, unlinked frame
/// extensions can join it without losing a link or newly appended body text.
fn check_thread_links(&self, from: ItemId, to: ItemId, keep_to: bool) -> Result<()> {
let fs = self.frame_story(from)?;
let ts = self.frame_story(to)?;
let first = fs.and_then(|id| self.story(id));
let second = ts.and_then(|id| self.story(id));
let incompatible = if keep_to {
first.is_some_and(|st| st.link.is_some()) || second.is_some_and(|st| st.link.is_some_and(|(parent, _)| Some(parent) == fs))
} else {
second.is_some_and(|st| st.link.is_some())
|| first.is_some_and(|st| {
st.link.is_some()
&& second.is_some_and(|incoming| !incoming.text.is_empty() || st.link.is_some_and(|(parent, _)| parent == incoming.id))
})
};
if incompatible {
return Err(DocError::Invalid("unlink the linked story before merging its text".into()));
}
Ok(())
}
/// Can a user thread `from` → `to`? Both are text frames or empty frames (not lines), on
/// document spreads or on the same parent spread, not in one story already, and no frame
/// threads into `to` yet. [`Document::thread`] itself is less strict (import and autoflow).
pub fn check_thread(&self, from: ItemId, to: ItemId) -> Result<()> {
if from == to {
return Err(DocError::Invalid("cannot thread a frame to itself".into()));
}
for id in [from, to] {
let it = self.item(id).ok_or(DocError::NoItem(id))?;
if !matches!(it.content, Content::Text(_) | Content::Unassigned) || it.shape == Shape::GraphicLine {
return Err(DocError::Invalid(format!("{id} is not a text frame or an empty frame")));
}
if it.locked || self.layer(it.layer).is_some_and(|l| l.locked) {
return Err(DocError::Invalid(format!("{id} is locked or on a locked layer")));
}
}
let (Some(fl), Some(tl)) = (self.find(from), self.find(to)) else { return Err(DocError::NoItem(to)) };
let same_place = match (fl.spread, tl.spread) {
(SpreadRef::Doc(_), SpreadRef::Doc(_)) => true,
(a, b) => a == b,
};
if !same_place {
return Err(DocError::Invalid("frames on a parent spread thread only to frames on the same parent".into()));
}
let story = |id: ItemId| self.item(id).and_then(|i| i.text_frame()).map(|t| t.story);
if story(from).is_some() && story(from) == story(to) {
return Err(DocError::Invalid("the frames are already in the same thread".into()));
}
// Generated text is rebuilt from its source data. Merging body text into it would
// silently discard that text on the next update (endnotes update after every edit).
let generated = |sid| {
self.endnote_story == Some(sid)
|| self.toc.as_ref().is_some_and(|t| t.story == sid)
|| self.index.as_ref().is_some_and(|i| i.story == sid)
};
let (fs, ts) = (story(from), story(to));
if fs.is_some_and(generated) && ts.and_then(|sid| self.story(sid)).is_some_and(|st| !st.text.is_empty()) {
return Err(DocError::Invalid("cannot merge body text into a generated story".into()));
}
if ts.is_some_and(generated) && fs.and_then(|sid| self.story(sid)).is_some_and(|st| !st.text.is_empty() || st.frames != [from]) {
return Err(DocError::Invalid("a generated story can only take a new empty first frame".into()));
}
let keep_to = fs.is_none_or(|sid| self.story(sid).is_some_and(|st| st.text.is_empty() && st.frames == [from]))
&& ts.and_then(|sid| self.story(sid)).is_some_and(|st| st.frames.first() == Some(&to));
self.check_thread_links(from, to, keep_to)?;
if self.prev_frame(to).is_some() {
return Err(DocError::Invalid(format!("text already flows into {to} from another frame: break that thread first")));
}
Ok(())
}
/// The story of a frame that can take part in a thread: `Some` for a text frame, `None` for
/// an empty (unassigned) frame.
fn frame_story(&self, id: ItemId) -> Result<Option<StoryId>> {
match &self.item(id).ok_or(DocError::NoItem(id))?.content {
Content::Text(t) => Ok(Some(t.story)),
Content::Unassigned => Ok(None),
_ => Err(DocError::Invalid("can only thread text frames or empty frames".into())),
}
}
/// Make an empty frame a text frame with a new empty story.
fn give_empty_story(&mut self, id: ItemId) -> Result<StoryId> {
let sid = StoryId(self.alloc());
let mut st = Story::new(sid);
st.frames.push(id);
let it = self.item_mut(id).ok_or(DocError::NoItem(id))?;
it.content = Content::Text(TextFrame { story: sid, options: TextFrameOptions::default() });
self.stories.insert(sid, Arc::new(st));
Ok(sid)
}
/// Break the thread after `frame`: the following frames become a new empty story (the text
/// stays with the first part and may become overset).
pub fn unthread_after(&mut self, frame: ItemId) -> Result<()> {
@ -309,6 +451,30 @@ impl Document {
Ok(())
}
/// Take `frame` out of its thread: the frames before and after it join up and keep the text,
/// `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 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 += 1;
(st.vertical, st.direction)
};
let nid = StoryId(self.alloc());
let mut ns = Story::new(nid);
ns.frames = vec![frame];
ns.vertical = vertical;
ns.direction = direction;
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;
Ok(())
}
/// Next / previous frame in a thread.
pub fn next_frame(&self, frame: ItemId) -> Option<ItemId> {
let st = self.story(self.item(frame)?.text_frame()?.story)?;
@ -416,5 +582,11 @@ pub fn item_hit(it: &Item, p: Point, tol: f64) -> bool {
if area && it.path.is_closed() && designcraft_geom::hit::fill_contains(&bp, designcraft_geom::FillRule::NonZero, inner) {
return true;
}
designcraft_geom::hit::stroke_contains(&bp, it.stroke.weight.max(1.0), tol, inner)
// Type on a path: the letters stand over the path, so a click on them hits it too.
let reach = if it.text_frame().is_some_and(|t| t.options.path.is_some()) { PATH_TYPE_REACH } else { 0.0 };
designcraft_geom::hit::stroke_contains(&bp, it.stroke.weight.max(1.0), tol + reach, inner)
}
/// How far from its path type on a path is hit (points): about a cap height of 12–18 pt type,
/// either side (the type may sit on or under the path).
const PATH_TYPE_REACH: f64 = 12.0;

View File

@ -21,3 +21,24 @@ id_type!(SpreadId, "sp");
id_type!(PageId, "p");
id_type!(LayerId, "l");
id_type!(AssetId, "a");
/// Fresh story-local id. Keep monotonically increasing ids normally; a loaded MAX id must
/// not make the next insertion overwrite it. In that case choose the first unused gap.
pub(crate) fn next_local_id(ids: impl Iterator<Item = u64> + Clone) -> u64 {
if let Some(next) = ids.clone().max().unwrap_or(0).checked_add(1) {
return next;
}
let mut used: Vec<_> = ids.collect();
used.sort_unstable();
let mut next = 1u64;
for id in used {
if id > next {
break;
}
if id == next {
// An allocated Vec cannot contain every u64 id, so this always finds a gap.
next = next.saturating_add(1);
}
}
next
}

View File

@ -281,6 +281,10 @@ pub enum FirstBaseline {
Leading,
XHeight,
Fixed,
/// The top of the font's em box: its `BASE` em box; else, for a CJK font, its OS/2 typo
/// ascender when the typo ascender and descender span one em; else half an em above half the
/// cap height.
EmboxHeight,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@ -321,7 +325,7 @@ pub struct TextFrameOptions {
pub first_baseline_min: f64,
pub ignore_wrap: bool,
pub auto_size: AutoSize,
/// Reference point (0..9) that stays fixed when auto-sizing.
/// Reference point (0..9, row-major from the top left) that stays fixed when auto-sizing.
pub auto_size_ref: u8,
pub column_rule: bool,
pub column_rule_weight: f64,
@ -345,8 +349,12 @@ pub struct TextFrameOptions {
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct PathType {
/// Where the text starts, as a distance along the path.
/// Where the text starts, as a distance along the path (the start bracket).
pub start: f64,
/// Where the text ends, as a distance along the path (the end bracket); `None` is the path's
/// end. Text past it is overset.
#[serde(skip_serializing_if = "Option::is_none")]
pub end: Option<f64>,
/// Run the other way (the text sits on the other side).
pub flip: bool,
pub align: PathAlign,
@ -378,7 +386,7 @@ impl Default for TextFrameOptions {
first_baseline_min: 0.0,
ignore_wrap: false,
auto_size: AutoSize::Off,
auto_size_ref: 1,
auto_size_ref: 4,
column_rule: false,
column_rule_weight: 1.0,
column_rule_color: designcraft_color::swatch::BLACK.into(),
@ -1152,13 +1160,29 @@ impl Item {
b.inflate(w, w)
}
/// The frame's text area (inner space) after inset.
/// The text frame's text area (inner space): inside the part of a visible stroke that lies
/// within the frame, then the inset, on each edge.
pub fn text_area(&self) -> Rect {
let r = self.inner_bounds();
let inset = self.text_frame().map(|t| t.options.inset).unwrap_or([0.0; 4]);
let s = self.stroke_inside();
let inset = self.text_frame().map(|t| t.options.inset).unwrap_or([0.0; 4]).map(|v| v + s);
Rect::new(r.x0 + inset[1], r.y0 + inset[0], (r.x1 - inset[3]).max(r.x0 + inset[1]), (r.y1 - inset[2]).max(r.y0 + inset[0]))
}
/// How far a visible stroke reaches inside the path: half its weight centred, all of it inside,
/// none outside.
fn stroke_inside(&self) -> f64 {
let w = self.stroke.weight;
if self.stroke.is_none() || !w.is_finite() {
return 0.0;
}
match self.stroke.align {
StrokeAlign::Center => w / 2.0,
StrokeAlign::Inside => w,
StrokeAlign::Outside => 0.0,
}
}
/// Spread-space centre.
pub fn center(&self) -> Point {
self.bounds().center()
@ -1203,6 +1227,32 @@ mod tests {
assert_eq!(it.visible_bounds(), Rect::new(8.0, 18.0, 112.0, 72.0));
}
#[test]
fn text_area_starts_inside_the_stroke() {
let mut it = Item::new(ItemId(1), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)));
it.content = Content::Text(TextFrame { story: StoryId(2), options: TextFrameOptions { inset: [5.0, 6.0, 7.0, 8.0], ..Default::default() } });
let unstroked = Rect::new(6.0, 5.0, 92.0, 43.0);
let stroked = |align: StrokeAlign, swatch: &str| {
let mut it = it.clone();
it.stroke = Stroke { weight: 4.0, align, swatch: swatch.into(), ..Stroke::default() };
it.text_area()
};
let black = designcraft_color::swatch::BLACK;
// The part of the stroke inside the frame, on every edge, then the inset.
assert_eq!(stroked(StrokeAlign::Center, black), Rect::new(8.0, 7.0, 90.0, 41.0));
assert_eq!(stroked(StrokeAlign::Inside, black), Rect::new(10.0, 9.0, 88.0, 39.0));
assert_eq!(stroked(StrokeAlign::Outside, black), unstroked);
// A stroke without a colour doesn't count.
assert_eq!(stroked(StrokeAlign::Inside, designcraft_color::swatch::NONE), unstroked);
// Nor does a weight that isn't a number.
it.stroke = Stroke { weight: f64::NAN, ..Stroke::default() };
assert_eq!(it.text_area(), unstroked);
// A stroke wider than the frame leaves no room for text.
it.stroke = Stroke { weight: 200.0, align: StrokeAlign::Inside, ..Stroke::default() };
let a = it.text_area();
assert!(a.width() == 0.0 && a.height() == 0.0, "{a:?}");
}
#[test]
fn group_bounds_union_children() {
let a = Item::new(ItemId(1), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)));

View File

@ -12,6 +12,7 @@
pub mod anchored;
pub mod arabic;
pub mod arrow;
pub mod assets;
pub mod attrs;
pub mod build;
pub mod cjk;
@ -36,6 +37,7 @@ use std::collections::BTreeMap;
use std::sync::Arc;
pub use anchored::{AnchorPosition, AnchoredObject, OBJECT_MARK};
pub use assets::{find_same_asset, insert_asset, remap_story_assets, story_asset_refs};
pub use attrs::*;
pub use datamerge::{
DataField, DataFieldKind, DataMerge, DataSource, Delimiter, Fingerprint, MergeOptions, Placeholder, PlaceholderAnchor, PlaceholderRole,
@ -141,7 +143,7 @@ impl Default for DocumentGrid {
}
/// A named numbered list.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct NumberedList {
pub name: String,
@ -149,12 +151,6 @@ pub struct NumberedList {
pub continue_across_stories: bool,
}
impl Default for NumberedList {
fn default() -> Self {
NumberedList { name: String::new(), continue_across_stories: true }
}
}
/// A conditional-text condition: its indicator colour (screen only) and whether text with it shows.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -450,6 +446,16 @@ pub struct Asset {
pub page: u32,
}
impl Asset {
/// A raster image (PNG, JPEG, GIF, WebP, TIFF, BMP, PSD), as opposed to a PDF, EPS or SVG.
pub fn is_raster(&self) -> bool {
matches!(
self.mime.as_str(),
"image/png" | "image/jpeg" | "image/gif" | "image/webp" | "image/tiff" | "image/bmp" | "image/vnd.adobe.photoshop"
)
}
}
fn is_zero(v: &u32) -> bool {
*v == 0
}

View File

@ -178,7 +178,7 @@ impl Story {
}
pub(crate) fn next_note_id(&self) -> u64 {
self.notes.iter().map(|n| n.id).max().unwrap_or(0) + 1
crate::ids::next_local_id(self.notes.iter().map(|n| n.id))
}
/// Insert empty footnotes for `n` new reference characters before which `k` references sit.
@ -235,10 +235,10 @@ impl crate::Document {
before.sort_by_key(|st| key(st));
let mut n = 0u32;
for st in before {
for p in &st.paras {
for (p, r) in st.paras.iter().zip(st.para_ranges()) {
let (pp, _) = self.styles.resolve_para(p);
if pp.list_type == crate::ListType::Numbers && pp.list_name == name {
n = pp.start_at.map_or(n + 1, |s| s.max(1));
if pp.list_type == crate::ListType::Numbers && pp.list_name == name && !r.is_empty() {
n = pp.start_at.map_or(n.saturating_add(1), |s| s.max(1));
}
}
}

View File

@ -246,7 +246,8 @@ impl Page {
/// Split `area` into `n` equal columns separated by `gutter`.
pub fn column_rects(area: Rect, n: u32, gutter: f64) -> Vec<Rect> {
let n = n.max(1) as usize;
// The public allocator also receives native/imported options, outside command validation.
let n = n.clamp(1, 216) as usize;
let w = ((area.width() - gutter * (n as f64 - 1.0)) / n as f64).max(0.0);
(0..n).map(|i| Rect::new(area.x0 + i as f64 * (w + gutter), area.y0, area.x0 + i as f64 * (w + gutter) + w, area.y1)).collect()
}
@ -403,3 +404,18 @@ mod tests {
assert_eq!(s.page_at_x(5000.0), Some(1));
}
}
#[cfg(test)]
mod column_allocation_safety_tests {
use super::*;
#[test]
fn column_allocation_caps_page_count_and_preserves_geometry() {
let area = Rect::new(0., 0., 300., 100.);
// Fail safely at a small boundary before exercising hostile counts.
assert_eq!(column_rects(area, 217, 0.).len(), 216);
assert_eq!(column_rects(area, u32::MAX, 0.).len(), 216);
assert_eq!(column_rects(area, 0, 12.), vec![area]);
assert_eq!(column_rects(area, 2, 12.), vec![Rect::new(0., 0., 144., 100.), Rect::new(156., 0., 300., 100.)]);
}
}

View File

@ -126,6 +126,66 @@ impl Story {
self.rev += 1;
end
}
/// Append the whole of `other` as new paragraphs after this story's text (threading two
/// stories merges them this way). Its formatting, notes, endnotes, editorial notes, markers,
/// anchored objects and tables come along; footnotes, endnotes, editorial notes and tables get
/// ids that are free in this story. An empty story simply takes `other`'s content. Frames stay.
pub fn append_story(&mut self, mut other: Story) {
if other.text.is_empty() {
return;
}
if self.text.is_empty() {
let (id, frames, rev, link) = (self.id, std::mem::take(&mut self.frames), self.rev, self.link.take());
*self = Story { id, frames, rev: rev.saturating_add(1), link, ..other };
return;
}
if !other.tables.is_empty() {
let mut ids = std::collections::HashMap::new();
let mut next = crate::ids::next_local_id(self.tables.keys().copied());
for (old, t) in std::mem::take(&mut other.tables) {
let new = next;
let mut t = Arc::unwrap_or_clone(t);
t.id = new;
ids.insert(old, new);
self.tables.insert(new, Arc::new(t));
// Continue through free ids without rescanning the table map for its maximum.
// On exhaustion wrap to gaps; an allocated map cannot occupy every u64 id.
next = next.checked_add(1).unwrap_or(1);
while self.tables.contains_key(&next) {
next = next.checked_add(1).unwrap_or(1);
}
}
for p in &mut other.paras {
p.table = p.table.and_then(|id| ids.get(&id).copied());
}
}
self.text.push('\n');
self.text.push_str(&other.text);
// The paragraph end takes the last run's format.
if let Some(last) = self.chars.last_mut() {
last.len += 1;
}
self.chars.extend(other.chars.into_iter().filter(|r| r.len > 0));
self.paras.extend(other.paras);
for n in other.notes {
let id = self.next_note_id();
self.notes.push(Arc::new(crate::notes::Footnote { id, text: n.text.clone() }));
}
for n in other.endnotes {
let id = crate::ids::next_local_id(self.endnotes.iter().map(|x| x.id));
self.endnotes.push(Arc::new(crate::notes::Footnote { id, text: n.text.clone() }));
}
for n in other.editorial {
let id = crate::ids::next_local_id(self.editorial.iter().map(|x| x.id));
self.editorial.push(Arc::new(crate::endnotes::EditorialNote { id, ..(*n).clone() }));
}
self.anchors.extend(other.anchors);
self.xrefs.extend(other.xrefs);
self.index_refs.extend(other.index_refs);
self.objects.extend(other.objects);
self.rev = self.rev.saturating_add(1);
}
}
#[cfg(test)]
@ -159,4 +219,52 @@ mod tests {
assert_eq!(part.notes.len(), 1);
assert!(a.extract(2..8).notes.is_empty());
}
#[test]
fn append_tables_preserves_sparse_and_wrapping_ids() {
for existing in [vec![1, 3, u64::MAX], vec![1, 3, u64::MAX - 1], vec![1, 3, 8]] {
let story = |id, ids: &[u64]| {
let mut s = Story::with_text(StoryId(id), &vec![crate::TABLE_ANCHOR.to_string(); ids.len()].join("\n"), ParaFormat::default());
for (p, &tid) in s.paras.iter_mut().zip(ids) {
p.table = Some(tid);
let mut t = crate::table::Table::new(tid, 1, 1, 0, 0, 100.0);
t.cells[0].text = Story::with_text(StoryId(0), &format!("{id}:{tid}"), ParaFormat::default());
s.tables.insert(tid, Arc::new(t));
}
s
};
let mut a = story(1, &existing);
let b = story(2, &[1, 2, 3]);
a.check().unwrap();
b.check().unwrap();
a.append_story(b);
a.check().unwrap();
assert_eq!(a.tables.len(), 6);
for (p, tid) in a.paras.iter().take(3).zip(&existing) {
assert_eq!(p.table, Some(*tid));
assert_eq!(a.tables[tid].cells[0].text.text, format!("1:{tid}"));
}
for (p, tid) in a.paras.iter().skip(3).zip(1..=3) {
assert_eq!(a.tables[&p.table.unwrap()].cells[0].text.text, format!("2:{tid}"));
}
}
}
#[test]
fn append_story_with_maximal_ids_does_not_overflow() {
let mut a = Story::with_text(StoryId(1), "Alpha", ParaFormat::default());
a.tables.insert(u64::MAX, Arc::new(crate::table::Table::new(u64::MAX, 1, 1, 0, 0, 100.0)));
let note = |id| Arc::new(crate::notes::Footnote { id, text: Story::with_text(StoryId(0), "n", ParaFormat::default()) });
a.endnotes.push(note(u64::MAX));
a.notes.push(note(u64::MAX));
a.rev = u64::MAX;
let mut b = Story::with_text(StoryId(2), "Beta", ParaFormat::default());
b.tables.insert(1, Arc::new(crate::table::Table::new(1, 1, 1, 0, 0, 100.0)));
b.endnotes.push(note(1));
b.notes.push(note(1));
a.append_story(b);
assert_eq!(a.text, "Alpha\nBeta");
assert_eq!(a.endnotes.len(), 2);
assert_eq!(a.notes.len(), 2);
}
}

View File

@ -692,8 +692,8 @@ impl Styles {
self.object.iter().find(|s| s.name == name)
}
/// The paragraph style chain from root to `name` (cycle-safe).
fn para_chain(&self, name: &str) -> Vec<&ParagraphStyle> {
/// The paragraph style chain from root to `name` (cycle-safe): resolution starts at its first.
pub fn para_chain(&self, name: &str) -> Vec<&ParagraphStyle> {
let mut chain = Vec::new();
let mut cur = self.para(name);
while let Some(s) = cur {

View File

@ -695,6 +695,79 @@ impl Table {
self.set_regions(&regions);
}
/// Split each original owner touched by `range` once. Remember merged-cell cuts in the
/// original grid: a neighboring split may refine that grid before we reach the owner.
pub fn split_cells(&mut self, range: CellRange, horizontal: bool) -> Result<(), String> {
if range.r0 > range.r1 || range.c0 > range.c1 || range.r1 >= self.nrows() || range.c1 >= self.ncols() {
return Err("cell range out of bounds".into());
}
let owners = self.owners();
let mut selected = std::collections::BTreeSet::new();
for r in range.r0..=range.r1 {
for c in range.c0..=range.c1 {
let owner = owners.get(r * self.ncols() + c).ok_or("missing cell owner")?;
selected.insert(*owner);
}
}
let mut targets = Vec::new();
for (r, c) in selected {
let cell = self.cell(r, c).ok_or("missing split cell")?;
let span = if horizontal { cell.row_span } else { cell.col_span } as usize;
let start = if horizontal { r } else { c };
let cut = if span > 1 { Some(start.checked_add(span.div_ceil(2)).ok_or("invalid split boundary")?) } else { None };
targets.push((r, c, cut));
}
for i in 0..targets.len() {
let (r, c, cut) = *targets.get(i).ok_or("missing split target")?;
let cell = self.cell(r, c).ok_or("missing split cell")?;
let (rs, cs) = (cell.row_span as usize, cell.col_span as usize);
let mut blank = cell.blank_like();
// Splitting creates an empty half, not a duplicate of graphic-cell content.
blank.graphic = None;
let before = if horizontal { self.nrows() } else { self.ncols() };
if let Some(cut) = cut {
let (start, span) = if horizontal { (r, rs) } else { (c, cs) };
let first = cut.checked_sub(start).filter(|n| *n > 0 && *n < span).ok_or("invalid split boundary")?;
let mut regions: Vec<_> = self.regions().into_iter().filter(|&(rr, cc, _, _)| (rr, cc) != (r, c)).collect();
if horizontal {
regions.push((r, c, first, cs));
regions.push((cut, c, rs - first, cs));
} else {
regions.push((r, c, rs, first));
regions.push((r, cut, rs, cs - first));
}
self.set_regions(&regions);
} else {
// An originally single-track cell may now span two tracks inserted for its
// neighbor. split_cell divides that span instead of inserting another track.
self.split_cell(r, c, horizontal);
}
let first = self.cell(r, c).ok_or("missing split half")?;
let second = if horizontal { (r + first.row_span as usize, c) } else { (r, c + first.col_span as usize) };
let second = self.cell_mut(second.0, second.1).ok_or("missing split half")?;
blank.row_span = second.row_span;
blank.col_span = second.col_span;
*second = blank;
let after = if horizontal { self.nrows() } else { self.ncols() };
if after > before {
let at = if horizontal { r + 1 } else { c + 1 };
let added = after - before;
for (row, col, cut) in targets.iter_mut().skip(i + 1) {
let axis = if horizontal { row } else { col };
if *axis >= at {
*axis += added;
}
if let Some(cut) = cut
&& *cut >= at
{
*cut += added;
}
}
}
}
Ok(())
}
/// Split the cell covering (r, c) in two: `horizontal` stacks the halves (Split Cell
/// Horizontally), otherwise side by side. A merged cell splits its span; a single cell gets a
/// new row (column) that the cells beside it span, the row height (column width) shared.
@ -719,10 +792,11 @@ impl Table {
return;
}
if horizontal {
let h = self.rows[or].height;
let row = self.rows[or].clone();
self.insert_rows(or + 1, 1);
self.rows[or].height = h / 2.0;
self.rows[or + 1].height = h / 2.0;
self.rows[or].height = row.height / 2.0;
// Both halves keep the split row's height mode and header/body/footer role.
self.rows[or + 1] = Row { height: row.height / 2.0, ..row };
// insert_rows doesn't grow regions that end at the split row; everything else on it
// spans the new row now.
let mut regions: Vec<_> = self.regions();
@ -1255,4 +1329,89 @@ mod split_tests {
assert_eq!(m.nrows(), 4);
assert_eq!((m.cell(0, 0).unwrap().row_span, m.cell(2, 0).unwrap().row_span), (2, 2));
}
#[test]
fn split_range_keeps_original_merged_cut_when_neighbor_tracks_are_refined() {
for horizontal in [true, false] {
let mut t = Table::new(1, 4, 4, 0, 0, 280.0);
for (i, size) in [40.0, 60.0, 80.0, 100.0].into_iter().enumerate() {
t.rows[i].mode = RowHeightMode::Exactly;
t.rows[i].height = if horizontal { size } else { 70.0 };
t.columns[i].width = if horizontal { 70.0 } else { size };
}
for r in 0..4 {
for c in 0..4 {
let cell = t.cell_mut(r, c).unwrap();
cell.text.insert(0, &format!("{r},{c}"));
cell.style = format!("style-{r}-{c}");
cell.insets = [r as f64 + c as f64 + 1.0; 4];
}
}
let (merged, selected, owner, lower, neighbor, moved_neighbor) = if horizontal {
(CellRange::new(0, 1, 2, 1), CellRange::new(0, 0, 2, 1), (0, 1), (4, 1), (3, 3), (6, 3))
} else {
(CellRange::new(1, 0, 1, 2), CellRange::new(0, 0, 1, 2), (1, 0), (1, 4), (3, 3), (3, 6))
};
t.merge(merged).unwrap();
let original = t.clone();
t.split_cells(selected, horizontal).unwrap();
t.check().unwrap();
assert_eq!((t.nrows(), t.ncols()), if horizontal { (7, 4) } else { (4, 7) });
let sizes: Vec<_> = if horizontal { t.rows.iter().map(|r| r.height).collect() } else { t.columns.iter().map(|c| c.width).collect() };
assert_eq!(sizes, [20.0, 20.0, 30.0, 30.0, 40.0, 40.0, 100.0]);
let first = t.cell(owner.0, owner.1).unwrap();
let second = t.cell(lower.0, lower.1).unwrap();
// The original three-track merge divides after its second original track,
// regardless of which of those tracks a neighboring selected cell subdivides.
assert_eq!(if horizontal { first.row_span } else { first.col_span }, 4);
assert_eq!(if horizontal { second.row_span } else { second.col_span }, 2);
assert_eq!(first.text, original.cell(owner.0, owner.1).unwrap().text);
assert!(second.text.is_empty());
assert_eq!(second.style, first.style);
assert_eq!(second.insets, first.insets);
assert_eq!(t.cell(moved_neighbor.0, moved_neighbor.1), original.cell(neighbor.0, neighbor.1));
let owners = t.owners();
assert_eq!(owners.iter().copied().collect::<std::collections::BTreeSet<_>>().len(), 18, "four original selected owners split once each");
let mut before_text: Vec<_> = original.cells.iter().filter(|c| !c.text.is_empty()).map(|c| c.text.text.clone()).collect();
let mut after_text: Vec<_> = t.cells.iter().filter(|c| !c.text.is_empty()).map(|c| c.text.text.clone()).collect();
before_text.sort();
after_text.sort();
assert_eq!(after_text, before_text, "no original text is copied or lost");
}
}
#[test]
fn split_range_resolves_covered_positions_without_expanding_selection() {
for horizontal in [true, false] {
let mut t = Table::new(1, 3, 3, 0, 0, 180.0);
t.merge(CellRange::new(0, 0, 1, 1)).unwrap();
let selected = if horizontal { CellRange::new(1, 1, 1, 2) } else { CellRange::new(1, 1, 2, 1) };
t.split_cells(selected, horizontal).unwrap();
t.check().unwrap();
let owners = t.owners();
assert_eq!(owners.iter().copied().collect::<std::collections::BTreeSet<_>>().len(), 8, "only the two touched owners split");
assert_eq!((t.nrows(), t.ncols()), if horizontal { (4, 3) } else { (3, 4) });
assert_eq!(t.owner(0, 0), (0, 0));
assert_eq!(if horizontal { t.owner(1, 0) } else { t.owner(0, 1) }, if horizontal { (1, 0) } else { (0, 1) });
}
}
#[test]
fn split_header_row_keeps_its_height_mode_and_role() {
let mut t = Table::new(1, 1, 2, 1, 1, 180.0);
t.rows[0].height = 60.0;
t.rows[0].mode = RowHeightMode::Exactly;
let body = t.rows[1].clone();
let footer = t.rows[2].clone();
t.split_cells(CellRange::new(0, 0, 0, 1), true).unwrap();
t.check().unwrap();
assert_eq!(t.header_rows(), 2);
for row in &t.rows[..2] {
assert_eq!(row.height, 30.0);
assert_eq!(row.mode, RowHeightMode::Exactly);
assert_eq!(row.kind, RowKind::Header);
}
assert_eq!(t.rows[2], body);
assert_eq!(t.rows[3], footer);
}
}

View File

@ -42,6 +42,126 @@ fn thread_to_empty_frame_converts_it() {
assert!(d.thread(a, a).is_err());
}
#[test]
fn threading_merges_tables_notes_and_endnotes() {
let mut d = doc();
let lid = d.default_layer();
let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "first", ParaFormat::default()).unwrap();
let (b, sb) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "second", ParaFormat::default()).unwrap();
for (sid, note) in [(sa, "Note A"), (sb, "Note B")] {
let st = d.story_mut(sid).unwrap();
st.insert_note(3, note, ParaFormat::default());
// Both stories hold a table with id 1.
let n = st.len();
st.insert_table(n, crate::table::Table::new(1, 2, 2, 0, 0, 80.0));
}
let st = d.story_mut(sb).unwrap();
st.insert_endnote(2, "End B", ParaFormat::default());
st.insert(1, &crate::NOTE_MARK.to_string());
d.thread(a, b).unwrap();
d.check().unwrap();
let st = d.story(sa).unwrap();
assert!(d.story(sb).is_none());
assert_eq!(st.tables.len(), 2);
assert_eq!(st.notes.len(), 2);
assert_eq!(st.notes[1].text.text, "Note B");
assert_eq!(st.endnotes.len(), 1);
assert_eq!(st.endnotes[0].text.text, "End B");
assert_eq!(st.editorial.len(), 1);
}
#[test]
fn empty_frame_threaded_before_a_story_joins_it_at_the_front() {
let mut d = doc();
let lid = d.default_layer();
let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "", ParaFormat::default()).unwrap();
let (b, sb) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "Hello", ParaFormat::default()).unwrap();
d.thread_keeping_story(a, b).unwrap();
d.check().unwrap();
assert!(d.story(sa).is_none());
let st = d.story(sb).unwrap();
assert_eq!(st.frames, vec![a, b]);
assert_eq!(st.text, "Hello");
}
#[test]
fn empty_story_takes_the_threaded_text_without_a_blank_paragraph() {
let mut d = doc();
let lid = d.default_layer();
let (a, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "", ParaFormat::default()).unwrap();
let (a2, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 110.0, 100.0, 200.0), lid, "", ParaFormat::default()).unwrap();
d.thread(a, a2).unwrap();
let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "Hello", ParaFormat::default()).unwrap();
d.thread(a2, b).unwrap();
d.check().unwrap();
let st = d.story(d.item(a).unwrap().text_frame().unwrap().story).unwrap();
assert_eq!(st.frames, vec![a, a2, b]);
assert_eq!(st.text, "Hello");
}
#[test]
fn threading_from_an_empty_frame_makes_it_a_text_frame() {
let mut d = doc();
let lid = d.default_layer();
let id = ItemId(d.alloc());
let it = Item::new(id, lid, Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 200.0, 50.0, 300.0)));
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "Hello", ParaFormat::default()).unwrap();
d.thread(id, b).unwrap();
d.check().unwrap();
let st = d.story(d.item(id).unwrap().text_frame().unwrap().story).unwrap();
assert_eq!(st.frames, vec![id, b]);
assert_eq!(st.text, "Hello");
}
#[test]
fn removing_a_frame_from_its_thread_keeps_the_text_in_the_others() {
let mut d = doc();
let lid = d.default_layer();
let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "words", ParaFormat::default()).unwrap();
let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "", ParaFormat::default()).unwrap();
let (c, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(210.0, 10.0, 300.0, 100.0), lid, "", ParaFormat::default()).unwrap();
d.thread(a, b).unwrap();
d.thread(b, c).unwrap();
d.remove_from_thread(b).unwrap();
d.check().unwrap();
assert_eq!(d.story(sa).unwrap().frames, vec![a, c]);
assert_eq!(d.story(sa).unwrap().text, "words");
let own = d.item(b).unwrap().text_frame().unwrap().story;
assert_ne!(own, sa);
assert!(d.story(own).unwrap().text.is_empty());
// A frame alone in its story has no thread to leave.
assert!(d.remove_from_thread(b).is_err());
}
#[test]
fn a_frame_removed_from_a_vertical_thread_stays_vertical() {
let mut d = doc();
let lid = d.default_layer();
let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "縦書き", ParaFormat::default()).unwrap();
let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "", ParaFormat::default()).unwrap();
d.story_mut(sa).unwrap().vertical = true;
d.thread(a, b).unwrap();
d.remove_from_thread(b).unwrap();
d.check().unwrap();
assert!(d.frame_vertical(d.item(a).unwrap()));
assert!(d.frame_vertical(d.item(b).unwrap()), "the removed frame keeps the story's direction");
}
#[test]
fn a_frame_removed_from_a_right_to_left_thread_keeps_its_column_order() {
let mut d = doc();
let lid = d.default_layer();
let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "نص", ParaFormat::default()).unwrap();
let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "", ParaFormat::default()).unwrap();
d.story_mut(sa).unwrap().direction = crate::TextDirection::RightToLeft;
d.thread(a, b).unwrap();
d.remove_from_thread(b).unwrap();
d.check().unwrap();
let own = d.item(b).unwrap().text_frame().unwrap().story;
assert_eq!(d.story(own).unwrap().direction, crate::TextDirection::RightToLeft);
}
#[test]
fn frames_keep_their_story_direction_through_threading() {
let mut d = doc();

View File

@ -347,13 +347,18 @@ impl Document {
let (sid, pos) = self.find_anchor(id)?;
let st = self.story(sid)?;
let pi = st.para_at(pos);
let r = st.para_ranges()[pi].clone();
let ranges = st.para_ranges();
let r = ranges[pi].clone();
let (pp, _) = self.styles.resolve_para(&st.paras[pi]);
let para_num = match pp.list_type {
crate::ListType::Numbers => {
// Count the run of numbered paragraphs before this one.
let n = (0..=pi).rev().take_while(|&k| self.styles.resolve_para(&st.paras[k]).0.list_type == crate::ListType::Numbers).count();
format!("{}.", pp.number_style.format(n as u32))
crate::ListType::Numbers if !r.is_empty() => {
// Count the run of numbered paragraphs before this one (empty ones have no number).
let n = (0..=pi)
.rev()
.take_while(|&k| self.styles.resolve_para(&st.paras[k]).0.list_type == crate::ListType::Numbers)
.filter(|&k| ranges.get(k).is_some_and(|r| !r.is_empty()))
.count();
pp.number_label(u32::try_from(n).unwrap_or(u32::MAX)).trim_end().to_string()
}
_ => String::new(),
};

View File

@ -9,6 +9,7 @@ description = "DesignCraft engine: session, documents, history, selection, comma
workspace = true
[dependencies]
craft-print = { workspace = true }
designcraft-images = { workspace = true }
designcraft-geom = { workspace = true }
designcraft-color = { workspace = true }
@ -28,6 +29,8 @@ thiserror = { workspace = true }
log = { workspace = true }
regex = { workspace = true }
quick-xml = { workspace = true }
caseless = { workspace = true }
unicode-segmentation = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tempfile = { workspace = true }

359
crates/engine/src/atomic.rs Normal file
View File

@ -0,0 +1,359 @@
//! Crash-safe file replacement: how documents, exports and other user files reach the disk.
//!
//! [`write_atomic`] never writes the destination in place. It writes a uniquely named temporary
//! file in the same folder, flushes it (`sync_all`), renames it over the destination and then
//! flushes the folder (Unix) so the rename survives a power loss. When any step before the rename
//! fails, the temporary file is removed and the destination keeps its old bytes.
//!
//! - A replaced file keeps its permissions (Unix mode).
//! - A symbolic link keeps pointing at its file: the file it names is replaced.
//! - Windows: antivirus scanners and the search indexer briefly hold files open, which makes the
//! replacing rename fail with `PermissionDenied`; the rename is retried with a short backoff.
//! - A failed rename (cross-device, unwritable folder) is returned as an error; there is no
//! fallback to writing in place.
//! - Flushing the parent folder after replacement is best effort. A successful save confirms
//! that the new file is in place; it cannot guarantee survival of a subsequent power loss if
//! the file system rejects that folder flush.
//!
//! The web has no file system, so this module is not built for `wasm32`.
use std::fs::File;
#[cfg(unix)]
use std::fs::OpenOptions;
use std::io::{self, Write as _};
use std::path::{Path, PathBuf};
/// How many times a replacing rename is tried before its error is returned.
const RENAME_TRIES: u32 = if cfg!(windows) { 5 } else { 1 };
/// The pause before the first rename retry; it doubles up to [`RENAME_MAX_DELAY_MS`].
const RENAME_FIRST_DELAY_MS: u64 = 10;
const RENAME_MAX_DELAY_MS: u64 = 200;
/// Symbolic links followed before giving up (a loop, or an absurd chain).
const MAX_LINKS: usize = 40;
/// Atomically replace (or create) `path` with `bytes`. See the module docs.
pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
write_atomic_with(path, |f| f.write_all(bytes))
}
/// [`write_atomic`] for a command's `path` parameter: the error names the path.
pub(crate) fn write_file(path: &str, bytes: &[u8]) -> crate::Result<()> {
write_atomic(Path::new(path), bytes).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))
}
/// Atomically replace (or create) `path` with what `fill` writes into the temporary file. When
/// `fill`, the flush or the rename fails, the temporary file is removed, `path` is untouched and
/// the error is returned.
pub fn write_atomic_with(path: &Path, fill: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
let target = resolve_links(path)?;
target.file_name().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, format!("{}: not a file path", path.display())))?;
let dir = match target.parent() {
Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(),
_ => PathBuf::from("."),
};
let existing = std::fs::metadata(&target).ok();
if existing.is_some() {
check_writable(&target)?;
}
let mut tmp = create_temp(&dir, existing.as_ref())?;
if let Some(existing) = existing {
// Restore the exact mode before any document bytes are written, and flush it along with
// the content. The caller's umask may have restricted the initial temporary-file mode.
keep_permissions(tmp.as_file(), &existing)?;
}
fill(tmp.as_file_mut())?;
tmp.as_file().sync_all()?;
// Closing the file before renaming also allows replacement on Windows. TempPath removes the
// staging file on every error; NamedTempFile does the same if the writer unwinds.
let tmp = tmp.into_temp_path();
retry_rename(RENAME_TRIES, || std::fs::rename(&tmp, &target))?;
// The new bytes are in place. A failed folder flush can't be undone and must not report the
// save as failed.
let _ = sync_dir(&dir);
Ok(())
}
/// Create a fresh temporary file beside the target (the same folder, so the rename stays on one
/// volume). A short randomized name supports long destination names and skips existing entries.
fn create_temp(dir: &Path, existing: Option<&std::fs::Metadata>) -> io::Result<tempfile::NamedTempFile> {
let mut builder = tempfile::Builder::new();
builder.prefix(".designcraft-save-").suffix(".tmp");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
// Private replacements are private from creation. New files retain fs::write's normal
// 0666 mode restricted by the caller's umask.
let mode = existing.map_or(0o666, |metadata| metadata.permissions().mode());
builder.permissions(std::fs::Permissions::from_mode(mode));
}
#[cfg(not(unix))]
let _ = existing;
builder.tempfile_in(dir)
}
/// The file a chain of symbolic links ends at (`path` itself when it is not a link). A dangling
/// link resolves to the missing file it names, which is then created.
fn resolve_links(path: &Path) -> io::Result<PathBuf> {
let mut p = path.to_path_buf();
for _ in 0..MAX_LINKS {
match std::fs::symlink_metadata(&p) {
Ok(m) if m.file_type().is_symlink() => {
let to = std::fs::read_link(&p)?;
p = match p.parent() {
Some(dir) if to.is_relative() => dir.join(to),
_ => to,
};
}
_ => return Ok(p),
}
}
Err(io::Error::new(io::ErrorKind::InvalidInput, format!("{}: too many levels of symbolic links", path.display())))
}
/// A rename replaces a file the user can't write (Unix only needs the folder to be writable);
/// refuse, as writing it in place would. Opening for writing without truncating changes nothing.
#[cfg(unix)]
fn check_writable(target: &Path) -> io::Result<()> {
OpenOptions::new().write(true).open(target).map(drop)
}
/// Windows refuses to rename over a read-only file by itself.
#[cfg(not(unix))]
fn check_writable(_: &Path) -> io::Result<()> {
Ok(())
}
/// The replacement keeps the replaced file's mode (Unix; Windows files keep only a read-only flag,
/// and a read-only file can't be replaced).
#[cfg(unix)]
fn keep_permissions(tmp: &File, replaced: &std::fs::Metadata) -> io::Result<()> {
tmp.set_permissions(replaced.permissions())
}
#[cfg(not(unix))]
fn keep_permissions(_: &File, _: &std::fs::Metadata) -> io::Result<()> {
Ok(())
}
/// Run `rename` up to `tries` times, pausing with a doubling delay while it fails with
/// `PermissionDenied` (a scanner holding the file on Windows). Other errors return at once.
fn retry_rename(tries: u32, mut rename: impl FnMut() -> io::Result<()>) -> io::Result<()> {
let mut delay = RENAME_FIRST_DELAY_MS;
let mut left = tries.max(1);
loop {
left = left.saturating_sub(1);
match rename() {
Err(e) if e.kind() == io::ErrorKind::PermissionDenied && left > 0 => {
std::thread::sleep(std::time::Duration::from_millis(delay));
delay = delay.saturating_mul(2).min(RENAME_MAX_DELAY_MS);
}
r => return r,
}
}
}
/// Flush a folder's entries (Unix). Rust's portable file API cannot open a directory on Windows;
/// this does not establish power-loss durability for its directory entries.
#[cfg(unix)]
fn sync_dir(dir: &Path) -> io::Result<()> {
File::open(dir)?.sync_all()
}
#[cfg(not(unix))]
fn sync_dir(_: &Path) -> io::Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn dir(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("dc-atomic-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// The names in `d`, sorted.
fn names(d: &Path) -> Vec<String> {
let mut v: Vec<String> = std::fs::read_dir(d).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
v.sort();
v
}
#[test]
fn replaces_and_creates_without_leftovers() {
let d = dir("replace");
let path = d.join("doc.designcraft");
write_atomic(&path, b"first").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"first");
write_atomic(&path, b"second, longer").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"second, longer");
write_atomic(&path, b"3").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"3");
assert_eq!(names(&d), ["doc.designcraft"], "no temporary file is left");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_failed_write_leaves_the_original_and_no_temporary_file() {
let d = dir("fail");
let path = d.join("doc.designcraft");
std::fs::write(&path, b"original").unwrap();
let r = write_atomic_with(&path, |f| {
f.write_all(b"half a docu")?;
Err(io::Error::other("disk full"))
});
assert_eq!(r.unwrap_err().to_string(), "disk full");
assert_eq!(std::fs::read(&path).unwrap(), b"original");
assert_eq!(names(&d), ["doc.designcraft"], "the temporary file is removed");
// A folder that doesn't exist: an error, nothing written.
assert!(write_atomic(&d.join("missing").join("x.designcraft"), b"x").is_err());
assert_eq!(names(&d), ["doc.designcraft"]);
// A folder in the way: an error, the folder stays and nothing is left beside it.
std::fs::create_dir(d.join("folder")).unwrap();
assert!(write_atomic(&d.join("folder"), b"x").is_err());
assert_eq!(names(&d), ["doc.designcraft", "folder"]);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_long_destination_name_does_not_lengthen_the_temporary_name() {
let d = dir("long-name");
let name = format!("{}.designcraft", "a".repeat(235));
let path = d.join(&name);
std::fs::write(&path, b"old").unwrap();
write_atomic(&path, b"new").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"new");
assert_eq!(names(&d), [name]);
let _ = std::fs::remove_dir_all(&d);
}
#[cfg(unix)]
#[test]
fn private_permissions_are_preserved_while_the_file_is_written() {
use std::os::unix::fs::PermissionsExt as _;
let d = dir("private-staging");
let path = d.join("private.designcraft");
std::fs::write(&path, b"old").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
write_atomic_with(&path, |file| {
assert_eq!(file.metadata()?.permissions().mode() & 0o777, 0o600);
file.write_all(b"private document")?;
assert_eq!(file.metadata()?.permissions().mode() & 0o777, 0o600);
Ok(())
})
.unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"private document");
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
assert_eq!(names(&d), ["private.designcraft"]);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn an_unwinding_writer_removes_its_partial_temporary_file() {
let d = dir("unwinding-writer");
let path = d.join("private.designcraft");
std::fs::write(&path, b"old").unwrap();
let result = std::panic::catch_unwind(|| {
write_atomic_with(&path, |file| {
file.write_all(b"partial private document")?;
panic!("synthetic writer panic");
})
});
assert!(result.is_err());
assert_eq!(std::fs::read(&path).unwrap(), b"old");
assert_eq!(names(&d), ["private.designcraft"]);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn the_rename_is_retried_only_while_access_is_denied() {
let mut calls = 0;
let r = retry_rename(3, || {
calls += 1;
if calls < 3 { Err(io::Error::from(io::ErrorKind::PermissionDenied)) } else { Ok(()) }
});
assert!(r.is_ok() && calls == 3);
calls = 0;
let r = retry_rename(3, || {
calls += 1;
Err(io::Error::from(io::ErrorKind::PermissionDenied))
});
assert_eq!(r.map_err(|e| e.kind()), Err(io::ErrorKind::PermissionDenied));
assert_eq!(calls, 3, "gives up after the last try");
calls = 0;
let r = retry_rename(3, || {
calls += 1;
Err(io::Error::from(io::ErrorKind::CrossesDevices))
});
assert!(r.is_err() && calls == 1, "other errors are not retried");
}
#[cfg(unix)]
#[test]
fn permissions_are_kept_and_unwritable_files_are_not_replaced() {
use std::os::unix::fs::PermissionsExt as _;
let d = dir("perms");
let path = d.join("private.designcraft");
std::fs::write(&path, b"old").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
write_atomic(&path, b"new").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"new");
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
// A read-only file stays as it is (root may write it anyway).
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o400)).unwrap();
if OpenOptions::new().write(true).open(&path).is_err() {
assert!(write_atomic(&path, b"newer").is_err());
assert_eq!(std::fs::read(&path).unwrap(), b"new");
assert_eq!(names(&d), ["private.designcraft"]);
}
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
let _ = std::fs::remove_dir_all(&d);
}
/// A folder the user can't write: the save fails and the original stays.
#[cfg(unix)]
#[test]
fn an_unwritable_folder_fails_without_touching_the_file() {
use std::os::unix::fs::PermissionsExt as _;
let d = dir("ro-dir");
let path = d.join("doc.designcraft");
std::fs::write(&path, b"original").unwrap();
std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o555)).unwrap();
// Root writes anyway; the check only means something for an ordinary user.
let probe = d.join("probe");
let unwritable = std::fs::write(&probe, b"").is_err();
if unwritable {
assert!(write_atomic(&path, b"new").is_err());
assert_eq!(std::fs::read(&path).unwrap(), b"original");
}
std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o755)).unwrap();
let _ = std::fs::remove_file(&probe);
assert_eq!(names(&d), ["doc.designcraft"]);
let _ = std::fs::remove_dir_all(&d);
}
#[cfg(unix)]
#[test]
fn a_symbolic_link_keeps_pointing_at_the_replaced_file() {
let d = dir("link");
std::fs::create_dir(d.join("real")).unwrap();
let target = d.join("real").join("doc.designcraft");
std::fs::write(&target, b"old").unwrap();
let link = d.join("link.designcraft");
std::os::unix::fs::symlink("real/doc.designcraft", &link).unwrap();
write_atomic(&link, b"new").unwrap();
assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink());
assert_eq!(std::fs::read(&target).unwrap(), b"new");
assert_eq!(names(&d), ["link.designcraft", "real"]);
assert_eq!(names(&d.join("real")), ["doc.designcraft"]);
// A loop of links is an error, not a hang.
std::os::unix::fs::symlink("b", d.join("a")).unwrap();
std::os::unix::fs::symlink("a", d.join("b")).unwrap();
assert!(write_atomic(&d.join("a"), b"x").is_err());
let _ = std::fs::remove_dir_all(&d);
}
}

View File

@ -28,7 +28,7 @@ fn no_book() -> EngineError {
#[cfg(not(target_arch = "wasm32"))]
fn save(b: &Book) -> Result<()> {
let bytes = serde_json::to_vec_pretty(b).map_err(|e| EngineError::Other(e.to_string()))?;
std::fs::write(&b.path, bytes).map_err(|e| EngineError::Other(format!("{}: {e}", b.path)))
crate::atomic::write_file(&b.path, &bytes)
}
#[cfg(target_arch = "wasm32")]
@ -47,20 +47,176 @@ fn load(path: &str) -> Result<designcraft_doc::Document> {
if path.to_lowercase().ends_with(".idml") {
return designcraft_idml::import_idml(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")));
}
super::file::from_bytes(&bytes)
super::file::from_bytes(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
fn store(path: &str, d: &designcraft_doc::Document) -> Result<()> {
#[cfg(not(target_arch = "wasm32"))]
return std::fs::write(path, super::file::to_bytes(d)).map_err(|e| EngineError::Other(format!("{path}: {e}")));
#[cfg(target_arch = "wasm32")]
{
let _ = (path, d);
Err(EngineError::Other("books need a file system".into()))
#[cfg(not(target_arch = "wasm32"))]
const MAX_BATCH_CHAPTERS: usize = 4096;
/// A batch keeps only bounded path records in memory. Chapter snapshots live in a private
/// temporary directory; only one document and serialized blob are live while staging.
#[cfg(not(target_arch = "wasm32"))]
struct ChapterBatch {
directory: tempfile::TempDir,
chapters: Vec<(String, tempfile::TempPath)>,
command: &'static str,
}
#[cfg(not(target_arch = "wasm32"))]
impl ChapterBatch {
fn new(chapters: usize, command: &'static str) -> Result<Self> {
if chapters > MAX_BATCH_CHAPTERS {
return Err(bad(command, format!("a book update supports at most {MAX_BATCH_CHAPTERS} chapters")));
}
let directory = tempfile::Builder::new()
.prefix(".designcraft-book-")
.tempdir()
.map_err(|e| EngineError::Other(format!("{command}: cannot stage chapter changes: {e}")))?;
Ok(Self { directory, chapters: Vec::with_capacity(chapters), command })
}
fn stage(&mut self, path: &str, doc: &designcraft_doc::Document) -> Result<()> {
use std::io::Write;
// Propagate serialization errors; an empty fallback must never replace a chapter.
let bytes = designcraft_format::save(doc).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let mut file = tempfile::Builder::new()
.prefix("chapter-")
.tempfile_in(self.directory.path())
.map_err(|e| EngineError::Other(format!("{path}: cannot stage changes: {e}")))?;
file.write_all(&bytes).map_err(|e| EngineError::Other(format!("{path}: cannot stage changes: {e}")))?;
self.chapters.push((path.to_string(), file.into_temp_path()));
Ok(())
}
fn commit(self) -> Result<()> {
self.commit_with(write_staged_chapter)
}
fn commit_with(self, mut write: impl FnMut(&std::path::Path, &std::path::Path) -> std::io::Result<()>) -> Result<()> {
let mut completed = Vec::with_capacity(self.chapters.len());
for (path, staged) in self.chapters {
if let Err(e) = write(std::path::Path::new(&path), &staged) {
// Each file is atomic; the whole book is not. Keep the successful prefix visible
// rather than pretending that an error undid earlier chapter replacements.
return Err(EngineError::Other(format!(
"{}: failed to save chapter {path}: {e}; completed chapter paths: {completed:?}",
self.command
)));
}
completed.push(path);
}
Ok(())
}
}
#[cfg(not(target_arch = "wasm32"))]
fn write_staged_chapter(path: &std::path::Path, staged: &std::path::Path) -> std::io::Result<()> {
let mut input = std::fs::File::open(staged)?;
crate::atomic::write_atomic_with(path, |file| std::io::copy(&mut input, file).map(|_| ()))
}
/// Batch commands only write closed native chapters. IDML is an imported source, and an open
/// chapter's model (including unsaved edits) cannot be reconciled by a disk-only book update.
#[cfg(not(target_arch = "wasm32"))]
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")) {
return Err(bad(command, format!("cannot change imported IDML chapter {path}; convert it to a native DesignCraft document first")));
}
let target = std::fs::canonicalize(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if s.docs.iter().any(|doc| doc.path.as_ref().is_some_and(|open| std::fs::canonicalize(open).is_ok_and(|open| open == target))) {
return Err(bad(command, format!("chapter {path} is open; close it before updating the book")));
}
if !std::fs::metadata(&target).is_ok_and(|metadata| metadata.is_file()) {
return Err(bad(command, format!("chapter {path} is not a regular file")));
}
// Check both the file and its resolved parent without truncating it. Replacement needs a
// writable directory even when an in-place write would be permitted.
std::fs::OpenOptions::new().write(true).open(&target).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let parent = target.parent().ok_or_else(|| bad(command, format!("chapter {path} has no parent folder")))?;
tempfile::Builder::new().prefix(".designcraft-book-check-").tempfile_in(parent).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
fn paginate(s: &Session) -> Result<Value> {
let b = s.book.as_ref().ok_or_else(no_book)?;
if b.documents.is_empty() {
return Ok(json!({"pages": 0}));
}
let mut batch = ChapterBatch::new(b.documents.len(), "book.paginate")?;
let mut next = 1u32;
for path in &b.documents {
writable_chapter(s, path, "book.paginate")?;
let mut d = load(path)?;
start_at(&mut d, next);
let pages = u32::try_from(d.page_count()).map_err(|_| bad("book.paginate", "too many pages for continuous numbering"))?;
next = next.checked_add(pages).ok_or_else(|| bad("book.paginate", "too many pages for continuous numbering"))?;
batch.stage(path, &d)?;
}
batch.commit()?;
Ok(json!({"pages": next - 1}))
}
#[cfg(not(target_arch = "wasm32"))]
fn sync_styles(s: &Session) -> Result<Value> {
let b = s.book.as_ref().ok_or_else(no_book)?;
let mut batch = ChapterBatch::new(b.documents.len(), "book.syncStyles")?;
let src_path = b.documents.get(b.style_source).ok_or_else(|| bad("book.syncStyles", "the book is empty"))?;
let src = load(src_path)?;
let styles = src.styles.clone();
let swatches = src.swatches.clone();
drop(src);
let mut n = 0;
for (i, path) in b.documents.iter().enumerate() {
if i == b.style_source {
continue;
}
writable_chapter(s, path, "book.syncStyles")?;
let mut d = load(path)?;
{
let st = d.styles_mut();
for ps in &styles.paragraph {
st.paragraph.retain(|x| x.name != ps.name);
st.paragraph.push(ps.clone());
}
for cs in &styles.character {
st.character.retain(|x| x.name != cs.name);
st.character.push(cs.clone());
}
}
for sw in &swatches {
match d.swatches.iter_mut().find(|w| w.name == sw.name) {
Some(w) if !w.locked => *w = sw.clone(),
Some(_) => {}
None => d.swatches.push(sw.clone()),
}
}
batch.stage(path, &d)?;
n += 1;
}
batch.commit()?;
Ok(json!({"synced": n}))
}
#[cfg(target_arch = "wasm32")]
fn paginate(_: &Session) -> Result<Value> {
Err(EngineError::Other("books need a file system".into()))
}
#[cfg(target_arch = "wasm32")]
fn sync_styles(_: &Session) -> Result<Value> {
Err(EngineError::Other("books need a file system".into()))
}
#[cfg(all(test, not(target_arch = "wasm32")))]
#[path = "book/safety_tests.rs"]
mod safety_tests;
/// Make page numbering of `d` start at `n` (the first section).
#[cfg(not(target_arch = "wasm32"))]
fn start_at(d: &mut designcraft_doc::Document, n: u32) {
if !d.sections.iter().any(|x| x.start == 0) {
d.sections.insert(
@ -108,15 +264,16 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "book.add", "Add Document", [], None, "{path, at?: index}", has_book, |s, p| {
let path = str_param(p, "path").ok_or_else(|| bad("book.add", "`path` required"))?.to_string();
load(&path)?;
let b = s.book.as_mut().ok_or_else(no_book)?;
let mut b = s.book.as_ref().ok_or_else(no_book)?.clone();
let at = p.get("at").and_then(Value::as_u64).map_or(b.documents.len(), |i| (i as usize).min(b.documents.len()));
b.documents.insert(at, path);
save(b)?;
save(&b)?;
s.book = Some(b);
ok()
}),
cmd!(noundo "book.remove", "Remove Document", [], None, "{index}", has_book, |s, p| {
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("book.remove", "`index` required"))? as usize;
let b = s.book.as_mut().ok_or_else(no_book)?;
let mut b = s.book.as_ref().ok_or_else(no_book)?.clone();
if i >= b.documents.len() {
return Err(bad("book.remove", format!("no document {i}")));
}
@ -124,17 +281,19 @@ pub fn specs() -> Vec<CommandSpec> {
if b.style_source >= b.documents.len() {
b.style_source = 0;
}
save(b)?;
save(&b)?;
s.book = Some(b);
ok()
}),
cmd!(noundo "book.styleSource", "Style Source", [], None, "{index}", has_book, |s, p| {
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
let b = s.book.as_mut().ok_or_else(no_book)?;
let mut b = s.book.as_ref().ok_or_else(no_book)?.clone();
if i >= b.documents.len() {
return Err(bad("book.styleSource", format!("no document {i}")));
}
b.style_source = i;
save(b)?;
save(&b)?;
s.book = Some(b);
ok()
}),
cmd!(query "book.list", "Book", [], None, "{} → {path, styleSource, documents: [{path, pages, firstPage}]}", has_book, |s, _| {
@ -149,48 +308,10 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"path": b.path, "styleSource": b.style_source, "documents": docs}))
}),
cmd!(noundo "book.paginate", "Update Numbering", [], None, "{} — each document starts numbering where the previous one ended (files are saved)", has_book, |s, _| {
let b = s.book.clone().ok_or_else(no_book)?;
let mut next = 1u32;
for p in &b.documents {
let mut d = load(p)?;
start_at(&mut d, next);
next += d.page_count() as u32;
store(p, &d)?;
}
Ok(json!({"pages": next - 1}))
paginate(s)
}),
cmd!(noundo "book.syncStyles", "Synchronize Book", [], None, "{} — paragraph and character styles and swatches of the style source go into every document (by name)", has_book, |s, _| {
let b = s.book.clone().ok_or_else(no_book)?;
let src_path = b.documents.get(b.style_source).ok_or_else(|| bad("book.syncStyles", "the book is empty"))?;
let src = load(src_path)?;
let mut n = 0;
for (i, p) in b.documents.iter().enumerate() {
if i == b.style_source {
continue;
}
let mut d = load(p)?;
{
let st = d.styles_mut();
for ps in &src.styles.paragraph {
st.paragraph.retain(|x| x.name != ps.name);
st.paragraph.push(ps.clone());
}
for cs in &src.styles.character {
st.character.retain(|x| x.name != cs.name);
st.character.push(cs.clone());
}
}
for sw in &src.swatches {
match d.swatches.iter_mut().find(|w| w.name == sw.name) {
Some(w) if !w.locked => *w = sw.clone(),
Some(_) => {}
None => d.swatches.push(sw.clone()),
}
}
store(p, &d)?;
n += 1;
}
Ok(json!({"synced": n}))
sync_styles(s)
}),
cmd!(noundo "book.exportPdf", "Export Book to PDF…", [], None, "{path?} — every document in order as one PDF → {path, bytes, pages} (no path: {base64, …})", has_book, |s, p| {
let b = s.book.clone().ok_or_else(no_book)?;
@ -210,7 +331,7 @@ pub fn specs() -> Vec<CommandSpec> {
match str_param(p, "path") {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
crate::atomic::write_file(path, &bytes)?;
Ok(json!({"path": path, "bytes": bytes.len(), "pages": pages}))
}
None => Ok(json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len(), "pages": pages})),
@ -235,6 +356,7 @@ mod tests {
s.execute("file.new", &json!({"pages": pages})).unwrap();
s.execute("style.paragraph.create", &json!({"name": style, "chars": {"size": 20}})).unwrap();
s.execute("file.saveAs", &json!({"path": p(name)})).unwrap();
s.execute("file.close", &json!({})).unwrap();
}
s.execute("book.new", &json!({"path": p("book.dcbook")})).unwrap();
s.execute("book.add", &json!({"path": p("a.designcraft")})).unwrap();

View File

@ -0,0 +1,52 @@
use super::{ChapterBatch, MAX_BATCH_CHAPTERS, write_staged_chapter};
use crate::Session;
use serde_json::json;
#[test]
fn a_commit_failure_reports_the_completed_prefix_and_cleans_staging() {
let dir = tempfile::tempdir().unwrap();
let first = dir.path().join("first.designcraft");
let second = dir.path().join("second.designcraft");
let third = dir.path().join("third.designcraft");
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let old = (*s.doc().unwrap().doc).clone();
let mut changed = old.clone();
changed.sections.first_mut().unwrap().start_number = Some(9);
let old_bytes = designcraft_format::save(&old).unwrap();
for path in [&first, &second, &third] {
std::fs::write(path, &old_bytes).unwrap();
}
let mut batch = ChapterBatch::new(3, "book.paginate").unwrap();
for path in [&first, &second, &third] {
batch.stage(path.to_str().unwrap(), &changed).unwrap();
}
let staging = batch.directory.path().to_path_buf();
let mut calls = 0;
let error = batch
.commit_with(|path, staged| {
calls += 1;
if path == second { Err(std::io::Error::other("synthetic disk full")) } else { write_staged_chapter(path, staged) }
})
.unwrap_err()
.to_string();
assert_eq!(calls, 2);
assert!(error.contains("synthetic disk full"), "{error}");
assert!(error.contains(second.to_str().unwrap()), "{error}");
let completed = error.split("completed chapter paths:").nth(1).unwrap();
assert!(completed.contains(first.to_str().unwrap()), "{error}");
assert!(!completed.contains(second.to_str().unwrap()) && !completed.contains(third.to_str().unwrap()), "{error}");
assert_eq!(designcraft_format::load(&std::fs::read(first).unwrap()).unwrap().sections.first().unwrap().start_number, Some(9));
assert_eq!(std::fs::read(second).unwrap(), old_bytes);
assert_eq!(std::fs::read(third).unwrap(), old_bytes);
assert!(!staging.exists(), "temporary chapter snapshots must be removed on a partial failure");
}
#[test]
fn chapter_record_limit_is_checked_before_staging_allocation() {
let error = match ChapterBatch::new(MAX_BATCH_CHAPTERS + 1, "book.paginate") {
Err(e) => e,
Ok(_) => panic!("oversized book batch accepted"),
};
assert!(error.to_string().contains("4096"));
}

View File

@ -75,6 +75,11 @@ fn renumber(dst: &mut designcraft_doc::Document, src: &designcraft_doc::Document
let new_id = ItemId(dst.alloc());
maps.items.insert(old, new_id);
it.id = new_id;
if let Some(m) = it.media.as_mut()
&& let Some(p) = m.poster
{
m.poster = dst.adopt_asset(src, p).or(Some(p));
}
match &mut it.content {
Content::Text(tf) => {
let new_sid = if let Some(n) = maps.stories.get(&tf.story) {
@ -83,6 +88,15 @@ fn renumber(dst: &mut designcraft_doc::Document, src: &designcraft_doc::Document
let n = StoryId(dst.alloc());
let mut st = src.story(tf.story).cloned().unwrap_or_else(|| Story::new(n));
st.id = n;
let mut assets = HashMap::new();
for a in designcraft_doc::story_asset_refs(&st) {
if let Some(na) = dst.adopt_asset(src, a)
&& na != a
{
assets.insert(a, na);
}
}
designcraft_doc::remap_story_assets(&mut st, &assets);
st.frames.clear();
dst.stories.insert(n, Arc::new(st));
maps.stories.insert(tf.story, n);
@ -94,8 +108,8 @@ fn renumber(dst: &mut designcraft_doc::Document, src: &designcraft_doc::Document
}
}
Content::Graphic(g) => {
if let Some(a) = src.assets.get(&g.asset) {
dst.assets.entry(g.asset).or_insert_with(|| a.clone());
if let Some(a) = dst.adopt_asset(src, g.asset) {
g.asset = a;
}
}
Content::Group { items } => {
@ -355,21 +369,18 @@ fn place_image(
};
let (nw, nh) = (f64::from(pw), f64::from(ph));
let mode = fitting_of(&options.fitting);
let aid = AssetId(doc.alloc());
let fresh = AssetId(doc.alloc());
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| field.to_string());
let link = options.link_images.then(|| path.to_string_lossy().into_owned());
doc.assets.insert(
aid,
Arc::new(Asset {
id: aid,
name,
mime: designcraft_render::image_mime(&bytes).to_string(),
link,
data: Arc::new(bytes),
pixels: Some((pw, ph)),
page: 0,
}),
);
let aid = doc.add_asset(Asset {
id: fresh,
name,
mime: designcraft_render::image_mime(&bytes).to_string(),
link,
data: Arc::new(bytes),
pixels: Some((pw, ph)),
page: 0,
});
let mut g = Graphic { asset: aid, size: (nw, nh), xf: Affine::translate((inner.x0, inner.y0)), auto_fit: mode, fit_align: 0, crop: [0.0; 4] };
if let Some(xf) = g.fitted(inner, mode) {
g.xf = xf;

View File

@ -367,10 +367,16 @@ fn sniff(name: &str, text: &str) -> Delimiter {
if ext == "csv" {
return Delimiter::Comma;
}
let header = first_physical_line(text);
if header.contains('\t') {
let tab = header_delimiter_candidate(text, '\t');
let semicolon = header_delimiter_candidate(text, ';');
let comma = header_delimiter_candidate(text, ',');
// Prefer formats with coherent quoted-field boundaries. If none separates fields,
// retain the legacy permissive choice; this is detection, not record validation.
let prefer_coherent = [&tab, &semicolon, &comma].iter().any(|c| c.found && c.coherent);
let eligible = |c: &HeaderDelimiterCandidate| c.found && (!prefer_coherent || c.coherent);
if eligible(&tab) {
Delimiter::Tab
} else if header.contains(';') && !header.contains(',') {
} else if eligible(&semicolon) && !eligible(&comma) {
Delimiter::Semicolon
} else {
Delimiter::Comma
@ -381,8 +387,66 @@ pub fn extension(name: &str) -> String {
Path::new(name).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase()
}
fn first_physical_line(text: &str) -> &str {
text.split('\n').next().unwrap_or("").trim_end_matches('\r')
struct HeaderDelimiterCandidate {
found: bool,
coherent: bool,
}
/// A candidate separator outside quotes in its first nonempty logical record.
/// Only that candidate starts a new field. Quotes in an unquoted field stay literal;
/// Non-whitespace text after a quoted field closes lowers coherence, not validity.
fn header_delimiter_candidate(text: &str, delimiter: char) -> HeaderDelimiterCandidate {
let mut found = false;
let mut coherent = true;
let mut quoted = false;
let mut after_quote = false;
let mut field_start = true;
let mut nonempty = false;
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
if quoted {
if c == '"' {
if chars.peek() == Some(&'"') {
chars.next();
nonempty = true;
} else {
quoted = false;
after_quote = true;
}
} else if !c.is_whitespace() {
nonempty = true;
}
continue;
}
match c {
'"' if field_start => {
quoted = true;
field_start = false;
}
separator if separator == delimiter => {
found = true;
field_start = true;
after_quote = false;
}
'\r' | '\n' => {
if nonempty {
break;
}
found = false;
coherent = true;
field_start = true;
after_quote = false;
}
_ => {
// Whitespace may follow a closing quote, but does not start a new field
// or allow a later non-whitespace suffix to regain coherence.
coherent &= !after_quote || c.is_whitespace();
nonempty |= !c.is_whitespace();
field_start = false;
}
}
}
HeaderDelimiterCandidate { found, coherent: coherent && !quoted }
}
/// Header cell: trim, drop one leading apostrophe, then `@` is image and `#` is qr.
@ -452,6 +516,7 @@ fn parse_delimited(s: &str, delim: char) -> Result<Vec<RawRow>, String> {
let mut row: Vec<String> = Vec::new();
let mut field = String::new();
let mut quoted = false;
let mut field_start = true;
let mut line = 1usize;
let mut row_line = 1usize;
let mut data_rows = 0usize;
@ -501,21 +566,25 @@ fn parse_delimited(s: &str, delim: char) -> Result<Vec<RawRow>, String> {
};
while let Some(c) = chars.next() {
if c == '\n' {
if c == '\n' || (c == '\r' && chars.peek() != Some(&'\n')) {
line = line.saturating_add(1);
}
if quoted {
match c {
'"' if chars.peek() == Some(&'"') => {
field.push('"');
if !discarding {
field.push('"');
}
chars.next();
}
'"' => quoted = false,
_ => field.push(c),
_ if !discarding => field.push(c),
_ => {}
}
continue;
}
if c == delim {
field_start = true;
if discarding {
field.clear();
continue;
@ -531,14 +600,25 @@ fn parse_delimited(s: &str, delim: char) -> Result<Vec<RawRow>, String> {
continue;
}
match c {
'"' if field.is_empty() && !discarding => quoted = true,
'\r' => {}
'\n' => {
'"' if field_start => {
quoted = true;
field_start = false;
}
'\r' | '\n' => {
if c == '\r' && chars.peek() == Some(&'\n') {
chars.next();
line = line.saturating_add(1);
}
finish(&mut row, &mut field, &mut rows, &mut data_rows, &mut header_width, row_line, &mut discarding)?;
row_line = line;
field_start = true;
}
_ => {
field_start = false;
if !discarding {
field.push(c);
}
}
_ if discarding => {}
_ => field.push(c),
}
}
if !field.is_empty() || !row.is_empty() || discarding {

View File

@ -85,6 +85,282 @@ fn csv_parsing() {
assert_eq!(quoted[1][0], "Ada\nGrace");
}
#[test]
fn data_source_sniffs_quoted_logical_headers() {
for (body, delimiter, names) in [
("\"Last, First\";City\nAda;London\n", Delimiter::Semicolon, ["Last, First", "City"]),
("\"Name\tLabel\",City\nAda,London\n", Delimiter::Comma, ["Name\tLabel", "City"]),
("\"Name\nLabel\";City\nAda;London\n", Delimiter::Semicolon, ["Name\nLabel", "City"]),
("\r\n \r\nName;City\r\nAda;London\r\n", Delimiter::Semicolon, ["Name", "City"]),
("\"Name \"\"label,\"\"\";City\nAda;London\n", Delimiter::Semicolon, ["Name \"label,\"", "City"]),
] {
let mut s = Session::new();
new_doc(&mut s, 1);
let result = exec(&mut s, "data.source.select", json!({"bytes": crate::cmd::base64_encode(body.as_bytes()), "name": "people.txt"}));
assert_eq!(result["records"], 1, "{body:?}");
let source = &s.documents()[0].doc.data_merge.sources[0];
assert_eq!(source.delimiter, delimiter, "{body:?}");
assert_eq!(source.fields.iter().map(|f| f.name.as_str()).collect::<Vec<_>>(), names, "{body:?}");
assert_eq!(source.rows, vec![vec!["Ada", "London"]], "{body:?}");
}
}
#[test]
fn data_source_sniff_quote_state_belongs_to_the_candidate_delimiter() {
for ending in ["\n", "\r\n", "\r"] {
for (body, delimiter, names) in [
(format!("Name,\"preferred\tCity\"{ending}Ada\tLondon{ending}"), Delimiter::Tab, ["Name,\"preferred", "City\""]),
(format!("\"Last, First\";City{ending}Ada;London{ending}"), Delimiter::Semicolon, ["Last, First", "City"]),
(format!("\"Name\tLabel\",City{ending}Ada,London{ending}"), Delimiter::Comma, ["Name\tLabel", "City"]),
(format!("\"\";\"\"{ending} \t{ending}\"Last, First\";City{ending}Ada;London{ending}"), Delimiter::Semicolon, ["Last, First", "City"]),
] {
let automatic = parse::table_from_bytes(body.as_bytes(), "people.txt", None).unwrap();
let explicit = parse::table_from_bytes(body.as_bytes(), "people.txt", Some(delimiter)).unwrap();
let fields: Vec<_> = names.into_iter().map(|name| DataField { name: name.into(), kind: DataFieldKind::Text }).collect();
assert_eq!(automatic.delimiter, delimiter, "{body:?}");
assert_eq!(automatic.fields, fields, "{body:?}");
assert_eq!(automatic.rows, vec![vec!["Ada", "London"]], "{body:?}");
assert!(automatic.warnings.is_empty(), "{body:?}: {:?}", automatic.warnings);
assert_eq!(automatic, explicit, "{body:?}");
}
}
}
#[test]
fn data_source_sniff_prefers_coherent_quoted_field_boundaries() {
for ending in ["\n", "\r\n", "\r"] {
for (header, first, second, delimiter, names, rows, warnings) in [
(
"\"Name\";\"Last, First\"",
"\"Ada\";\"Lovelace, Countess\"",
"\"Grace\";\"Hopper, Admiral\"",
Delimiter::Semicolon,
vec!["Name", "Last, First"],
vec![vec!["Ada", "Lovelace, Countess"], vec!["Grace", "Hopper, Admiral"]],
vec![],
),
(
"\"Name\",\"Region\tCity\"",
"\"Ada\",\"London\"",
"\"Grace\",\"Paris\"",
Delimiter::Comma,
vec!["Name", "Region\tCity"],
vec![vec!["Ada", "London"], vec!["Grace", "Paris"]],
vec![],
),
(
"\"Name\";\"City\";\"Last, \"\"First\"\"\";\"Title\"",
"\"Ada\";\"London\";\"Lovelace, \"\"Countess\"\"\";\"Analyst\"",
"\"Grace\";\"Paris\";\"Hopper, \"\"Admiral\"\"\";\"Engineer\";\"ignored\"",
Delimiter::Semicolon,
vec!["Name", "City", "Last, \"First\"", "Title"],
vec![vec!["Ada", "London", "Lovelace, \"Countess\"", "Analyst"], vec!["Grace", "Paris", "Hopper, \"Admiral\"", "Engineer"]],
vec!["Row 3 has cells past the header. Those cells were ignored."],
),
(
"\"Name\",\"Role\",\"Region\t\"\"City\"\"\",\"Title\"",
"\"Ada\",\"Analyst\",\"London\t\"\"West End\"\"\",\"Countess\"",
"\"Grace\",\"Engineer\",\"Paris\t\"\"Centre\"\"\",\"Admiral\",\"ignored\"",
Delimiter::Comma,
vec!["Name", "Role", "Region\t\"City\"", "Title"],
vec![vec!["Ada", "Analyst", "London\t\"West End\"", "Countess"], vec!["Grace", "Engineer", "Paris\t\"Centre\"", "Admiral"]],
vec!["Row 3 has cells past the header. Those cells were ignored."],
),
] {
let body = format!("{header}{ending}{first}{ending}{second}{ending}");
let automatic = parse::table_from_bytes(body.as_bytes(), "people.txt", None).unwrap();
let explicit = parse::table_from_bytes(body.as_bytes(), "people.txt", Some(delimiter)).unwrap();
let fields: Vec<_> = names.into_iter().map(|name| DataField { name: name.into(), kind: DataFieldKind::Text }).collect();
assert_eq!(automatic.delimiter, delimiter, "{body:?}");
assert_eq!(automatic.fields, fields, "{body:?}");
assert_eq!(automatic.rows, rows, "{body:?}");
assert_eq!(automatic.warnings, warnings, "{body:?}");
assert_eq!(automatic, explicit, "{body:?}");
}
}
}
#[test]
fn data_source_sniff_retains_priority_and_permissive_fallback() {
for ending in ["\n", "\r\n", "\r"] {
for (header, row, delimiter, names, cells) in [
// Equally coherent candidates retain Tab, then Comma, then Semicolon priority.
("Name,Title\tCity", "Ada,Analyst\tLondon\tignored", Delimiter::Tab, ["Name,Title", "City"], ["Ada,Analyst", "London"]),
("Name;Title,City", "Ada;Analyst,London,ignored", Delimiter::Comma, ["Name;Title", "City"], ["Ada;Analyst", "London"]),
// No coherent candidate separates fields here. Keep parsing the trailing text
// after a quoted field exactly as before, using the same delimiter priority.
(
"\"Name\"suffix,Title\tCity",
"\"Ada\"suffix,Analyst\tLondon\tignored",
Delimiter::Tab,
["Namesuffix,Title", "City"],
["Adasuffix,Analyst", "London"],
),
(
"\"Name\"suffix;Title,City",
"\"Ada\"suffix;Analyst,London,ignored",
Delimiter::Comma,
["Namesuffix;Title", "City"],
["Adasuffix;Analyst", "London"],
),
("\"Name\"suffix;City", "\"Ada\"suffix;London;ignored", Delimiter::Semicolon, ["Namesuffix", "City"], ["Adasuffix", "London"]),
] {
let body = format!("{header}{ending}{row}{ending}");
let automatic = parse::table_from_bytes(body.as_bytes(), "people.txt", None).unwrap();
let explicit = parse::table_from_bytes(body.as_bytes(), "people.txt", Some(delimiter)).unwrap();
let fields: Vec<_> = names.into_iter().map(|name| DataField { name: name.into(), kind: DataFieldKind::Text }).collect();
assert_eq!(automatic.delimiter, delimiter, "{body:?}");
assert_eq!(automatic.fields, fields, "{body:?}");
assert_eq!(automatic.rows, vec![cells.to_vec()], "{body:?}");
assert_eq!(automatic.warnings, vec!["Row 2 has cells past the header. Those cells were ignored."], "{body:?}");
assert_eq!(automatic, explicit, "{body:?}");
}
}
}
#[test]
fn data_source_sniff_allows_post_quote_whitespace() {
for ending in ["\n", "\r\n", "\r"] {
for space in [" ", "\u{00A0}", "\u{2003}"] {
for (header, separator, delimiter, names) in [
(format!("\"Name\"{space};\"Last, First\""), ';', Delimiter::Semicolon, ["Name", "Last, First"]),
(format!("\"Name\"{space},\"Region\tCity\"{space}"), ',', Delimiter::Comma, ["Name", "Region\tCity"]),
(format!("\"Name\"{space}\t\"Last, First; City\"{space}"), '\t', Delimiter::Tab, ["Name", "Last, First; City"]),
] {
let body = format!(
"{header}{ending}\"Ada\"{space}{separator}\"London\"{space}{ending}\"Grace\"{space}{separator}\"Paris\"{space}{separator}ignored{ending}"
);
let automatic = parse::table_from_bytes(body.as_bytes(), "people.txt", None).unwrap();
let explicit = parse::table_from_bytes(body.as_bytes(), "people.txt", Some(delimiter)).unwrap();
let fields: Vec<_> = names.into_iter().map(|name| DataField { name: name.into(), kind: DataFieldKind::Text }).collect();
assert_eq!(automatic.delimiter, delimiter, "{body:?}");
assert_eq!(automatic.fields, fields, "{body:?}");
// Header classification trims; record values must keep their whitespace.
assert_eq!(
automatic.rows,
vec![vec![format!("Ada{space}"), format!("London{space}")], vec![format!("Grace{space}"), format!("Paris{space}")]],
"{body:?}"
);
assert_eq!(automatic.warnings, vec!["Row 3 has cells past the header. Those cells were ignored."], "{body:?}");
assert_eq!(automatic, explicit, "{body:?}");
}
}
}
}
#[test]
fn data_source_sniff_post_quote_whitespace_keeps_closed_field_state() {
for ending in ["\n", "\r\n", "\r"] {
for space in [" ", "\u{00A0}", "\u{2003}"] {
for suffix in ["suffix", "\"suffix\""] {
let body = format!("\"Name\"{space}{suffix};\"Last, First\"{ending}Ada,London,ignored{ending}");
// Both separated formats have incompatible post-quote suffixes. Keep
// the permissive Comma fallback, including literal quotes in the name.
let automatic = parse::table_from_bytes(body.as_bytes(), "people.txt", None).unwrap();
let explicit = parse::table_from_bytes(body.as_bytes(), "people.txt", Some(Delimiter::Comma)).unwrap();
assert_eq!(automatic.delimiter, Delimiter::Comma, "{body:?}");
assert_eq!(
automatic.fields,
vec![
DataField { name: format!("Name{space}{suffix};\"Last"), kind: DataFieldKind::Text },
DataField { name: "First\"".into(), kind: DataFieldKind::Text },
],
"{body:?}"
);
assert_eq!(automatic.rows, vec![vec!["Ada", "London"]], "{body:?}");
assert_eq!(automatic.warnings, vec!["Row 2 has cells past the header. Those cells were ignored."], "{body:?}");
assert_eq!(automatic, explicit, "{body:?}");
}
}
}
}
#[test]
fn data_source_literal_quote_header_merges_the_detected_column() {
let mut s = Session::new();
new_doc(&mut s, 1);
text_frame(&mut s, [36.0, 36.0, 240.0, 80.0], "", None);
let body = "Name,\"preferred\tCity\"\nAda\tLondon\nGrace\tParis\n";
let selected = exec(&mut s, "data.source.select", json!({"bytes": crate::cmd::base64_encode(body.as_bytes()), "name": "people.txt"}));
assert_eq!(selected["records"], 2);
let source = &s.documents()[0].doc.data_merge.sources[0];
assert_eq!(source.delimiter, Delimiter::Tab);
assert_eq!(source.rows, vec![vec!["Ada", "London"], vec!["Grace", "Paris"]]);
exec(&mut s, "data.placeholder.add", json!({"field": "City\""}));
let merged = exec(&mut s, "data.merge", json!({}));
assert_eq!(merged["records"], 2);
assert_eq!(merged["pages"], 2);
assert_eq!(texts(&s), vec!["London", "Paris"]);
}
#[test]
fn data_source_preserves_quoted_newlines_and_counts_record_lines() {
for ending in ["\n", "\r\n", "\r"] {
let body = format!("Name,City{ending}\"Ada{ending}Lovelace\",London{ending}Grace,Paris,extra{ending}");
let mut s = Session::new();
new_doc(&mut s, 1);
let result = select_csv(&mut s, &body);
assert_eq!(result["records"], 2, "{ending:?}");
let source = &s.documents()[0].doc.data_merge.sources[0];
assert_eq!(source.rows, vec![vec![format!("Ada{ending}Lovelace"), "London".into()], vec!["Grace".into(), "Paris".into()]]);
assert_eq!(source.warnings.len(), 1);
assert!(source.warnings[0].contains("Row 4"), "{:?}", source.warnings);
}
}
#[test]
fn data_source_quoted_header_and_cr_records_merge_independently() {
let mut s = Session::new();
new_doc(&mut s, 1);
text_frame(&mut s, [36.0, 36.0, 240.0, 80.0], "", None);
let body = "\u{FEFF}\"Last, First\";City\rAda;London\rGrace;Paris\r";
exec(&mut s, "data.source.select", json!({"bytes": crate::cmd::base64_encode(body.as_bytes()), "name": "people.txt"}));
exec(&mut s, "data.placeholder.add", json!({"field": "Last, First"}));
let template = s.documents()[0].doc.clone();
let result = exec(&mut s, "data.merge", json!({}));
assert_eq!(result["records"], 2);
assert_eq!(result["pages"], 2);
assert_eq!(texts(&s), vec!["Ada", "Grace"]);
assert_eq!(serde_json::to_value(&s.documents()[0].doc).unwrap(), serde_json::to_value(&template).unwrap());
}
#[test]
fn data_source_discarded_quoted_fields_keep_records_and_warning_lines() {
for ending in ["\n", "\r\n", "\r"] {
for (discarded, following_line) in [(format!("\"Address \"\"line 1\"\"{ending}Address line 2\""), 4), ("literal\"quote".into(), 3)] {
let mut s = Session::new();
new_doc(&mut s, 1);
text_frame(&mut s, [36.0, 36.0, 240.0, 80.0], "", None);
let body = format!("Name{ending}Ada,,,{discarded}{ending}Grace,extra{ending}");
let selected = select_csv(&mut s, &body);
assert_eq!(selected["records"], 2, "{body:?}");
let source = &s.documents()[0].doc.data_merge.sources[0];
assert_eq!(source.rows, vec![vec!["Ada"], vec!["Grace"]], "{body:?}");
assert_eq!(source.warnings.len(), 2, "{:?}", source.warnings);
assert!(source.warnings[0].contains("Row 2"), "{:?}", source.warnings);
assert!(source.warnings[1].contains(&format!("Row {following_line}")), "{:?}", source.warnings);
exec(&mut s, "data.placeholder.add", json!({"field": "Name"}));
let merged = exec(&mut s, "data.merge", json!({}));
assert_eq!(merged["records"], 2);
assert_eq!(merged["pages"], 2);
assert_eq!(texts(&s), vec!["Ada", "Grace"]);
}
}
}
#[test]
fn data_source_delimiter_override_and_known_extensions_win() {
let body = "Name;City\nAda;London\n";
let csv = parse::table_from_bytes(body.as_bytes(), "people.csv", None).unwrap();
assert_eq!(csv.delimiter, Delimiter::Comma);
assert_eq!(csv.rows, vec![vec!["Ada;London"]]);
let explicit = parse::table_from_bytes(body.as_bytes(), "people.csv", Some(Delimiter::Semicolon)).unwrap();
assert_eq!(explicit.rows, vec![vec!["Ada", "London"]]);
let tab = parse::table_from_bytes(b"Name,Title\tCity\nAda,Analyst\tLondon", "people.tsv", None).unwrap();
assert_eq!(tab.delimiter, Delimiter::Tab);
assert_eq!(tab.rows, vec![vec!["Ada,Analyst", "London"]]);
}
#[test]
fn text_sniff_padding_skips_and_warnings() {
let dir = scratch("sniff");

View File

@ -20,13 +20,6 @@ fn has_clip(s: &Session) -> std::result::Result<(), String> {
if s.clipboard.is_some() || text { Ok(()) } else { Err("clipboard is empty".into()) }
}
fn has_clip_or_text(s: &Session) -> std::result::Result<(), String> {
if s.active().is_some_and(|d| d.selection.text.is_some()) {
return Ok(());
}
has_clip(s)
}
fn has_selection_or_text(s: &Session) -> std::result::Result<(), String> {
if s.active().and_then(|d| d.selection.text).is_some_and(|t| !t.is_caret()) {
return Ok(());
@ -88,7 +81,73 @@ fn paste_text(s: &mut Session, text: Option<String>, formatted: bool) -> Result<
});
}
let text = text.or_else(|| clip.map(|(_, p)| p)).ok_or_else(|| super::bad("edit.paste", "clipboard is empty"))?;
s.execute("text.insert", &json!({"text": text.replace("\r\n", "\n").replace('\r', "\n"), "raw": true}))
let text = outside_text(&text)?;
s.execute("text.insert", &json!({"text": text, "raw": true}))
}
/// Text from the system clipboard as story text: Unix line ends, and none of the private-use
/// characters (U+E000–U+E1FF) that stories use as markers for breaks, footnotes, tables and
/// variables, since a stray one would be read as a marker without its payload.
fn outside_text(text: &str) -> Result<String> {
let text: String = text.replace("\r\n", "\n").replace('\r', "\n").chars().filter(|c| !('\u{E000}'..='\u{E1FF}').contains(c)).collect();
if text.is_empty() { Err(super::bad("edit.paste", "the clipboard holds no text")) } else { Ok(text) }
}
/// Pasted text with no insertion point: a new text frame holding it, as wide as the first column
/// of the page's margin area and as tall as the text (at most the margin area's height), centred
/// on `at` (spread coordinates) or else on the margin area. The frame ends up selected. With
/// `slice` (the text copied here), the frame's story is that formatted copy.
fn paste_text_frame(s: &mut Session, p: &Value, text: &str, slice: Option<&designcraft_doc::Story>) -> Result<Value> {
use designcraft_geom::{Point, Rect, shapes};
const LIMIT: f64 = 1.0e5;
let sr = super::spread_param(p, "spread")?;
let at = super::point_param(p, "at")
.filter(|a| a.x.is_finite() && a.y.is_finite())
.map(|a| Point::new(a.x.clamp(-LIMIT, LIMIT), a.y.clamp(-LIMIT, LIMIT)));
let lid = s.doc()?.active_layer;
let cache = s.cache.clone();
let text = if slice.is_some() { String::new() } else { outside_text(text)? };
s.edit(|d, sel| {
let sp = d.spread(sr).ok_or_else(|| super::bad("edit.paste", "no such spread"))?;
let page = at.and_then(|a| sp.page_at_x(a.x)).and_then(|i| sp.pages.get(i)).or_else(|| sp.pages.first());
let page = page.ok_or_else(|| super::bad("edit.paste", "the spread has no pages"))?;
let margins = page.margin_rect();
let column = page.column_rects().first().copied().unwrap_or(margins);
let w = column.width().max(12.0);
let max_h = margins.height().max(12.0);
let c = at.unwrap_or(Point::new(column.center().x, margins.center().y));
let rect = |h: f64| Rect::new(c.x - w / 2.0, c.y - h / 2.0, c.x + w / 2.0, c.y + h / 2.0);
let para = match slice.and_then(|sl| sl.paras.first()) {
Some(pf) => designcraft_doc::ParaFormat { table: None, ..pf.clone() },
None => designcraft_doc::ParaFormat { style: d.styles.default_paragraph.clone(), ..Default::default() },
};
let (id, sid) = d.add_text_frame(sr, rect(max_h), lid, &text, para)?;
if let Some(sl) = slice {
d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?.insert_story(0, sl);
}
// Shrink to the composed text unless it already fills the frame.
let composed = cache.get(d, sid, None);
if !composed.is_overset()
&& let Some(ft) = composed.frame(id)
{
let inset = d.item(id).and_then(|i| i.text_frame()).map_or(0.0, |tf| tf.options.inset[0] + tf.options.inset[2]);
let h = (ft.content_height + inset + 0.5).clamp(12.0, max_h);
if h.is_finite()
&& h < max_h
&& let Some(it) = d.item_mut(id)
{
it.path = shapes::rectangle(rect(h));
// Keep the full height if the last line no longer fits.
if cache.get(d, sid, None).is_overset()
&& let Some(it) = d.item_mut(id)
{
it.path = shapes::rectangle(rect(max_h));
}
}
}
*sel = Selection::items(vec![id]);
Ok(json!({"id": id.0, "story": sid.0}))
})
}
fn strip_objects(st: &designcraft_doc::Story) -> designcraft_doc::Story {
@ -110,6 +169,8 @@ pub fn specs() -> Vec<CommandSpec> {
st.doc = e.doc;
st.selection = e.selection;
st.revision += 1;
// Pending typing overrides belong to the current edit state, not a restored snapshot.
s.typing_format = None;
Ok(json!({"undone": e.label}))
}),
cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, |s, _| {
@ -119,6 +180,7 @@ pub fn specs() -> Vec<CommandSpec> {
st.doc = e.doc;
st.selection = e.selection;
st.revision += 1;
s.typing_format = None;
Ok(json!({"redone": e.label}))
}),
cmd!(noundo "selection.set", "Select", [], None, "{ids: [id], add?: bool, content?: bool}", has_doc, |s, p| {
@ -227,11 +289,18 @@ pub fn specs() -> Vec<CommandSpec> {
"Paste",
["Edit"],
Some("Cmd+V"),
"{inPlace?: bool, text?: the system clipboard's text (pastes it unless it is what was copied here)}",
has_clip_or_text,
"{inPlace?: bool, text?: the system clipboard's text (pastes it unless it is what was copied here; with no insertion point it becomes a new text frame), spread?, at?: [x, y] (spread coords: the new text frame's centre)}",
// The system clipboard may hold text from another app, so Paste is always available.
has_doc,
|s, p| {
let text = super::str_param(p, "text").filter(|t| !t.is_empty());
if s.doc()?.selection.text.is_some() {
return paste_text(s, super::str_param(p, "text").map(str::to_string), true);
return paste_text(s, text.map(str::to_string), true);
}
if let Some(text) = text {
// What our own text copy put on the clipboard keeps its formatting.
let slice = s.text_clipboard.clone().filter(|(_, plain)| text.replace("\r\n", "\n") == *plain).map(|(slice, _)| slice);
return paste_text_frame(s, p, text, slice.as_deref());
}
let Some(clip) = s.clipboard.clone() else {
return Err(super::bad("edit.paste", "clipboard is empty"));
@ -316,6 +385,87 @@ mod text_clipboard_tests {
s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story
}
#[test]
fn paste_text_with_no_insertion_point_makes_a_text_frame() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let items = |s: &Session| s.doc().unwrap().doc.spreads[0].items.len();
// Text from another app, nothing copied here: a new, selected text frame.
let r = s.execute("edit.paste", &json!({"text": "From elsewhere\r\nSecond line"})).unwrap();
let id = ItemId(r["id"].as_u64().unwrap());
let d = s.doc().unwrap();
assert_eq!(d.selection.items, vec![id]);
assert!(d.selection.text.is_none(), "selected as an object");
let it = d.doc.item(id).unwrap();
assert_eq!(d.doc.story(it.text_frame().unwrap().story).unwrap().text, "From elsewhere\nSecond line");
let page = &d.doc.spreads[0].pages[0];
let (b, m) = (it.bounds(), page.margin_rect());
assert!((b.width() - page.column_rects()[0].width()).abs() < 1e-6, "{b:?}");
assert!(b.height() < m.height() / 2.0, "fits two lines: {b:?}");
assert!((b.center().y - m.center().y).abs() < 1e-6, "{b:?}");
assert!(!s.cache.get(&d.doc, it.text_frame().unwrap().story, None).is_overset());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(items(&s), 0, "one undo removes it");
// Centred on `at`; hostile numbers are clamped.
let r = s.execute("edit.paste", &json!({"text": "Here", "at": [200.0, 300.0]})).unwrap();
let b = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().bounds();
assert!((b.center().x - 200.0).abs() < 1e-6 && (b.center().y - 300.0).abs() < 1e-6, "{b:?}");
assert!(s.execute("edit.paste", &json!({"text": "Far", "at": [1e300, -1e300], "spread": 99})).is_err());
s.execute("edit.paste", &json!({"text": "Far", "at": [1e300, -1e300]})).unwrap();
// Copied objects paste when no text is given.
s.execute("edit.deselectAll", &json!({})).unwrap();
let rect = s.execute("frame.create", &json!({"rect": [72, 72, 200, 160]})).unwrap()["id"].clone();
s.execute("selection.set", &json!({"ids": [rect]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
let n = items(&s);
let r = s.execute("edit.paste", &json!({})).unwrap();
assert_eq!(r["ids"].as_array().unwrap().len(), 1);
assert_eq!(items(&s), n + 1);
assert!(s.doc().unwrap().doc.item(ItemId(r["ids"][0].as_u64().unwrap())).unwrap().text_frame().is_none());
s.doc().unwrap().doc.check().unwrap();
}
#[test]
fn outside_text_never_pastes_markers() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
// Private-use characters from another app would read as footnote, table or variable markers.
let dirty = "a\u{E00A}b\u{E009}c\u{E1FF}d\u{E200}";
let r = s.execute("edit.paste", &json!({"text": dirty})).unwrap();
let st = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story;
assert_eq!(s.doc().unwrap().doc.story(st).unwrap().text, "abcd\u{E200}");
s.doc().unwrap().doc.check().unwrap();
let b = frame(&mut s, "Target");
s.execute("text.select", &json!({"story": b.0, "anchor": 6, "focus": 6})).unwrap();
s.execute("edit.paste", &json!({"text": dirty})).unwrap();
s.execute("edit.pasteWithoutFormatting", &json!({"text": dirty})).unwrap();
assert_eq!(s.doc().unwrap().doc.story(b).unwrap().text, "Targetabcd\u{E200}abcd\u{E200}");
assert!(s.execute("edit.paste", &json!({"text": "\u{E00A}"})).is_err());
s.doc().unwrap().doc.check().unwrap();
}
#[test]
fn copied_text_pasted_with_no_insertion_point_keeps_its_formatting() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = frame(&mut s, "Bold words\nSecond");
s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 4})).unwrap();
s.execute("type.bold", &json!({})).unwrap();
s.execute("text.select", &json!({"story": a.0, "anchor": 11, "focus": 17})).unwrap();
s.execute("type.para", &json!({"attrs": {"align": "center"}})).unwrap();
s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 17})).unwrap();
let copied = s.execute("edit.copy", &json!({})).unwrap()["text"].as_str().unwrap().to_string();
s.execute("edit.deselectAll", &json!({})).unwrap();
let r = s.execute("edit.paste", &json!({"text": copied})).unwrap();
let d = s.doc().unwrap();
let st = d.doc.story(d.doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story).unwrap();
assert_eq!(st.text, "Bold words\nSecond");
assert_eq!(st.char_format_at(2).over.font_style.as_deref(), Some("Bold"));
assert_eq!(st.char_format_at(7).over.font_style, None);
assert_eq!(st.paras[1], d.doc.story(a).unwrap().paras[1]);
d.doc.check().unwrap();
}
#[test]
fn formatted_copy_paste_and_plain_paste() {
let mut s = Session::new();

View File

@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
if ts.cell.is_some() {
return Err(bad("note.new", "notes go in story text"));
}
let text = str_param(p, "text").unwrap_or("").to_string();
let text = super::text_param(p, "text");
let author = str_param(p, "author").unwrap_or("").to_string();
let at = ts.anchor.max(ts.focus);
s.edit(|d, sel| {
@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
}),
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(str::to_string), str_param(p, "author").map(str::to_string));
let (text, author) = (str_param(p, "text").map(|_| super::text_param(p, "text")), str_param(p, "author").map(str::to_string));
s.edit(|d, _| {
let st = d.story_mut(sid).ok_or_else(|| bad("note.edit", "no such story"))?;
let n = st.editorial.iter_mut().find(|n| n.id == id).ok_or_else(|| bad("note.edit", format!("no note {id}")))?;

View File

@ -4,7 +4,7 @@
use designcraft_doc::{ParaFormat, SpreadRef, StoryId};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, has_text, ok, str_param};
use super::{CommandSpec, bad, cmd, has_doc, has_text, ok};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
@ -20,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!("endnote.edit", "Edit Endnote", [], None, "{story, id, text}", has_doc, |s, p| {
let (sid, id) = target(p)?;
let text = str_param(p, "text").unwrap_or("").to_string();
let text = super::text_param(p, "text");
s.edit(|d, _| {
let style = d.endnote_options.para_style.clone();
let st = d.story_mut(sid).ok_or_else(|| bad("endnote.edit", "no such story"))?;
@ -103,7 +103,7 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
if st.doc.endnote_story == Some(ts.story) {
return Err(bad(ID, "the endnote frame lists the endnotes"));
}
let text = str_param(p, "text").unwrap_or("").to_string();
let text = super::text_param(p, "text");
let at = ts.anchor.max(ts.focus);
s.edit(|d, sel| {
// The endnote frame: a text frame in the margins of a new last page.

View File

@ -1,4 +1,4 @@
//! File › Export: Adobe PDF (Print).
//! File › Export PDF (Print).
use designcraft_pdf::{Marks, PdfOptions, Standard};
use serde_json::{Value, json};
@ -44,7 +44,7 @@ fn print_booklet(s: &mut Session, p: &Value) -> Result<Value> {
match str_param(p, "path") {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
std::fs::write(path, &r.bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
crate::atomic::write_file(path, &r.bytes)?;
Ok(json!({"path": path, "bytes": r.bytes.len(), "sheets": r.pages, "warnings": r.warnings}))
}
None => Ok(json!({"base64": super::file::base64_encode(&r.bytes), "bytes": r.bytes.len(), "sheets": r.pages, "warnings": r.warnings})),
@ -59,7 +59,7 @@ fn export_html(s: &mut Session, p: &Value) -> Result<Value> {
match str_param(p, "path") {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
std::fs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
crate::atomic::write_file(path, text.as_bytes())?;
Ok(json!({"path": path, "bytes": text.len()}))
}
None => Ok(json!({"bytes": text.len(), "text": text})),
@ -91,7 +91,7 @@ fn export_text(s: &mut Session, p: &Value) -> Result<Value> {
match path {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
std::fs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
crate::atomic::write_file(path, text.as_bytes())?;
Ok(json!({"path": path, "bytes": text.len()}))
}
None => Ok(json!({"bytes": text.len(), "text": text})),
@ -160,7 +160,7 @@ fn export_epub(s: &mut Session, p: &Value) -> Result<Value> {
match p.get("path").and_then(Value::as_str) {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
std::fs::write(path, &bytes).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?;
crate::atomic::write_file(path, &bytes)?;
Ok(serde_json::json!({"path": path, "bytes": bytes.len()}))
}
None => Ok(serde_json::json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len()})),
@ -598,7 +598,7 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
#[cfg(not(target_arch = "wasm32"))]
fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
std::fs::write(path, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))
crate::atomic::write_file(path, bytes)
}
#[cfg(target_arch = "wasm32")]
fn write_file(path: &str, _bytes: &[u8]) -> Result<()> {

View File

@ -18,16 +18,18 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "file.newSample", "Sample Document", ["Help"], None, "{} — a multi-page magazine sample", always, file_sample),
cmd!(query "file.presets", "Document Presets", [], None, "{}", always, |_, _| Ok(serde_json::to_value(PRESETS).unwrap_or_default())),
cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"),
"{path} — .designcraft or .idml; the fonts in a `Document Fonts` folder beside it load first → {index, documentFonts: faces loaded, warnings: font files skipped}",
"{path} — .designcraft or .idml; the fonts in a `Document Fonts` folder beside it load first → {index, documentFonts: faces loaded, warnings: font files skipped (and, for .idml, package parts missing)}",
always, file_open),
cmd!(noundo "file.openBytes", "Open Bytes", [], None, "{name, base64} — DesignCraft JSON or an IDML package", always, file_open_bytes),
cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?}", has_doc, file_save),
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path}", has_doc, file_save),
cmd!(noundo "file.saveACopy", "Save a Copy…", ["File"], None, "{path} — writes the document without changing which file it is or its unsaved state", has_doc, |s, p| {
let path = str_param(p, "path").ok_or_else(|| bad("file.saveACopy", "missing `path`"))?.to_string();
let bytes = to_bytes(&s.doc()?.doc);
let mut copy = (*s.doc()?.doc).clone();
super::datamerge::refresh_relative_paths(&mut copy, std::path::Path::new(&path));
let bytes = to_bytes(&copy);
#[cfg(not(target_arch = "wasm32"))]
std::fs::write(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
crate::atomic::write_file(&path, &bytes)?;
Ok(json!({"path": path, "bytes": bytes.len()}))
}),
cmd!(noundo "file.revert", "Revert", ["File"], None, "{} — back to the last saved version (not undoable)", can_revert, file_revert),
@ -37,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
}),
cmd!(noundo "file.recovery.save", "Save Recovery Data", [], None, "{dir?} — write every unsaved document to the recovery folder (the app does this on a timer)", always, |s, p| {
let dir = recovery_dir(s, p)?;
Ok(json!({"saved": crate::recovery::save(s, &dir)?}))
Ok(json!({"saved": crate::recovery::save_tracked(s, &dir)?}))
}),
cmd!(noundo "file.recovery.open", "Recover Documents", [], None, "{dir?} — reopen documents left in the recovery folder (unsaved)", always, |s, p| {
let dir = recovery_dir(s, p)?;
@ -237,24 +239,26 @@ fn file_save(s: &mut Session, p: &Value) -> Result<Value> {
.map(str::to_string)
.or_else(|| st.path.clone())
.ok_or_else(|| bad("file.save", "missing `path` (document has never been saved)"))?;
{
let mut d = (*st.doc).clone();
super::datamerge::refresh_relative_paths(&mut d, std::path::Path::new(&path));
if d.data_merge != st.doc.data_merge {
st.doc = Arc::new(d);
st.revision = st.revision.saturating_add(1);
}
}
let bytes = to_bytes(&st.doc);
// Prepare paths for the destination without changing the live document until persistence succeeds.
let mut saved = (*st.doc).clone();
super::datamerge::refresh_relative_paths(&mut saved, std::path::Path::new(&path));
let paths_changed = saved.data_merge != st.doc.data_merge;
let saved = if paths_changed { Arc::new(saved) } else { st.doc.clone() };
let bytes = to_bytes(&saved);
#[cfg(not(target_arch = "wasm32"))]
std::fs::write(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
crate::atomic::write_file(&path, &bytes)?;
if paths_changed {
st.doc = saved;
st.revision = st.revision.saturating_add(1);
}
st.path = Some(path.clone());
st.saved_revision = st.revision;
st.saved_doc = st.doc.clone();
#[cfg(not(target_arch = "wasm32"))]
if let Some(dir) = &s.recovery_dir {
let uid = s.doc()?.uid;
crate::recovery::discard(dir, uid);
{
let recovery_dir = s.recovery_dir.clone();
let index = s.active_index().ok_or(EngineError::NoDocument)?;
crate::recovery::discard_document(s, index, recovery_dir.as_deref());
}
Ok(json!({"path": path, "bytes": bytes.len()}))
}
@ -313,8 +317,9 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
.map(|(w, h)| (w.round().max(1.0) as u32, h.round().max(1.0) as u32))
.ok_or_else(|| bad("file.place", "can't read that PDF page"))?,
};
// 72 ppi by default unless the file says otherwise; scale so it fits the page when huge.
let (nw, nh) = eps_size.unwrap_or((pw as f64, ph as f64));
// 72 ppi by default unless the file says otherwise; scale so it fits the page when huge. PDF pages
// and SVGs keep their exact size: rounding it to whole points would stretch them unevenly.
let (nw, nh) = eps_size.or_else(|| designcraft_render::natural_size(&bytes, pdf_page as usize)).unwrap_or((pw as f64, ph as f64));
// Image Import Options › Crop to: the frame shows that box of the PDF page.
let crop_box = match str_param(p, "pdfCrop") {
Some(k) if designcraft_render::is_pdf(&bytes) => {
@ -330,15 +335,15 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.active()?;
st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| matches!(it.content, Content::Unassigned | Content::Graphic(_))))
});
let spread = super::spread_param(p, "spread");
let spread = super::spread_param(p, "file.place")?;
let lid = s.doc()?.active_layer;
let px = p.get("x").and_then(Value::as_f64);
let py = p.get("y").and_then(Value::as_f64);
let want_w = p.get("width").and_then(Value::as_f64);
s.edit(|d, sel| {
let aid = AssetId(d.alloc());
let fresh = AssetId(d.alloc());
let mime = designcraft_render::image_mime(&bytes).to_string();
d.assets.insert(aid, Arc::new(Asset { page: pdf_page, id: aid, name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)) }));
let aid = d.add_asset(Asset { page: pdf_page, id: fresh, name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)) });
// The width the whole page (or image) gets; the height follows the proportions.
let w = match want_w {
Some(w) => w,
@ -350,14 +355,14 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
let id = if let Some(fid) = target_frame {
let it = d.item_mut(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?;
let r = it.inner_bounds();
// Fill frame proportionally.
// Fill the frame proportionally once. Auto-Fit stays off, so resizing the frame crops.
let k = (r.width() / nw).max(r.height() / nh);
let (gw, gh) = (nw * k, nh * k);
it.content = Content::Graphic(Graphic {
asset: aid,
size: (nw, nh),
xf: Affine::translate((r.x0 + (r.width() - gw) / 2.0, r.y0 + (r.height() - gh) / 2.0)) * Affine::scale(k),
auto_fit: designcraft_doc::Fitting::FillProportionally,
auto_fit: Default::default(),
fit_align: 4,
crop: [0.0; 4],
});
@ -440,23 +445,20 @@ pub(crate) fn read_source(p: &Value) -> Result<(Vec<u8>, String, Option<String>)
fn place_load(s: &mut Session, p: &Value) -> Result<Value> {
let (bytes, name, link) = read_source(p)?;
let (pw, ph) = designcraft_render::image_size(&bytes).ok_or_else(|| bad("place.load", "unsupported or corrupt image"))?;
let natural = designcraft_render::natural_size(&bytes, 0).unwrap_or((pw as f64, ph as f64));
let aid = s.edit(|d, _| {
let aid = AssetId(d.alloc());
let fresh = AssetId(d.alloc());
let mime = designcraft_render::image_mime(&bytes).to_string();
d.assets.insert(
aid,
Arc::new(Asset { page: 0, id: aid, name: name.clone(), mime, link: link.clone(), data: Arc::new(bytes), pixels: Some((pw, ph)) }),
);
Ok(aid)
Ok(d.add_asset(Asset { page: 0, id: fresh, name: name.clone(), mime, link: link.clone(), data: Arc::new(bytes), pixels: Some((pw, ph)) }))
})?;
s.loaded = Some((aid, (pw as f64, ph as f64)));
s.loaded = Some((aid, natural));
s.set_tool("placeGun");
Ok(json!({"asset": aid.0, "name": name, "width": pw, "height": ph}))
}
fn place_drop(s: &mut Session, p: &Value) -> Result<Value> {
let (aid, (nw, nh)) = s.loaded.ok_or_else(|| bad("place.drop", "nothing loaded in the place cursor"))?;
let sr = super::spread_param(p, "spread");
let sr = super::spread_param(p, "place.drop")?;
let lid = s.doc()?.active_layer;
let frame = super::id_param(p, "frame");
let rect = super::rect_param(p, "rect");
@ -471,7 +473,7 @@ fn place_drop(s: &mut Session, p: &Value) -> Result<Value> {
asset: aid,
size: (nw, nh),
xf: Affine::translate((r.x0 + (r.width() - nw * k) / 2.0, r.y0 + (r.height() - nh * k) / 2.0)) * Affine::scale(k),
auto_fit: designcraft_doc::Fitting::FillProportionally,
auto_fit: Default::default(),
fit_align: 4,
crop: [0.0; 4],
});
@ -520,7 +522,7 @@ fn snippet_export(s: &mut Session, p: &Value) -> Result<Value> {
match str_param(p, "path") {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
crate::atomic::write_file(path, &bytes)?;
Ok(json!({"path": path, "bytes": bytes.len(), "items": d.spreads.first().map(|s| s.items.len()).unwrap_or(0)}))
}
None => Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})),
@ -548,7 +550,7 @@ fn place_layout_page(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) -> Re
if ids.is_empty() {
return Err(bad(ID, format!("page {page} has no objects")));
}
let to = super::spread_param(p, "spread");
let to = super::spread_param(p, ID)?;
let off = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
(Some(x), Some(y)) => designcraft_geom::Vec2::new(x - pr.x0, y - pr.y0),
_ => designcraft_geom::Vec2::new(-pr.x0, -pr.y0),
@ -584,7 +586,7 @@ pub(crate) fn snippet_place(s: &mut Session, p: &Value) -> Result<Value> {
if ids.is_empty() {
return Err(bad("snippet.place", "the snippet is empty"));
}
let sr = super::spread_param(p, "spread");
let sr = super::spread_param(p, "snippet.place")?;
let bounds = ids.iter().filter_map(|i| src.item(*i).map(|it| it.bounds())).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO);
let off = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
(Some(x), Some(y)) => designcraft_geom::Vec2::new(x - bounds.x0, y - bounds.y0),
@ -645,15 +647,14 @@ fn file_revert(s: &mut Session, _: &Value) -> Result<Value> {
// As opened again: the fonts beside it now (unchanged files are shared, not read again).
let (fonts, _, _) = load_document_fonts(&mut d, &path);
let uid = s.doc()?.uid;
let st = s.doc_mut()?;
let recovery_dir = s.recovery_dir.clone();
let index = s.active_index().ok_or(EngineError::NoDocument)?;
let mut fresh = DocState::new(d, Some(path.clone()));
fresh.fonts = fonts;
fresh.uid = uid;
fresh.revision = st.revision + 1;
*st = fresh;
if let Some(dir) = &s.recovery_dir {
crate::recovery::discard(dir, uid);
}
fresh.revision = s.doc()?.revision + 1;
crate::recovery::discard_document(s, index, recovery_dir.as_deref());
*s.doc_mut()? = fresh;
Ok(json!({"path": path}))
}
#[cfg(target_arch = "wasm32")]
@ -689,6 +690,24 @@ mod pdf_crop_tests {
assert!((trim.width() - 612.0).abs() < 1e-3 && (trim.height() - 792.0).abs() < 1e-3, "{trim:?}");
assert!(t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "pdfCrop": "nope"})).is_err());
}
#[test]
fn placed_pdf_keeps_its_exact_page_size() {
// A 100.4 x 50.4 pt page placed 100.4 pt wide is 50.4 pt tall, not stretched to whole points.
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100.4, "height": 50.4, "pages": 1})).unwrap();
let b64 = s.execute("file.exportPdf", &json!({})).unwrap()["base64"].as_str().unwrap().to_string();
let mut t = Session::new();
t.execute("file.new", &json!({})).unwrap();
let r = t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "width": 100.4, "x": 0, "y": 0})).unwrap();
let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap());
let d = &t.doc().unwrap().doc;
let it = d.item(id).unwrap();
let b = it.bounds();
assert!((b.width() - 100.4).abs() < 1e-3 && (b.height() - 50.4).abs() < 1e-3, "{b:?}");
let designcraft_doc::Content::Graphic(g) = &it.content else { panic!("not a graphic") };
assert!((g.size.0 - 100.4).abs() < 1e-3 && (g.size.1 - 50.4).abs() < 1e-3, "{:?}", g.size);
}
}
#[cfg(test)]
@ -719,6 +738,37 @@ mod layout_place_tests {
}
}
#[cfg(test)]
mod save_tests {
use serde_json::json;
use crate::Session;
/// Saving over an existing document replaces it whole and leaves nothing beside it.
#[test]
fn save_replaces_the_existing_file_without_leftovers() {
let dir = std::env::temp_dir().join(format!("dc-save-atomic-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("doc.designcraft");
std::fs::write(&path, vec![b'x'; 1 << 20]).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Saved whole", "caret": false})).unwrap();
s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap();
s.execute("file.saveACopy", &json!({"path": path.to_string_lossy()})).unwrap();
let saved = super::from_bytes(&std::fs::read(&path).unwrap()).unwrap();
assert!(saved.stories.values().any(|st| st.text == "Saved whole"));
let names: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name()).collect();
assert_eq!(names, ["doc.designcraft"], "no temporary file is left");
// A folder that doesn't exist: an error naming the path.
let missing = dir.join("missing").join("doc.designcraft");
let e = s.execute("file.save", &json!({"path": missing.to_string_lossy()})).unwrap_err();
assert!(e.to_string().contains("missing"), "{e}");
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
mod document_fonts_tests {
use std::path::{Path, PathBuf};
@ -939,4 +989,24 @@ mod document_fonts_tests {
assert!(glyph_faces(&s).iter().all(|(_, f)| f.family == FAMILY));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn bold_and_italic_link_the_document_fonts_styles() {
const FAMILY: &str = "DocFont Linked";
let dir = temp_dir("linked");
std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("linked.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap();
let path = dir.join("Linked.designcraft");
write_document(&path, FAMILY);
let mut s = Session::new();
open(&mut s, &path);
let sid = *s.doc().unwrap().doc.stories.keys().next().unwrap();
s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap();
// The family is the document's alone: its Regular is the style without bold or italic.
let r = s.execute("type.bold", &json!({"on": false})).unwrap();
assert_eq!(r["fontStyle"], "Regular", "{r}");
let r = s.execute("type.italic", &json!({"on": false})).unwrap();
assert_eq!(r["fontStyle"], "Regular", "{r}");
let _ = std::fs::remove_dir_all(&dir);
}
}

View File

@ -181,6 +181,7 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> {
over.set_json(k, v).map_err(|e| bad("find.change", e))?;
}
}
super::text::validate_char_fill(&s.doc()?.doc, &over, "find.change")?;
let targets = scope(s, p)?;
s.edit(|d, sel| {
let mut count = 0usize;

View File

@ -7,9 +7,13 @@ use designcraft_doc::{Document, Guide, Orientation, SpreadRef};
use designcraft_geom::Rect;
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, spread_param, str_param};
use super::{CommandSpec, bad, cmd, has_doc, str_param};
use crate::{Result, Session};
const MAX_GUIDE_DIVISIONS: u64 = 1000;
const MAX_GUIDES_CREATED: usize = 10_000;
const MAX_GUIDE_GUTTER: f64 = 1_000_000.0;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@ -24,7 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("guide.move", "Move Guide", [], None, "{spread?, page, index, position}", has_doc, move_guide),
cmd!("guide.delete", "Delete Guide", [], None, "{spread?, page, index}", has_doc, delete),
cmd!("guide.deleteAll", "Delete All Guides on Spread", ["View", "Grids & Guides"], None, "{spread?}", has_doc, |s, p| {
let r = spread_param(p, "spread");
let r = super::spread_param(p, "guide.deleteAll")?;
s.edit(|d, _| {
let sp = d.spread_mut(r).ok_or_else(|| bad("guide.deleteAll", "no such spread"))?;
let mut n = 0;
@ -37,7 +41,7 @@ pub fn specs() -> Vec<CommandSpec> {
}),
cmd!(query "guide.list", "Guides", [], None, "{spread?} → [{page, index, orientation, position, spread}]", has_doc, |s, p| {
let d = &s.doc()?.doc;
let sp = d.spread(spread_param(p, "spread")).ok_or_else(|| bad("guide.list", "no such spread"))?;
let sp = d.spread(super::spread_param(p, "guide.list")?).ok_or_else(|| bad("guide.list", "no such spread"))?;
let mut out = Vec::new();
for (pi, pg) in sp.pages.iter().enumerate() {
for (i, g) in pg.guides.iter().enumerate() {
@ -51,13 +55,31 @@ pub fn specs() -> Vec<CommandSpec> {
"Create Guides…",
["Layout"],
None,
"{rows?: 0, columns?: 0 (at most 100 each), rowGutter?: 12, columnGutter?: 12, fitTo?: margins|page, removeExisting?: false, spread?, page? (index in the spread; default all pages)}",
"{rows?: 0..1000, columns?: 0..1000, rowGutter?: 12, columnGutter?: 12, fitTo?: margins|page, removeExisting?: false, spread?, page? (index in the spread; default all pages)} — at most 10000 guides per command",
has_doc,
create_guides
),
]
}
fn checked_usize(value: &Value, key: &str, command: &str) -> Result<usize> {
let n = value.as_u64().ok_or_else(|| bad(command, format!("`{key}` must be a non-negative integer")))?;
usize::try_from(n).map_err(|_| bad(command, format!("`{key}` is too large for this platform")))
}
fn optional_usize(p: &Value, key: &str, command: &str) -> Result<Option<usize>> {
p.get(key).map(|value| checked_usize(value, key, command)).transpose()
}
fn finite_number(p: &Value, key: &str, command: &str) -> Result<Option<f64>> {
let Some(value) = p.get(key) else { return Ok(None) };
let number = value.as_f64().ok_or_else(|| bad(command, format!("`{key}` must be a finite number")))?;
if !number.is_finite() {
return Err(bad(command, format!("`{key}` must be a finite number")));
}
Ok(Some(number))
}
fn orientation(p: &Value) -> Result<Orientation> {
match str_param(p, "orientation") {
Some("horizontal") => Ok(Orientation::Horizontal),
@ -73,17 +95,17 @@ fn page_at(d: &Document, r: SpreadRef, x: f64) -> usize {
fn add(s: &mut Session, p: &Value) -> Result<Value> {
let o = orientation(p)?;
let pos = p.get("position").and_then(Value::as_f64).ok_or_else(|| bad("guide.add", "missing position"))?;
let r = spread_param(p, "spread");
let pos = finite_number(p, "position", "guide.add")?.ok_or_else(|| bad("guide.add", "missing position"))?;
let r = super::spread_param(p, "guide.add")?;
let spread_guide = p.get("spreadGuide").and_then(Value::as_bool).unwrap_or(false);
let d = &s.doc()?.doc;
let pi = match p.get("page").and_then(Value::as_u64) {
Some(pi) => pi as usize,
let pi = match optional_usize(p, "page", "guide.add")? {
Some(pi) => pi,
None if spread_guide => 0,
None => {
let x = match o {
Orientation::Vertical => pos,
Orientation::Horizontal => p.get("at").and_then(Value::as_f64).unwrap_or(0.0),
Orientation::Horizontal => finite_number(p, "at", "guide.add")?.unwrap_or(0.0),
};
page_at(d, r, x)
}
@ -98,9 +120,9 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
}
fn guide_ref(p: &Value, cmd: &str) -> Result<(SpreadRef, usize, usize)> {
let pi = p.get("page").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing page"))? as usize;
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing index"))? as usize;
Ok((spread_param(p, "spread"), pi, i))
let pi = optional_usize(p, "page", cmd)?.ok_or_else(|| bad(cmd, "missing page"))?;
let i = optional_usize(p, "index", cmd)?.ok_or_else(|| bad(cmd, "missing index"))?;
Ok((super::spread_param(p, cmd)?, pi, i))
}
/// Commands must use the same guide/layer protection as pointer editing.
@ -114,7 +136,7 @@ fn check_editable(d: &Document, r: SpreadRef, pi: usize, i: usize, command: &str
fn move_guide(s: &mut Session, p: &Value) -> Result<Value> {
let (r, pi, i) = guide_ref(p, "guide.move")?;
let pos = p.get("position").and_then(Value::as_f64).ok_or_else(|| bad("guide.move", "missing position"))?;
let pos = finite_number(p, "position", "guide.move")?.ok_or_else(|| bad("guide.move", "missing position"))?;
s.edit(|d, _| {
check_editable(d, r, pi, i, "guide.move")?;
let g = d
@ -140,40 +162,89 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
})
}
fn guide_divisions(p: &Value, key: &str) -> Result<u64> {
let Some(value) = p.get(key) else { return Ok(0) };
let count = value.as_u64().ok_or_else(|| bad("layout.createGuides", format!("`{key}` must be an integer")))?;
if count > MAX_GUIDE_DIVISIONS {
return Err(bad("layout.createGuides", format!("`{key}` must not exceed {MAX_GUIDE_DIVISIONS}")));
}
Ok(count)
}
fn guide_gutter(p: &Value, key: &str) -> Result<f64> {
let Some(value) = p.get(key) else { return Ok(12.0) };
let gutter = value.as_f64().ok_or_else(|| bad("layout.createGuides", format!("`{key}` must be a finite number")))?;
if !gutter.is_finite() || !(0.0..=MAX_GUIDE_GUTTER).contains(&gutter) {
return Err(bad("layout.createGuides", format!("`{key}` must be between 0 and {MAX_GUIDE_GUTTER}")));
}
Ok(gutter)
}
fn guide_count(divisions: u64, gutter: f64) -> Result<usize> {
let edges = divisions.saturating_sub(1);
let count = if gutter > 0.0 { edges.checked_mul(2) } else { Some(edges) }
.ok_or_else(|| bad("layout.createGuides", "the requested guide count is too large"))?;
usize::try_from(count).map_err(|_| bad("layout.createGuides", "the requested guide count is too large for this platform"))
}
/// Evenly spaced rows/columns with gutters inside `area`: the guide positions.
pub(crate) fn grid_positions(a: f64, b: f64, n: u32, gutter: f64) -> Vec<f64> {
fn grid_positions(a: f64, b: f64, n: u64, gutter: f64) -> Result<Vec<f64>> {
if n < 2 {
return vec![];
return Ok(vec![]);
}
if !a.is_finite() || !b.is_finite() || !gutter.is_finite() || n > MAX_GUIDE_DIVISIONS {
return Err(bad("layout.createGuides", "guide geometry must be finite and within the supported range"));
}
let cell = ((b - a) - gutter * (n - 1) as f64) / n as f64;
let mut v = Vec::new();
if !cell.is_finite() {
return Err(bad("layout.createGuides", "guide geometry is outside the supported range"));
}
let mut v = Vec::with_capacity(guide_count(n, gutter)?);
for k in 1..n {
let edge = a + k as f64 * cell + (k - 1) as f64 * gutter;
if !edge.is_finite() || (gutter > 0.0 && !(edge + gutter).is_finite()) {
return Err(bad("layout.createGuides", "guide geometry is outside the supported range"));
}
v.push(edge);
if gutter > 0.0 {
v.push(edge + gutter);
}
}
v
Ok(v)
}
/// The most rows or columns Create Guides lays out. The count comes from the caller, and every
/// guide is stored in the document.
const MAX_GUIDE_GRID: u32 = 100;
fn create_guides(s: &mut Session, p: &Value) -> Result<Value> {
let count = |key: &str| p.get(key).and_then(Value::as_u64).unwrap_or(0).min(u64::from(MAX_GUIDE_GRID)) as u32;
let (rows, cols) = (count("rows"), count("columns"));
let rg = p.get("rowGutter").and_then(Value::as_f64).unwrap_or(12.0);
let cg = p.get("columnGutter").and_then(Value::as_f64).unwrap_or(12.0);
let rows = guide_divisions(p, "rows")?;
let cols = guide_divisions(p, "columns")?;
let rg = guide_gutter(p, "rowGutter")?;
let cg = guide_gutter(p, "columnGutter")?;
let to_page = str_param(p, "fitTo") == Some("page");
let remove = p.get("removeExisting").and_then(Value::as_bool).unwrap_or(false);
let r = spread_param(p, "spread");
let only = p.get("page").and_then(Value::as_u64).map(|v| v as usize);
let r = super::spread_param(p, "layout.createGuides")?;
let only = optional_usize(p, "page", "layout.createGuides")?;
let page_count = {
let sp = s.doc()?.doc.spread(r).ok_or_else(|| bad("layout.createGuides", "no such spread"))?;
match only {
Some(page) => {
if sp.pages.get(page).is_none() {
return Err(bad("layout.createGuides", "no such page"));
}
1
}
None => sp.pages.len(),
}
};
let per_page = guide_count(rows, rg)?
.checked_add(guide_count(cols, cg)?)
.ok_or_else(|| bad("layout.createGuides", "the requested guide count is too large"))?;
per_page
.checked_mul(page_count)
.filter(|count| *count <= MAX_GUIDES_CREATED)
.ok_or_else(|| bad("layout.createGuides", format!("a single command may create at most {MAX_GUIDES_CREATED} guides")))?;
let layer = s.doc()?.active_layer;
s.edit(|d, _| {
let sp = d.spread_mut(r).ok_or_else(|| bad("layout.createGuides", "no such spread"))?;
let mut n = 0;
let mut n = 0usize;
for (pi, pg) in sp.pages.iter_mut().enumerate() {
if only.is_some_and(|o| o != pi) {
continue;
@ -182,7 +253,7 @@ fn create_guides(s: &mut Session, p: &Value) -> Result<Value> {
pg.guides.clear();
}
let area: Rect = if to_page { pg.bounds() } else { pg.margin_rect() };
for y in grid_positions(area.y0, area.y1, rows, rg) {
for y in grid_positions(area.y0, area.y1, rows, rg)? {
pg.guides.push(Guide {
orientation: Orientation::Horizontal,
position: y,
@ -193,7 +264,7 @@ fn create_guides(s: &mut Session, p: &Value) -> Result<Value> {
});
n += 1;
}
for x in grid_positions(area.x0, area.x1, cols, cg) {
for x in grid_positions(area.x0, area.x1, cols, cg)? {
pg.guides.push(Guide {
orientation: Orientation::Vertical,
position: x,
@ -243,16 +314,33 @@ mod tests {
assert_eq!(s.doc().unwrap().doc.spreads[0].pages[0].guides.len(), 5);
}
/// A huge row or column count once made that many guides (2³² − 1 of them exhaust memory, which
/// no guard catches), and a count past 2³² wrapped to a small one.
#[test]
fn create_guides_caps_the_row_and_column_counts() {
fn create_guides_validates_generation_limits() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let args = json!({"spread": 0, "rows": 4_294_967_298_u64, "columns": 3_000_000, "rowGutter": 0.0, "columnGutter": 0.0});
let r = s.execute("layout.createGuides", &args).unwrap();
let per_page = 2 * (u64::from(MAX_GUIDE_GRID) - 1);
assert_eq!(r["guides"], per_page * s.doc().unwrap().doc.spreads[0].pages.len() as u64);
assert!(s.execute("layout.createGuides", &json!({"columns": MAX_GUIDE_DIVISIONS + 1})).is_err());
assert!(s.execute("layout.createGuides", &json!({"columns": 2, "columnGutter": -1})).is_err());
assert!(s.execute("layout.createGuides", &json!({"columns": "many"})).is_err());
let r = s.execute("layout.createGuides", &json!({"columns": MAX_GUIDE_DIVISIONS, "columnGutter": 0})).unwrap();
assert_eq!(r["guides"], MAX_GUIDE_DIVISIONS - 1);
}
#[test]
fn mutating_guide_commands_reject_invalid_indices() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("guide.add", &json!({"orientation": "vertical", "position": 100})).unwrap();
assert!(s.execute("guide.add", &json!({"orientation": "vertical", "position": 100, "page": -1})).is_err());
assert!(s.execute("guide.move", &json!({"page": 0.5, "index": 0, "position": 200})).is_err());
assert!(s.execute("guide.delete", &json!({"page": 0, "index": -1})).is_err());
assert!(s.execute("guide.deleteAll", &json!({"spread": "0"})).is_err());
assert!(s.execute("layout.createGuides", &json!({"spread": -1, "columns": 2})).is_err());
assert!(s.execute("layout.createGuides", &json!({"page": u64::MAX, "columns": 2})).is_err());
assert_eq!(s.execute("guide.list", &json!({})).unwrap().as_array().unwrap().len(), 1);
s.execute("guide.add", &json!({"spread": {"kind": "doc", "index": 0}, "page": 0, "orientation": "horizontal", "position": 150})).unwrap();
assert_eq!(s.execute("guide.list", &json!({})).unwrap().as_array().unwrap().len(), 2);
}
}

View File

@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
"{path?, embedImages?: true} — writes an IDML package to `path`, or returns {base64} without a path",
has_doc, export_idml),
cmd!(noundo "file.openIdml", "Open IDML", [], None,
"{path | base64, name?} — opens an IDML package as a new document (linked images are read next to the file or from its Links/ folder; the fonts in a `Document Fonts` folder beside it load first) → {index, documentFonts, warnings}",
"{path | base64, name?} — opens an IDML package as a new document (linked images are read next to the file or from its Links/ folder; the fonts in a `Document Fonts` folder beside it load first) → {index, documentFonts, warnings: package parts missing, font files skipped}",
always, open_idml),
]
}
@ -25,7 +25,7 @@ fn export_idml(s: &mut Session, p: &Value) -> Result<Value> {
match str_param(p, "path") {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
crate::atomic::write_file(path, &bytes)?;
Ok(json!({"path": path, "bytes": bytes.len()}))
}
None => Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})),
@ -34,6 +34,11 @@ fn export_idml(s: &mut Session, p: &Value) -> Result<Value> {
/// Import IDML bytes; `dir` is the folder the package came from (for relative link lookup).
pub fn import(bytes: &[u8], dir: Option<&std::path::Path>) -> Result<Document> {
import_report(bytes, dir).map(|(document, _)| document)
}
/// [`import`], with the problems that did not stop it (parts missing from the package, …).
pub fn import_report(bytes: &[u8], dir: Option<&std::path::Path>) -> Result<(Document, Vec<String>)> {
#[cfg(not(target_arch = "wasm32"))]
let resolved = std::cell::RefCell::new(std::collections::HashMap::new());
let read = |link: &str| -> Option<Vec<u8>> {
@ -49,7 +54,8 @@ pub fn import(bytes: &[u8], dir: Option<&std::path::Path>) -> Result<Document> {
None
}
};
let document = designcraft_idml::import_idml_with(bytes, &read).map_err(|e| EngineError::Other(e.to_string()))?;
let designcraft_idml::Imported { document, warnings } =
designcraft_idml::import_idml_report(bytes, &read).map_err(|e| EngineError::Other(e.to_string()))?;
#[cfg(not(target_arch = "wasm32"))]
let document = {
let mut document = document;
@ -62,7 +68,7 @@ pub fn import(bytes: &[u8], dir: Option<&std::path::Path>) -> Result<Document> {
resolve_packaged_links(&mut document, dir);
document
};
Ok(document)
Ok((document, warnings))
}
#[cfg(not(target_arch = "wasm32"))]
@ -142,14 +148,15 @@ pub(crate) fn open_idml(s: &mut Session, p: &Value) -> Result<Value> {
} else {
return Err(bad("file.openIdml", "missing `path` or `base64`"));
};
let mut d = import(&bytes, dir.as_deref())?;
let (mut d, mut warnings) = import_report(&bytes, dir.as_deref())?;
if let Some(n) = name {
d.title = n;
}
let (fonts, faces, warnings) = match str_param(p, "path").filter(|_| str_param(p, "base64").is_none()) {
let (fonts, faces, font_warnings) = match str_param(p, "path").filter(|_| str_param(p, "base64").is_none()) {
Some(path) => super::file::load_document_fonts(&mut d, path),
None => (None, 0, Vec::new()),
};
warnings.extend(font_warnings);
// Never save over the .idml with the native format: the document starts unsaved.
let mut st = DocState::new(d, None);
st.fonts = fonts;

View File

@ -29,7 +29,7 @@ impl Library {
#[cfg(not(target_arch = "wasm32"))]
if let Some(p) = &self.path {
let bytes = serde_json::to_vec_pretty(self).map_err(|e| crate::EngineError::Other(e.to_string()))?;
std::fs::write(p, bytes).map_err(|e| crate::EngineError::Other(format!("{p}: {e}")))?;
crate::atomic::write_file(p, &bytes)?;
}
Ok(())
}
@ -86,10 +86,12 @@ pub fn specs() -> Vec<CommandSpec> {
let n = st.selection.items.len();
(str_param(p, "name").map(str::to_string).unwrap_or_else(|| if n > 1 { format!("{n} objects") } else { kind.clone() }), str_param(p, "description").unwrap_or("").to_string())
};
let lib = s.library.as_mut().ok_or_else(no_library)?;
let mut lib = s.library.as_ref().ok_or_else(no_library)?.clone();
let index = lib.items.len();
lib.items.push(LibraryItem { name, description, snippet: super::file::base64_encode(&snip) });
lib.save()?;
Ok(json!({"index": lib.items.len() - 1}))
s.library = Some(lib);
Ok(json!({"index": index}))
}),
cmd!(
"library.place",
@ -107,13 +109,14 @@ pub fn specs() -> Vec<CommandSpec> {
}
),
cmd!(noundo "library.remove", "Delete Item", [], None, "{index}", has_library, |s, p| {
let lib = s.library.as_mut().ok_or_else(no_library)?;
let mut lib = s.library.as_ref().ok_or_else(no_library)?.clone();
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("library.remove", "`index` required"))? as usize;
if i >= lib.items.len() {
return Err(bad("library.remove", format!("no item {i}")));
}
lib.items.remove(i);
lib.save()?;
s.library = Some(lib);
ok()
}),
cmd!(noundo "conveyor.collect", "Collect", [], None, "{ids? (default: the selection)} — each object onto the Content Collector conveyor → {count}", has_selection, |s, p| {

View File

@ -28,7 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
|s, p| {
let parent = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("story.placeAndLink", "missing story"))?);
let rect = rect_param(p, "rect").ok_or_else(|| bad("story.placeAndLink", "missing rect"))?;
let sr = spread_param(p, "spread");
let sr = spread_param(p, "story.placeAndLink")?;
let layer = s.doc()?.active_layer;
s.edit(|d, sel| {
if d.story(parent).is_none() {

View File

@ -112,8 +112,10 @@ pub fn status(a: &designcraft_doc::Asset) -> &'static str {
}
fn list(d: &Document) -> Vec<Value> {
let used = d.used_assets();
d.assets
.values()
.filter(|a| used.contains(&a.id))
.map(|a| {
let uses: Vec<Value> = uses(d, a.id)
.into_iter()
@ -311,3 +313,118 @@ mod relink_folder_tests {
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
mod shared_asset_tests {
use super::*;
fn png(w: u32, h: u32) -> Vec<u8> {
designcraft_render::Rendered { width: w, height: h, pixels: vec![200u8; (w * h * 4) as usize] }.to_png()
}
#[test]
fn placements_of_one_file_share_an_asset_and_saves_drop_replaced_ones() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = super::super::file::base64_encode(&png(10, 10));
s.execute("file.place", &json!({"base64": a, "name": "a.png", "x": 72, "y": 72})).unwrap();
s.execute("edit.deselectAll", &json!({})).unwrap();
s.execute("file.place", &json!({"base64": a, "name": "a.png", "x": 200, "y": 72})).unwrap();
let l = s.execute("links.list", &json!({})).unwrap();
assert_eq!(l.as_array().unwrap().len(), 1);
assert_eq!(l[0]["uses"].as_array().unwrap().len(), 2);
assert_eq!(s.doc().unwrap().doc.assets.len(), 1);
// A new image into a frame: the old asset stays in memory (undo) but not in the file.
let f = s.execute("frame.create", &json!({"rect": [100, 300, 200, 400]})).unwrap()["id"].as_u64().unwrap();
let b = super::super::file::base64_encode(&png(20, 10));
let c = super::super::file::base64_encode(&png(30, 10));
s.execute("file.place", &json!({"base64": b, "name": "b.png", "frame": f})).unwrap();
s.execute("file.place", &json!({"base64": c, "name": "c.png", "frame": f})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.assets.len(), 3);
assert_eq!(s.execute("links.list", &json!({})).unwrap().as_array().unwrap().len(), 2);
let back = designcraft_format::load(&designcraft_format::save(&s.doc().unwrap().doc).unwrap()).unwrap();
let mut names: Vec<&str> = back.assets.values().map(|a| a.name.as_str()).collect();
names.sort();
assert_eq!(names, ["a.png", "c.png"]);
}
#[test]
fn files_with_identical_bytes_at_different_paths_keep_their_own_links() {
let dir = std::env::temp_dir().join(format!("dc-same-bytes-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let (a, b) = (dir.join("a.png"), dir.join("b.png"));
std::fs::write(&a, png(10, 10)).unwrap();
std::fs::write(&b, png(10, 10)).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
for (path, x) in [(&a, 72), (&b, 200)] {
s.execute("edit.deselectAll", &json!({})).unwrap();
s.execute("file.place", &json!({"path": path.to_string_lossy(), "x": x, "y": 72})).unwrap();
}
assert_eq!(s.doc().unwrap().doc.assets.len(), 2);
let rows = |s: &mut Session| -> Vec<(String, Value)> {
let l = s.execute("links.list", &json!({})).unwrap();
l.as_array().unwrap().iter().map(|r| (r["path"].as_str().unwrap().to_string(), r["pixels"].clone())).collect()
};
let paths = |r: &[(String, Value)]| r.iter().map(|(p, _)| p.clone()).collect::<Vec<_>>();
let before = rows(&mut s);
assert_eq!(paths(&before), [a.to_string_lossy(), b.to_string_lossy()]);
// b.png changes on disk: updating it leaves a.png's placement alone.
std::fs::write(&b, png(20, 10)).unwrap();
assert_eq!(s.execute("links.update", &json!({})).unwrap()["updated"], 1);
let after = rows(&mut s);
assert_eq!(paths(&after), paths(&before));
assert_eq!((after[0].1.clone(), after[1].1.clone()), (json!([10, 10]), json!([20, 10])));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn pasted_graphics_reuse_the_identical_asset_of_the_target() {
use designcraft_doc::build::NewDocument;
use designcraft_doc::{Asset, Graphic, Item, ItemId, Shape, SpreadRef};
let asset = |id: u64, data: Vec<u8>| Asset {
id: AssetId(id),
name: "p.png".into(),
mime: "image/png".into(),
link: None,
data: Arc::new(data),
pixels: None,
page: 0,
};
let mut src = Document::new(&NewDocument::default());
let mut dst = Document::new(&NewDocument::default());
let (pa, pb) = (png(4, 4), png(8, 4));
// The source's asset id is taken in the target by a different file.
let sid = AssetId(src.alloc());
src.assets.insert(sid, Arc::new(asset(sid.0, pa.clone())));
dst.assets.insert(sid, Arc::new(asset(sid.0, pb)));
dst.alloc();
let same = AssetId(dst.alloc());
assert_ne!(same, sid);
dst.assets.insert(same, Arc::new(asset(same.0, pa)));
let iid = ItemId(src.alloc());
let mut it = Item::new(
iid,
src.default_layer(),
Shape::Rectangle,
designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, 9.0, 9.0)),
);
it.content = Content::Graphic(Graphic {
asset: sid,
size: (4.0, 4.0),
xf: designcraft_geom::Affine::IDENTITY,
auto_fit: Default::default(),
fit_align: 4,
crop: [1.0; 4],
});
src.insert_item(SpreadRef::Doc(0), it, None).unwrap();
let ids = super::super::object::duplicate_from(&mut dst, &src, &[iid], SpreadRef::Doc(0), designcraft_geom::Vec2::ZERO).unwrap();
let g = dst.item(ids[0]).and_then(|i| i.graphic()).unwrap();
assert_eq!((g.asset, g.crop), (same, [1.0; 4]));
assert_eq!(dst.assets.len(), 2);
}
}

View File

@ -168,7 +168,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
),
cmd!("guide.liquid", "Convert to Liquid Guide", [], None, "{spread?, page, index, on?: bool (default true)}", has_doc, |s, p| {
let sr = super::spread_param(p, "spread");
let sr = super::spread_param(p, "guide.liquid")?;
let pi = p.get("page").and_then(Value::as_u64).unwrap_or(0) as usize;
let gi = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("guide.liquid", "`index` required"))? as usize;
let on = p.get("on").and_then(Value::as_bool).unwrap_or(true);

View File

@ -13,11 +13,16 @@ use crate::{Result, Session};
fn math_item(d: &mut designcraft_doc::Document, latex: &str, size: f64, lid: designcraft_doc::LayerId, at: (f64, f64)) -> Item {
let (svg, (w, h)) = crate::math::to_svg(latex, size);
let aid = AssetId(d.alloc());
d.assets.insert(
aid,
Arc::new(Asset { page: 0, id: aid, name: "math.svg".into(), mime: "image/svg+xml".into(), link: None, data: Arc::new(svg), pixels: None }),
);
let fresh = AssetId(d.alloc());
let aid = d.add_asset(Asset {
page: 0,
id: fresh,
name: "math.svg".into(),
mime: "image/svg+xml".into(),
link: None,
data: Arc::new(svg),
pixels: None,
});
let id = ItemId(d.alloc());
let r = Rect::new(at.0, at.1, at.0 + w, at.1 + h);
let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r));
@ -65,7 +70,7 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
});
}
let text = s.doc()?.selection.text.filter(|t| t.cell.is_none());
let spread = super::spread_param(p, "spread");
let spread = super::spread_param(p, "math.insert")?;
let (px, py) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64));
s.edit(|d, sel| {
if let Some(t) = text {

View File

@ -20,13 +20,13 @@ pub(crate) fn place_media(s: &mut Session, p: &Value, name: String, mime: &str,
let st = s.active()?;
st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| matches!(it.content, Content::Unassigned | Content::Graphic(_))))
});
let spread = super::spread_param(p, "spread");
let spread = super::spread_param(p, "file.place")?;
let lid = s.doc()?.active_layer;
let (px, py) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64));
let mime = mime.to_string();
s.edit(|d, sel| {
let aid = AssetId(d.alloc());
d.assets.insert(aid, Arc::new(Asset { page: 0, id: aid, name, mime, link, data: Arc::new(bytes), pixels: None }));
let fresh = AssetId(d.alloc());
let aid = d.add_asset(Asset { page: 0, id: fresh, name, mime, link, data: Arc::new(bytes), pixels: None });
let id = match target {
Some(fid) => {
let it = d.item_mut(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?;
@ -90,10 +90,9 @@ pub fn specs() -> Vec<CommandSpec> {
s.edit(|d, _| {
let poster_id = match poster {
Some(Some((bytes, name, link, px))) => {
let aid = AssetId(d.alloc());
let fresh = AssetId(d.alloc());
let mime = designcraft_render::image_mime(&bytes).to_string();
d.assets.insert(aid, Arc::new(Asset { page: 0, id: aid, name, mime, link, data: Arc::new(bytes), pixels: Some(px) }));
Some(Some(aid))
Some(Some(d.add_asset(Asset { page: 0, id: fresh, name, mime, link, data: Arc::new(bytes), pixels: Some(px) })))
}
Some(None) => Some(None),
None => None,

View File

@ -50,6 +50,7 @@ mod strokes;
mod style;
pub mod table;
pub mod text;
mod thread;
mod toc;
mod transitions;
mod variables;
@ -184,6 +185,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(package::specs());
v.extend(print::specs());
v.extend(text::specs());
v.extend(thread::specs());
v.extend(style::specs());
v.extend(table::specs());
v.extend(layout::specs());
@ -254,12 +256,23 @@ pub(crate) fn rect_param(p: &Value, key: &str) -> Option<Rect> {
let f = |i: usize| a.get(i).and_then(Value::as_f64);
Some(Rect::new(f(0)?, f(1)?, f(2)?, f(3)?))
}
/// `{"kind":"doc","index":0}`, `0` or absent (= spread 0).
pub(crate) fn spread_param(p: &Value, key: &str) -> SpreadRef {
match p.get(key) {
Some(Value::Number(n)) => SpreadRef::Doc(n.as_u64().unwrap_or(0) as usize),
Some(v) => serde_json::from_value(v.clone()).unwrap_or(SpreadRef::Doc(0)),
None => SpreadRef::Doc(0),
/// The `spread` param: a body spread index (`0`), a spread reference (`{"kind":"doc"|"parent","index":0}`)
/// or absent (= spread 0). Anything else is an error: read as spread 0, a script aiming at a parent
/// spread got its items on the first page without a word.
pub(crate) fn spread_param(p: &Value, command: &str) -> Result<SpreadRef> {
let index = |v: &Value, key: &str| {
v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(command, format!("`{key}` must be a non-negative integer")))
};
let Some(value) = p.get("spread") else { return Ok(SpreadRef::Doc(0)) };
if value.is_number() {
return Ok(SpreadRef::Doc(index(value, "spread")?));
}
let object = value.as_object().ok_or_else(|| bad(command, "`spread` must be a non-negative integer or {kind: doc|parent, index}"))?;
let i = index(object.get("index").ok_or_else(|| bad(command, "missing `spread.index`"))?, "spread.index")?;
match object.get("kind").and_then(Value::as_str) {
Some("doc") => Ok(SpreadRef::Doc(i)),
Some("parent") => Ok(SpreadRef::Parent(i)),
_ => Err(bad(command, "`spread.kind` must be `doc` or `parent`")),
}
}

View File

@ -3,7 +3,7 @@
use designcraft_doc::{CellAddr, FootnoteOptions, ParaFormat, StoryId, TextSel};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, str_param};
use super::{CommandSpec, bad, cmd, has_doc};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
@ -22,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Document Footnote Options…",
["Type"],
None,
"{style?: arabic|upperRoman|lowerRoman|upperLetters|lowerLetters|symbols, startAt?, restart?: never|page|spread|section, prefix?, suffix?, affixIn?: none|reference|text|both, refPosition?: superscript|subscript|normal|otSuperscript, refCharStyle?, paraStyle?, separator?, spaceBefore?, spaceBetween?, firstBaseline?, firstBaselineMin?, spanColumns?, rule?: {on, weight, color, tint, width, offset, leftIndent}} → the options (no params: just read them)",
"{style?: arabic|upperRoman|lowerRoman|upperLetters|lowerLetters|symbols, startAt?, restart?: never|page|spread|section, prefix?, suffix?, affixIn?: none|reference|text|both, refPosition?: superscript|subscript|normal|otSuperscript, refCharStyle?, paraStyle?, separator?, spaceBefore?, spaceBetween?, firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin?, spanColumns?, rule?: {on, weight, color, tint, width, offset, leftIndent}} → the options (no params: just read them)",
has_doc,
options
),
@ -58,7 +58,7 @@ fn in_note(s: &Session) -> std::result::Result<(), String> {
}
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
let text = str_param(p, "text").unwrap_or("").to_string();
let text = super::text_param(p, "text");
s.edit(|d, sel| {
let t = sel.text.ok_or_else(|| bad("footnote.insert", "no insertion point"))?;
let para = ParaFormat { style: d.footnote_options.para_style.clone(), ..Default::default() };
@ -130,7 +130,7 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
let sid = story_id(p, "footnote.setText")?;
let id = note_id(p, "footnote.setText")?;
let text = str_param(p, "text").unwrap_or("").to_string();
let text = super::text_param(p, "text");
s.edit(|d, _| {
let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?;
let n = st.note_mut(id).ok_or_else(|| bad("footnote.setText", format!("no footnote {id}")))?;

View File

@ -1,7 +1,7 @@
//! Object creation and the Object menu: frames, lines, transforms, arrange, group, lock/hide,
//! fill/stroke, content type, Text Frame Options, corners, fitting, text wrap.
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use designcraft_color::BlendMode;
@ -14,6 +14,17 @@ use serde_json::{Value, json};
use super::{CommandSpec, bad, bool_or, cmd, f64_or, has_doc, has_selection, ok, point_param, rect_param, spread_param, str_param, targets};
use crate::{Result, Session};
/// `name` must be a swatch of the document: an unknown name would paint nothing. The error names
/// the swatch it was probably meant to be (`Paper` → `[Paper]`, a different case).
fn known_swatch(doc: &designcraft_doc::Document, name: &str, cmd: &str, key: &str) -> Result<()> {
if doc.swatch(name).is_some() {
return Ok(());
}
let bare = |n: &str| n.trim().trim_start_matches('[').trim_end_matches(']').to_lowercase();
let hint = doc.swatches.iter().find(|s| bare(&s.name) == bare(name)).map(|s| format!(" (did you mean `{}`?)", s.name)).unwrap_or_default();
Err(bad(cmd, format!("{key}: no swatch named `{name}`{hint}; create one with `swatch.create` and use its returned name")))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@ -21,12 +32,20 @@ pub fn specs() -> Vec<CommandSpec> {
"Create Frame",
[],
None,
"{spread?, rect: [x0,y0,x1,y1] (spread coords), shape?: rectangle|ellipse|polygon, content?: graphic|text|unassigned, sides?: 6, text?: string, caret?: bool, vertical?: bool (text: a new vertical story)}",
"{spread?: index | {kind: doc|parent, index} (a parent spread: index into `parents` of document.inspect), rect: [x0,y0,x1,y1] (spread coords), shape?: rectangle|ellipse|polygon, content?: graphic|text|unassigned, sides?: 6, text?: string, caret?: bool, vertical?: bool (text: a new vertical story)}",
has_doc,
frame_create
),
cmd!("line.create", "Create Line", [], None, "{spread?, a: [x,y], b: [x,y]}", has_doc, line_create),
cmd!("transform.move", "Move", ["Object", "Transform"], None, "{dx, dy, copy?: bool, ids?, toSpread?}", has_selection, transform_move),
cmd!(
"transform.move",
"Move",
["Object", "Transform"],
None,
"{dx, dy, copy?: bool, ids?, toSpread?} → {moved, ids: the objects moved (with copy, the copies)}",
has_selection,
transform_move
),
cmd!(
"transform.resize",
"Resize",
@ -160,11 +179,30 @@ pub fn specs() -> Vec<CommandSpec> {
)
}
),
cmd!("object.fill", "Fill", [], None, "{swatch, tint?: 0..1, ids?}", has_selection, |s, p| {
cmd!("object.fill", "Fill", [], None, "{swatch (a swatch's name, e.g. [Paper]), tint?: 0..1, ids?}", has_selection, |s, p| {
let sw = str_param(p, "swatch").ok_or_else(|| bad("object.fill", "missing swatch"))?.to_string();
known_swatch(&s.doc()?.doc, &sw, "object.fill", "swatch")?;
let tint = f64_or(p, "tint", 1.0) as f32;
set_flag(s, p, move |i| i.fill = Fill { swatch: sw.clone(), tint, ..i.fill.clone() }, false)
}),
cmd!(
"object.swapFillStroke",
"Swap Fill and Stroke",
[],
None,
"{ids?} — each object's fill and stroke colours (and tints) trade places; locked objects stay as they are",
has_editable_selection,
|s, p| edit_unlocked(s, p, swap_fill_stroke)
),
cmd!(
"object.defaultFillStroke",
"Default Fill and Stroke",
[],
None,
"{ids?} — no fill and a black stroke; locked objects stay as they are",
has_editable_selection,
|s, p| edit_unlocked(s, p, default_fill_stroke)
),
cmd!(
"object.stroke",
"Stroke",
@ -173,6 +211,11 @@ pub fn specs() -> Vec<CommandSpec> {
"{swatch?, tint?, weight?, align?: center|inside|outside, type?: {kind:…}, cap?: butt|round|projecting, join?: miter|round|bevel, miterLimit?, start?, end?: none|simple|simpleWide|triangle|triangleWide|barbed|curved|circle|circleSolid|square|squareSolid|bar, gapSwatch?, gapTint?, gapOverprint?: bool, ids?}",
has_selection,
|s, p| {
for key in ["swatch", "gapSwatch"] {
if let Some(name) = p.get(key).and_then(Value::as_str) {
known_swatch(&s.doc()?.doc, name, "object.stroke", key)?;
}
}
let p2 = p.clone();
set_flag(
s,
@ -489,7 +532,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, firstBaseline?, autoSize?, 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, firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, autoSize?, 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?}",
has_selection,
text_frame_options
),
@ -565,13 +608,39 @@ pub fn specs() -> Vec<CommandSpec> {
"Step and Repeat…",
["Edit"],
Some("Cmd+Alt+U"),
"{count?: 1, dx?, dy?, rows?, columns?, ids?} — copies of the selection (or ids) offset by (dx, dy); with rows/columns, a grid",
"{count?: 1..1000, dx?, dy?, rows?, columns?, ids?} — copies of the selection (or ids) offset by (dx, dy); with rows/columns, a grid of at most 1000 cells, and at most 1000 item copies including group contents → {created, ids: the copies}",
has_selection,
step_and_repeat
),
cmd!("path.create", "Create Path", [], None, "{spread?, anchors: [{p:[x,y], in?:[x,y], out?:[x,y]}], closed?: bool}", has_doc, path_create),
cmd!("path.appendAnchor", "Add Anchor", [], None, "{id, anchor: {p, in?, out?}} (spread coords)", has_doc, path_append),
cmd!("path.close", "Close Path", ["Object", "Paths"], None, "{id}", has_doc, path_close),
cmd!(
"path.appendAnchor",
"Add Anchor",
[],
None,
"{id, anchor: {p, in?, out?} (spread coords), subpath?: index (default the last)} — after the subpath's last anchor",
has_doc,
path_append
),
cmd!(
"path.setAnchor",
"Set Anchor",
[],
None,
"{id, subpath?: index (default the last), anchor: index|\"first\"|\"last\", p?, in?, out? (spread coords)} — moves an anchor or its direction lines; with both lines given and collinear it becomes smooth",
has_doc,
path_set_anchor
),
cmd!(
"path.reverse",
"Reverse Path",
["Object", "Paths"],
None,
"{ids?, subpath?: index (only that subpath of the first target)} — the anchors run the other way (the end point becomes the start)",
has_selection,
path_reverse
),
cmd!("path.close", "Close Path", ["Object", "Paths"], None, "{id, subpath?: index (default every subpath)}", has_doc, path_close),
cmd!("path.moveAnchors", "Move Anchors", [], None, "{id, anchors: [[subpath, index]], dx, dy, handle?: in|out}", has_doc, path_move_anchors),
cmd!(
"object.align",
@ -803,7 +872,7 @@ fn frame_create(s: &mut Session, p: &Value) -> Result<Value> {
let rect = rect_param(p, "rect").ok_or_else(|| bad("frame.create", "missing rect"))?;
let shape = str_param(p, "shape").unwrap_or("rectangle");
let content = str_param(p, "content").unwrap_or("graphic");
let sr = spread_param(p, "spread");
let sr = spread_param(p, "frame.create")?;
let lid = s.doc()?.active_layer;
let text = super::text_param(p, "text");
let caret = bool_or(p, "caret", content == "text");
@ -866,7 +935,7 @@ pub(crate) fn polygon_in(r: Rect, sides: u32, inset: f64) -> designcraft_geom::P
fn line_create(s: &mut Session, p: &Value) -> Result<Value> {
let a = point_param(p, "a").ok_or_else(|| bad("line.create", "missing a"))?;
let b = point_param(p, "b").ok_or_else(|| bad("line.create", "missing b"))?;
let sr = spread_param(p, "spread");
let sr = spread_param(p, "line.create")?;
let lid = s.doc()?.active_layer;
s.edit(|d, sel| {
let id = ItemId(d.alloc());
@ -900,6 +969,11 @@ pub fn duplicate_from(dst: &mut Document, src: &Document, ids: &[ItemId], to: Sp
fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut HashMap<StoryId, StoryId>) {
it.id = ItemId(dst.alloc());
let my_id = it.id;
if let Some(m) = it.media.as_mut()
&& let Some(p) = m.poster
{
m.poster = dst.adopt_asset(src, p).or(Some(p));
}
match &mut it.content {
Content::Text(tf) => {
let new_sid = match stories.get(&tf.story) {
@ -908,6 +982,15 @@ fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut Has
let n = StoryId(dst.alloc());
let mut st = src.story(tf.story).cloned().unwrap_or_else(|| Story::new(n));
st.id = n;
let mut assets = HashMap::new();
for a in designcraft_doc::story_asset_refs(&st) {
if let Some(na) = dst.adopt_asset(src, a)
&& na != a
{
assets.insert(a, na);
}
}
designcraft_doc::remap_story_assets(&mut st, &assets);
st.frames.clear();
dst.stories.insert(n, Arc::new(st));
stories.insert(tf.story, n);
@ -920,10 +1003,8 @@ fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut Has
}
}
Content::Graphic(g) => {
if let Some(a) = src.assets.get(&g.asset)
&& !dst.assets.contains_key(&g.asset)
{
dst.assets.insert(g.asset, a.clone());
if let Some(a) = dst.adopt_asset(src, g.asset) {
g.asset = a;
}
}
Content::Group { items } => {
@ -969,7 +1050,7 @@ fn transform_move(s: &mut Session, p: &Value) -> Result<Value> {
}
it.xf = Affine::translate((dx, dy)) * it.xf;
}
Ok(json!({"moved": ids.len()}))
Ok(json!({"moved": ids.len(), "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
})
}
@ -1281,6 +1362,43 @@ fn arrange(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let to = str_param(p, "to").unwrap_or("front").to_string();
s.edit(|d, _| {
if matches!(to.as_str(), "forward" | "backward") {
let selected: HashSet<ItemId> = ids.iter().copied().collect();
let mut positions: Vec<_> = selected
.iter()
.filter_map(|id| {
let loc = d.find(*id)?;
match loc.path.as_slice() {
[i] => Some((loc.spread, *i)),
_ => None,
}
})
.collect();
// Move the leading selected items first, independently of selection/click order.
// Swapping only with unselected siblings leaves the other selected indices valid.
positions.sort_by_key(|(_, i)| *i);
let forward = to == "forward";
if forward {
positions.reverse();
}
for (spread, i) in positions {
let Some(sp) = d.spread_mut(spread) else { continue };
let Some(it) = sp.items.get(i) else { continue };
let layer = it.layer;
let sibling = if forward {
sp.items.iter().enumerate().skip(i.saturating_add(1)).find(|(_, it)| it.layer == layer)
} else {
sp.items.iter().enumerate().take(i).rev().find(|(_, it)| it.layer == layer)
};
if let Some((j, it)) = sibling
&& !selected.contains(&it.id)
{
// Keep every other layer's slots and relative stacking order untouched.
sp.items.swap(i, j);
}
}
return ok();
}
for id in &ids {
let Some(loc) = d.find(*id) else { continue };
if loc.path.len() != 1 {
@ -1293,7 +1411,6 @@ fn arrange(s: &mut Session, p: &Value) -> Result<Value> {
let j = match to.as_str() {
"front" => n - 1,
"back" => 0,
"forward" => (i + 1).min(n - 1),
_ => i.saturating_sub(1),
};
sp.items.insert(j, it);
@ -1367,6 +1484,60 @@ fn set_flag(s: &mut Session, p: &Value, f: impl Fn(&mut Item), deselect: bool) -
})
}
/// An object a colour command may change: neither it nor its layer is locked.
fn unlocked(d: &Document, it: &Item) -> bool {
!it.locked && d.layer(it.layer).is_none_or(|l| !l.locked)
}
fn has_editable_selection(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
let st = s.active().ok_or_else(|| super::NOTHING_SELECTED.to_string())?;
if st.selection.items.iter().any(|id| st.doc.item(*id).is_some_and(|it| unlocked(&st.doc, it))) {
Ok(())
} else {
Err("the selected objects are locked".into())
}
}
/// Runs `f` on each unlocked target in one edit (one undo step).
fn edit_unlocked(s: &mut Session, p: &Value, f: impl Fn(&mut Item)) -> Result<Value> {
let ids = targets(s, p)?;
s.edit(|d, _| {
let mut changed = 0;
for id in &ids {
if d.item(*id).is_some_and(|it| unlocked(d, it))
&& let Some(it) = d.item_mut(*id)
{
f(it);
changed += 1;
}
}
Ok(json!({"changed": changed}))
})
}
/// A stroke that gets a colour needs a weight to show it.
fn visible_weight(st: &mut Stroke) {
if st.swatch != designcraft_color::swatch::NONE && st.weight <= 0.0 {
st.weight = Stroke::default().weight;
}
}
fn swap_fill_stroke(it: &mut Item) {
let fill = std::mem::take(&mut it.fill);
it.fill = Fill { swatch: std::mem::take(&mut it.stroke.swatch), tint: it.stroke.tint, ..fill.clone() };
it.stroke.swatch = fill.swatch;
it.stroke.tint = fill.tint;
visible_weight(&mut it.stroke);
}
fn default_fill_stroke(it: &mut Item) {
it.fill = Fill { swatch: designcraft_color::swatch::NONE.into(), tint: 1.0, ..it.fill.clone() };
it.stroke.swatch = designcraft_color::swatch::BLACK.into();
it.stroke.tint = 1.0;
visible_weight(&mut it.stroke);
}
fn all_flag(s: &mut Session, f: impl Fn(&mut Item)) -> Result<Value> {
s.edit(|d, _| {
for i in 0..d.spreads.len() {
@ -1555,7 +1726,7 @@ fn gap_move(s: &mut Session, p: &Value) -> Result<Value> {
.and_then(Value::as_array)
.and_then(|a| Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?)))
.ok_or_else(|| bad(ID, "missing at"))?;
let sr = spread_param(p, "spread");
let sr = spread_param(p, ID)?;
let (vertical, a, z, before, after) = find_gap(&s.doc()?.doc, sr, at).ok_or_else(|| bad(ID, "no gap there"))?;
// `delta` along the gap's axis, or a drag (dx, dy) of which the axis part counts.
let delta =
@ -1850,6 +2021,10 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
for ((id, r), t) in items.iter().zip(targets) {
let delta = t - lo(r);
if let Some(it) = d.item_mut(*id) {
// Keep locked items in the spacing calculation, as align does for its reference.
if it.locked {
continue;
}
let v = if horizontal { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) };
it.xf = Affine::translate(v) * it.xf;
}
@ -1882,7 +2057,7 @@ fn path_create(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad("path.create", "a path needs at least two anchors"));
}
let closed = bool_or(p, "closed", false);
let sr = spread_param(p, "spread");
let sr = spread_param(p, "path.create")?;
let lid = s.doc()?.active_layer;
s.edit(|d, sel| {
let id = ItemId(d.alloc());
@ -1898,11 +2073,20 @@ fn path_create(s: &mut Session, p: &Value) -> Result<Value> {
fn path_append(s: &mut Session, p: &Value) -> Result<Value> {
let id = super::id_param(p, "id").ok_or_else(|| bad("path.appendAnchor", "missing id"))?;
let a = p.get("anchor").and_then(anchor_from).ok_or_else(|| bad("path.appendAnchor", "missing anchor"))?;
let subpath = p.get("subpath").and_then(Value::as_u64);
s.edit(|d, _| {
let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
let inv = (d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default()).inverse();
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
let sp = it.path.subpaths.last_mut().ok_or_else(|| bad("path.appendAnchor", "empty path"))?;
let sp = match subpath {
Some(i) => {
usize::try_from(i).ok().and_then(|i| it.path.subpaths.get_mut(i)).ok_or_else(|| bad("path.appendAnchor", "no such subpath"))?
}
None => it.path.subpaths.last_mut().ok_or_else(|| bad("path.appendAnchor", "empty path"))?,
};
if sp.closed {
return Err(bad("path.appendAnchor", "the subpath is closed"));
}
sp.anchors.push(a.transform(inv));
if it.shape == Shape::GraphicLine {
it.shape = Shape::Path;
@ -1911,12 +2095,103 @@ fn path_append(s: &mut Session, p: &Value) -> Result<Value> {
})
}
fn path_set_anchor(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "path.setAnchor";
let id = super::id_param(p, "id").ok_or_else(|| bad(ID, "missing id"))?;
let pt = |k: &str| -> Option<Point> {
let a = p.get(k)?.as_array()?;
let q = Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?);
(q.x.is_finite() && q.y.is_finite()).then_some(q)
};
let (new_p, new_in, new_out) = (pt("p"), pt("in"), pt("out"));
if new_p.is_none() && new_in.is_none() && new_out.is_none() {
return Err(bad(ID, "give p, in or out"));
}
let subpath = p.get("subpath").and_then(Value::as_u64);
let which = p.get("anchor").cloned().unwrap_or(Value::Null);
s.edit(|d, _| {
let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
let inv = (d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default()).inverse();
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
if it.locked {
return Err(bad(ID, "the path is locked"));
}
let sp = match subpath {
Some(i) => usize::try_from(i).ok().and_then(|i| it.path.subpaths.get_mut(i)),
None => it.path.subpaths.last_mut(),
}
.ok_or_else(|| bad(ID, "no such subpath"))?;
let n = sp.anchors.len();
let ai = match &which {
Value::String(w) if w == "first" => 0,
Value::String(w) if w == "last" => n.saturating_sub(1),
v => v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(ID, "anchor: an index, \"first\" or \"last\""))?,
};
let a = sp.anchors.get_mut(ai).ok_or_else(|| bad(ID, "no such anchor"))?;
if let Some(q) = new_p {
let v = inv * q - a.p;
a.p += v;
a.h_in += v;
a.h_out += v;
}
if let Some(q) = new_in {
a.h_in = inv * q;
}
if let Some(q) = new_out {
a.h_out = inv * q;
}
if new_in.is_some() && new_out.is_some() {
let (i, o) = (a.h_in - a.p, a.h_out - a.p);
let collinear = (i.x * o.y - i.y * o.x).abs() <= 1e-6 * i.hypot().max(1.0) * o.hypot().max(1.0) && i.dot(o) <= 0.0;
a.kind = if collinear { designcraft_geom::AnchorKind::Smooth } else { designcraft_geom::AnchorKind::Corner };
}
ok()
})
}
fn path_reverse(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let only = p.get("subpath").and_then(Value::as_u64).map(|i| usize::try_from(i).unwrap_or(usize::MAX));
if let (Some(i), Some(id)) = (only, ids.first())
&& s.doc()?.doc.item(*id).is_none_or(|it| it.path.subpaths.get(i).is_none())
{
return Err(bad("path.reverse", "no such subpath"));
}
let ids = if only.is_some() { ids.into_iter().take(1).collect() } else { ids };
s.edit(|d, _| {
let mut reversed = 0;
for id in &ids {
let Some(it) = d.item_mut(*id) else { continue };
if it.locked || it.is_group() {
continue;
}
for (i, sp) in it.path.subpaths.iter_mut().enumerate() {
if only.is_some_and(|o| o != i) {
continue;
}
sp.anchors.reverse();
for a in &mut sp.anchors {
std::mem::swap(&mut a.h_in, &mut a.h_out);
}
}
reversed += 1;
}
Ok(json!({"reversed": reversed}))
})
}
fn path_close(s: &mut Session, p: &Value) -> Result<Value> {
let id = super::id_param(p, "id").ok_or_else(|| bad("path.close", "missing id"))?;
let only = p.get("subpath").and_then(Value::as_u64).map(|i| usize::try_from(i).unwrap_or(usize::MAX));
s.edit(|d, _| {
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
for sp in &mut it.path.subpaths {
sp.closed = true;
if only.is_some_and(|i| i >= it.path.subpaths.len()) {
return Err(bad("path.close", "no such subpath"));
}
for (i, sp) in it.path.subpaths.iter_mut().enumerate() {
if only.is_none_or(|o| o == i) {
sp.closed = true;
}
}
if it.shape == Shape::GraphicLine {
it.shape = Shape::Path;
@ -1962,20 +2237,113 @@ fn path_move_anchors(s: &mut Session, p: &Value) -> Result<Value> {
})
}
const MAX_STEP_REPEAT_COPIES: u64 = 1000;
const MAX_STEP_REPEAT_WORK: usize = 1000;
const MAX_STEP_REPEAT_OFFSET: f64 = 1_000_000_000.0;
fn step_repeat_count(p: &Value, key: &str, default: u64) -> Result<u64> {
let Some(value) = p.get(key) else { return Ok(default) };
let count = value.as_u64().ok_or_else(|| bad("edit.stepAndRepeat", format!("`{key}` must be an integer")))?;
if !(1..=MAX_STEP_REPEAT_COPIES).contains(&count) {
return Err(bad("edit.stepAndRepeat", format!("`{key}` must be between 1 and {MAX_STEP_REPEAT_COPIES}")));
}
Ok(count)
}
fn step_repeat_delta(p: &Value, key: &str, default: f64) -> Result<f64> {
let Some(value) = p.get(key) else { return Ok(default) };
let delta = value.as_f64().ok_or_else(|| bad("edit.stepAndRepeat", format!("`{key}` must be a finite number")))?;
if !delta.is_finite() {
return Err(bad("edit.stepAndRepeat", format!("`{key}` must be a finite number")));
}
Ok(delta)
}
fn checked_step_offset(dx: f64, dy: f64, x: u64, y: u64) -> Result<Vec2> {
let (x, y) = (x as f64 * dx, y as f64 * dy);
if !x.is_finite() || !y.is_finite() || x.abs() > MAX_STEP_REPEAT_OFFSET || y.abs() > MAX_STEP_REPEAT_OFFSET {
return Err(bad("edit.stepAndRepeat", format!("generated offsets must stay between -{MAX_STEP_REPEAT_OFFSET} and {MAX_STEP_REPEAT_OFFSET}")));
}
Ok(Vec2::new(x, y))
}
fn step_repeat_offsets(p: &Value) -> Result<Vec<Vec2>> {
let (dx, dy) = (step_repeat_delta(p, "dx", 12.0)?, step_repeat_delta(p, "dy", 12.0)?);
match (p.get("rows"), p.get("columns")) {
(None, None) => {
let count = step_repeat_count(p, "count", 1)?;
(1..=count).map(|k| checked_step_offset(dx, dy, k, k)).collect()
}
(Some(_), Some(_)) => {
let rows = step_repeat_count(p, "rows", 1)?;
let columns = step_repeat_count(p, "columns", 1)?;
let cells = rows
.checked_mul(columns)
.filter(|count| *count <= MAX_STEP_REPEAT_COPIES)
.ok_or_else(|| bad("edit.stepAndRepeat", format!("`rows` × `columns` must not exceed {MAX_STEP_REPEAT_COPIES}")))?;
let capacity = usize::try_from(cells.saturating_sub(1))
.map_err(|_| bad("edit.stepAndRepeat", "the requested grid is too large for this platform"))?;
let mut offsets = Vec::with_capacity(capacity);
for row in 0..rows {
for column in 0..columns {
if row != 0 || column != 0 {
offsets.push(checked_step_offset(dx, dy, column, row)?);
}
}
}
Ok(offsets)
}
_ => Err(bad("edit.stepAndRepeat", "`rows` and `columns` must be provided together")),
}
}
/// Existing roots to duplicate, with repeated/ancestor-overlapping targets collapsed. Returns an
/// error before cloning the document when the copies (including group descendants) exceed the
/// command's work budget.
fn checked_step_repeat_targets(d: &Document, ids: &[ItemId], copies: usize, max_work: usize) -> Result<Vec<ItemId>> {
let mut seen = HashSet::new();
let mut located = Vec::new();
for id in ids {
if seen.insert(*id)
&& let Some(loc) = d.find(*id)
{
located.push((*id, loc));
}
}
if located.is_empty() {
return Err(bad("edit.stepAndRepeat", "nothing to repeat"));
}
// A selected ancestor already deep-copies its descendants. Keeping a selected child as a
// second root would duplicate it twice and make the checked work disagree with the real work.
let roots: Vec<ItemId> = located
.iter()
.filter(|(_, loc)| {
!located.iter().any(|(_, parent)| parent.spread == loc.spread && parent.path.len() < loc.path.len() && loc.path.starts_with(&parent.path))
})
.map(|(id, _)| *id)
.collect();
let mut expanded = HashSet::new();
for id in &roots {
if let Some(item) = d.item(*id) {
item.walk(&mut |child| {
expanded.insert(child.id);
});
}
}
expanded
.len()
.checked_mul(copies)
.filter(|work| *work <= max_work)
.ok_or_else(|| bad("edit.stepAndRepeat", format!("a single command may create at most {max_work} items, including group contents")))?;
Ok(roots)
}
fn step_and_repeat(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let (dx, dy) = (f64_or(p, "dx", 12.0), f64_or(p, "dy", 12.0));
let rows = p.get("rows").and_then(Value::as_u64).map(|v| v as usize);
let cols = p.get("columns").and_then(Value::as_u64).map(|v| v as usize);
let count = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1000) as usize;
let offsets: Vec<Vec2> = match (rows, cols) {
(Some(r), Some(c)) if r.checked_mul(c).is_some_and(|n| n <= 1000) => (0..r)
.flat_map(|i| (0..c).map(move |j| (i, j)))
.filter(|&(i, j)| i + j > 0)
.map(|(i, j)| Vec2::new(j as f64 * dx, i as f64 * dy))
.collect(),
_ => (1..=count).map(|k| Vec2::new(k as f64 * dx, k as f64 * dy)).collect(),
};
let offsets = step_repeat_offsets(p)?;
let ids = checked_step_repeat_targets(&s.doc()?.doc, &ids, offsets.len(), MAX_STEP_REPEAT_WORK)?;
s.edit(|d, sel| {
let src = d.clone();
let mut all = ids.clone();
@ -1986,10 +2354,225 @@ fn step_and_repeat(s: &mut Session, p: &Value) -> Result<Value> {
}
}
*sel = Selection::items(all.clone());
Ok(json!({"created": all.len() - ids.len()}))
let copies: Vec<u64> = all.iter().skip(ids.len()).map(|i| i.0).collect();
Ok(json!({"created": copies.len(), "ids": copies}))
})
}
#[cfg(test)]
mod arrange_tests {
use super::*;
fn fixture<const N: usize>(layers: [usize; N]) -> (Session, [ItemId; N]) {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap();
let first = s.doc().unwrap().doc.default_layer().0;
let second = s.execute("layer.new", &json!({"name": "Other layer"})).unwrap()["id"].as_u64().unwrap();
let layer_ids = [first, second];
let mut ids = [ItemId(0); N];
for (id, layer) in ids.iter_mut().zip(layers) {
// Overlap objects within each layer; separate layers spatially for hit testing.
let x = if layer == 0 { 0 } else { 200 };
*id = ItemId(s.execute("frame.create", &json!({"rect": [x, 0, x + 100, 100]})).unwrap()["id"].as_u64().unwrap());
s.execute("object.setLayer", &json!({"ids": [id.0], "layer": layer_ids[layer]})).unwrap();
}
(s, ids)
}
fn stack(s: &Session) -> Vec<ItemId> {
s.doc().unwrap().doc.spreads[0].items.iter().map(|it| it.id).collect()
}
fn snapshot(s: &Session) -> Value {
let st = s.doc().unwrap();
json!({"document": &*st.doc, "selection": &st.selection})
}
fn assert_items_unchanged(before: &Document, after: &Document) {
assert_eq!(serde_json::to_value(&before.layers).unwrap(), serde_json::to_value(&after.layers).unwrap());
for it in &before.spreads[0].items {
assert_eq!(serde_json::to_value(it).unwrap(), serde_json::to_value(after.item(it.id).unwrap()).unwrap());
}
}
#[test]
fn arrange_steps_past_other_layers_change_hit_order_and_undo() {
for direction in ["forward", "backward"] {
let (mut s, [a, x, y, b]) = fixture([0, 1, 1, 0]);
let selected = if direction == "forward" { a } else { b };
s.execute("selection.set", &json!({"ids": [selected.0]})).unwrap();
let before = snapshot(&s);
let doc = s.doc().unwrap().doc.clone();
let undo_len = s.doc().unwrap().history.undo.len();
let hit = |s: &Session| s.doc().unwrap().doc.hit_item(0, Point::new(50.0, 50.0), 0.0);
assert_eq!(hit(&s), Some(b));
s.execute("object.arrange", &json!({"to": direction})).unwrap();
assert_eq!(stack(&s), [b, x, y, a], "{direction}");
assert_eq!(hit(&s), Some(a), "the visible topmost object must change");
assert_items_unchanged(&doc, &s.doc().unwrap().doc);
assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1);
assert_eq!(s.doc().unwrap().selection.items, [selected]);
let after = snapshot(&s);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(snapshot(&s), before);
assert_eq!(hit(&s), Some(b));
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(snapshot(&s), after);
assert_eq!(hit(&s), Some(a));
}
}
#[test]
fn arrange_multiple_selection_preserves_order_independent_of_input_order() {
// Layer 0: a, b, c, d. Layer 1: x, y. Arrays name the original stack indices.
let cases = [
("forward", [0, 2], [4, 1, 0, 3, 2, 5]), // a,b cross c as a block
("backward", [2, 4], [2, 1, 4, 3, 0, 5]), // b,c cross a as a block
("forward", [0, 4], [2, 1, 0, 3, 5, 4]), // separate selected blocks
("backward", [2, 5], [2, 1, 0, 3, 5, 4]),
("forward", [4, 5], [0, 1, 2, 3, 4, 5]), // front block is already at its endpoint
("backward", [0, 2], [0, 1, 2, 3, 4, 5]), // back block is already at its endpoint
];
for (direction, selected, expected) in cases {
for reverse in [false, true] {
let (mut s, ids) = fixture([0, 1, 0, 1, 0, 0]);
let doc = s.doc().unwrap().doc.clone();
let mut selected = selected.map(|i| ids[i].0).to_vec();
if reverse {
selected.reverse();
}
// Explicit command targets may contain duplicates: an item still moves once.
selected.push(selected[0]);
s.execute("object.arrange", &json!({"to": direction, "ids": selected})).unwrap();
assert_eq!(stack(&s), expected.map(|i| ids[i]), "{direction}, reverse={reverse}");
assert_items_unchanged(&doc, &s.doc().unwrap().doc);
}
}
}
#[test]
fn arrange_same_layer_steps_stop_at_layer_endpoints() {
let cases = [
("forward", vec![1], [0, 2, 1, 3, 4]),
("backward", vec![3], [0, 1, 3, 2, 4]),
("forward", vec![3], [0, 1, 2, 3, 4]),
("backward", vec![1], [0, 1, 2, 3, 4]),
("forward", vec![3, 1, 2], [0, 1, 2, 3, 4]),
("backward", vec![3, 1, 2], [0, 1, 2, 3, 4]),
];
for (direction, selected, expected) in cases {
let (mut s, ids) = fixture([1, 0, 0, 0, 1]);
let selected: Vec<_> = selected.into_iter().map(|i| ids[i].0).collect();
s.execute("object.arrange", &json!({"to": direction, "ids": selected})).unwrap();
assert_eq!(stack(&s), expected.map(|i| ids[i]));
}
for direction in ["forward", "backward"] {
let (mut s, [x, a, y]) = fixture([1, 0, 1]);
s.execute("object.arrange", &json!({"to": direction, "ids": [a.0]})).unwrap();
assert_eq!(stack(&s), [x, a, y], "only object on its layer");
}
}
#[test]
fn arrange_selection_on_two_layers_moves_each_layer_independently() {
for direction in ["forward", "backward"] {
let (mut s, [a, x, b, y]) = fixture([0, 1, 0, 1]);
let selected = if direction == "forward" { [x.0, a.0] } else { [b.0, y.0] };
s.execute("object.arrange", &json!({"to": direction, "ids": selected})).unwrap();
assert_eq!(stack(&s), [b, y, a, x]);
let doc = &s.doc().unwrap().doc;
assert_eq!(doc.hit_item(0, Point::new(50.0, 50.0), 0.0), Some(a));
assert_eq!(doc.hit_item(0, Point::new(250.0, 50.0), 0.0), Some(x));
}
}
#[test]
fn arrange_menu_steps_use_same_layer_order() {
for command in ["object.bringForward", "object.sendBackward"] {
let (mut s, [a, x, b]) = fixture([0, 1, 0]);
let selected = if command == "object.bringForward" { a } else { b };
s.execute("selection.set", &json!({"ids": [selected.0]})).unwrap();
s.execute(command, &json!({})).unwrap();
assert_eq!(stack(&s), [b, x, a]);
assert_eq!(s.doc().unwrap().doc.hit_item(0, Point::new(50.0, 50.0), 0.0), Some(a));
}
}
}
#[cfg(test)]
mod step_repeat_tests {
use super::*;
#[test]
fn swap_and_default_fill_stroke_are_one_undo_step_per_object() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = |v: Value| ItemId(v["id"].as_u64().unwrap());
let a = id(s.execute("frame.create", &json!({"rect": [0, 0, 10, 10]})).unwrap());
let b = id(s.execute("frame.create", &json!({"rect": [20, 0, 30, 10]})).unwrap());
let locked = id(s.execute("frame.create", &json!({"rect": [40, 0, 50, 10]})).unwrap());
s.execute("object.fill", &json!({"ids": [a.0], "swatch": "[Paper]", "tint": 0.5})).unwrap();
s.execute("object.stroke", &json!({"ids": [a.0], "swatch": "[Black]", "weight": 2})).unwrap();
s.execute("object.fill", &json!({"ids": [b.0, locked.0], "swatch": "[Black]"})).unwrap();
s.execute("object.stroke", &json!({"ids": [b.0, locked.0], "swatch": "[None]"})).unwrap();
s.execute("object.lock", &json!({"ids": [locked.0]})).unwrap();
s.execute("selection.set", &json!({"ids": [a.0, b.0, locked.0]})).unwrap();
let colors = |s: &Session, i: ItemId| {
let it = s.doc().unwrap().doc.item(i).unwrap().clone();
(it.fill.swatch, it.fill.tint, it.stroke.swatch, it.stroke.tint)
};
let before = [colors(&s, a), colors(&s, b), colors(&s, locked)];
assert_eq!(s.execute("object.swapFillStroke", &json!({})).unwrap()["changed"], 2);
assert_eq!(colors(&s, a), ("[Black]".into(), 1.0, "[Paper]".into(), 0.5));
assert_eq!(colors(&s, b), ("[None]".into(), 1.0, "[Black]".into(), 1.0));
assert_eq!(colors(&s, locked), before[2], "a locked object keeps its colours");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!([colors(&s, a), colors(&s, b), colors(&s, locked)], before, "one undo restores both");
s.execute("object.defaultFillStroke", &json!({})).unwrap();
for i in [a, b] {
assert_eq!(colors(&s, i), ("[None]".into(), 1.0, "[Black]".into(), 1.0));
}
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!([colors(&s, a), colors(&s, b), colors(&s, locked)], before);
s.execute("selection.set", &json!({"ids": [locked.0]})).unwrap();
assert!(s.execute("object.swapFillStroke", &json!({})).is_err(), "nothing unlocked to change");
}
#[test]
fn step_repeat_validates_counts_and_offsets() {
assert_eq!(step_repeat_offsets(&json!({"count": MAX_STEP_REPEAT_COPIES})).unwrap().len(), MAX_STEP_REPEAT_COPIES as usize);
assert_eq!(step_repeat_offsets(&json!({"rows": 25, "columns": 40})).unwrap().len(), 999);
assert!(step_repeat_offsets(&json!({"count": MAX_STEP_REPEAT_COPIES + 1})).is_err());
assert!(step_repeat_offsets(&json!({"rows": 500, "columns": 3})).is_err());
assert!(step_repeat_offsets(&json!({"rows": 3})).is_err());
assert!(step_repeat_offsets(&json!({"count": 2, "dx": f64::MAX})).is_err());
}
#[test]
fn step_repeat_counts_unique_items_and_group_contents() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = ItemId(s.execute("frame.create", &json!({"rect": [0, 0, 10, 10]})).unwrap()["id"].as_u64().unwrap());
let b = ItemId(s.execute("frame.create", &json!({"rect": [20, 0, 30, 10]})).unwrap()["id"].as_u64().unwrap());
let c = ItemId(s.execute("frame.create", &json!({"rect": [40, 0, 50, 10]})).unwrap()["id"].as_u64().unwrap());
let d = &s.doc().unwrap().doc;
assert_eq!(checked_step_repeat_targets(d, &[a, a], 2, 2).unwrap(), vec![a]);
assert!(checked_step_repeat_targets(d, &[a, b, c], 1, 2).is_err());
assert_eq!(s.execute("edit.stepAndRepeat", &json!({"ids": [a.0, a.0], "count": 1})).unwrap()["created"], 1);
let group = ItemId(s.execute("object.group", &json!({"ids": [a.0, b.0]})).unwrap()["id"].as_u64().unwrap());
let d = &s.doc().unwrap().doc;
assert_eq!(checked_step_repeat_targets(d, &[group, group, a], 1, 3).unwrap(), vec![group]);
assert!(checked_step_repeat_targets(d, &[group, a], 1, 2).is_err());
assert_eq!(s.execute("edit.stepAndRepeat", &json!({"ids": [group.0, a.0], "count": 1})).unwrap()["created"], 1);
}
}
#[cfg(test)]
mod polygon_tests {
use super::*;
@ -2024,7 +2607,7 @@ mod scale_tests {
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [100, 100, 200, 150]})).unwrap();
let id = ItemId(r["id"].as_u64().unwrap());
s.execute("object.stroke", &json!({"weight": 4, "swatch": "Black"})).unwrap();
s.execute("object.stroke", &json!({"weight": 4, "swatch": "[Black]"})).unwrap();
let weight = |s: &Session| s.doc().unwrap().doc.item(id).unwrap().stroke.weight;
let bounds = |s: &Session| s.doc().unwrap().doc.item(id).unwrap().bounds();
s.execute("transform.scale", &json!({"sx": 2.0})).unwrap();
@ -2336,8 +2919,8 @@ mod fitting_tests {
s.execute("object.fittingOptions", &json!({"ids": [f], "fitting": "fitProportionally", "align": 0})).unwrap();
let c = content(&s);
assert!((c.x0 - 100.0).abs() < 1e-6 && (c.y0 - 100.0).abs() < 1e-6 && (c.width() - 200.0).abs() < 1e-6, "{c:?}");
// Crop 10 pt on every side: the content spreads past the frame.
s.execute("object.fittingOptions", &json!({"ids": [f], "crop": 10, "align": 4})).unwrap();
// Crop 10 pt on every side and fit again: the content spreads past the frame.
s.execute("object.fittingOptions", &json!({"ids": [f], "fitting": "fitProportionally", "crop": 10, "align": 4})).unwrap();
let c = content(&s);
assert!((c.width() - 220.0).abs() < 1e-6, "{c:?}");
// Auto-fit: resizing the frame refits the content.
@ -2439,6 +3022,18 @@ mod line_end_tests {
s.execute("text.insert", &json!({"text": "\rFour", "raw": true})).unwrap();
assert_eq!(story(&s).text, "One\nTwo\nThree\nFour");
assert_eq!(story(&s).paras.len(), 4);
// Editing a range or the whole story (scripts written for InDesign end paragraphs with \r).
s.execute("story.replaceRange", &json!({"story": sid.0, "start": 0, "end": 3, "text": "Zero\rOne"})).unwrap();
assert_eq!((story(&s).text.as_str(), story(&s).paras.len()), ("Zero\nOne\nTwo\nThree\nFour", 5));
s.execute("story.setText", &json!({"story": sid.0, "text": "A\r\nB\rC"})).unwrap();
assert_eq!((story(&s).text.as_str(), story(&s).paras.len()), ("A\nB\nC", 3));
// Notes, made and edited.
s.execute("text.select", &json!({"story": sid.0, "anchor": 1})).unwrap();
let n = s.execute("note.new", &json!({"text": "One\rTwo"})).unwrap();
let note = |s: &mut Session| s.execute("note.list", &json!({})).unwrap()[0]["text"].clone();
assert_eq!(note(&mut s), "One\nTwo");
s.execute("note.edit", &json!({"story": n["story"], "id": n["id"], "text": "Three\r\nFour"})).unwrap();
assert_eq!(note(&mut s), "Three\nFour");
}
}
@ -2587,3 +3182,148 @@ mod blend_mode_tests {
assert_eq!(it.opacity, 1.0);
}
}
#[cfg(test)]
mod copy_ids_tests {
use serde_json::json;
#[test]
fn copies_report_their_ids() {
let mut s = crate::Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = s.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap()["id"].as_u64().unwrap();
let ids = |r: &serde_json::Value| r["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect::<Vec<_>>();
let selected = |s: &crate::Session| s.doc().unwrap().selection.items.iter().map(|i| i.0).collect::<Vec<_>>();
// Moving reports what moved; copying reports the copies, which are selected.
assert_eq!(ids(&s.execute("transform.move", &json!({"dx": 5, "dy": 0, "ids": [a]})).unwrap()), [a]);
let r = s.execute("transform.move", &json!({"dx": 100, "dy": 0, "copy": true, "ids": [a]})).unwrap();
let copy = ids(&r);
assert!(copy.len() == 1 && copy[0] != a && s.doc().unwrap().doc.item(designcraft_doc::ItemId(copy[0])).is_some(), "{r}");
assert_eq!(selected(&s), copy);
// Edit › Duplicate.
let r = s.execute("edit.duplicate", &json!({})).unwrap();
let dup = ids(&r);
assert!(dup.len() == 1 && dup[0] != copy[0], "{r}");
assert_eq!(selected(&s), dup);
// Step and Repeat: every copy.
let r = s.execute("edit.stepAndRepeat", &json!({"count": 3, "dx": 0, "dy": 60, "ids": [a]})).unwrap();
assert_eq!((r["created"].as_u64(), ids(&r).len()), (Some(3), 3), "{r}");
assert!(ids(&r).iter().all(|i| s.doc().unwrap().doc.item(designcraft_doc::ItemId(*i)).is_some()));
}
}
#[cfg(test)]
mod swatch_name_tests {
use serde_json::json;
#[test]
fn fills_and_strokes_must_name_a_swatch_and_change_nothing_otherwise() {
let mut s = crate::Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = s.execute("frame.create", &json!({"rect": [0, 0, 100, 100]})).unwrap()["id"].clone();
for (cmd, params, hint) in [
("object.fill", json!({"swatch": "Paper", "ids": [id]}), Some("[Paper]")),
("object.fill", json!({"swatch": "[black]", "ids": [id]}), Some("[Black]")),
("object.fill", json!({"swatch": "Missing Ink", "ids": [id]}), None),
("object.stroke", json!({"swatch": "#ff0000", "weight": 4, "ids": [id]}), None),
("object.stroke", json!({"gapSwatch": "paper", "ids": [id]}), Some("[Paper]")),
] {
let before = s.doc().unwrap().doc.clone();
let e = s.execute(cmd, &params).unwrap_err().to_string();
assert!(e.contains("no swatch named") && e.contains("swatch.create"), "{cmd}: {e}");
if let Some(h) = hint {
assert!(e.contains(&format!("did you mean `{h}`")), "{cmd}: {e}");
}
assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc), "{cmd}: nothing changed");
}
// Real names, [None] included, still work.
for sw in ["[Paper]", "[None]", "C=100 M=0 Y=0 K=0"] {
s.execute("object.fill", &json!({"swatch": sw, "ids": [id]})).unwrap();
s.execute("object.stroke", &json!({"swatch": sw, "ids": [id]})).unwrap();
}
}
}
#[cfg(test)]
mod path_edit_tests {
use designcraft_doc::ItemId;
use designcraft_geom::Point;
use serde_json::json;
fn session_with_path() -> (crate::Session, u64) {
let mut s = crate::Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = s
.execute(
"path.create",
&json!({"anchors": [{"p": [100, 100]}, {"p": [200, 100], "in": [170, 80], "out": [230, 120]}, {"p": [300, 200]}]}),
)
.unwrap()["id"]
.as_u64()
.unwrap();
(s, id)
}
fn anchors(s: &crate::Session, id: u64) -> Vec<designcraft_geom::Anchor> {
let d = &s.doc().unwrap().doc;
let it = d.item(ItemId(id)).unwrap();
let xf = d.parent_xf(&d.find(ItemId(id)).unwrap()) * it.xf;
it.path.subpaths[0].anchors.iter().map(|a| a.transform(xf)).collect()
}
#[test]
fn reverse_path_swaps_ends_and_direction_lines() {
let (mut s, id) = session_with_path();
s.execute("path.reverse", &json!({"ids": [id]})).unwrap();
let a = anchors(&s, id);
assert_eq!(a.iter().map(|a| a.p).collect::<Vec<_>>(), [Point::new(300.0, 200.0), Point::new(200.0, 100.0), Point::new(100.0, 100.0)]);
assert_eq!((a[1].h_in, a[1].h_out), (Point::new(230.0, 120.0), Point::new(170.0, 80.0)));
assert!(s.execute("path.reverse", &json!({"ids": [id], "subpath": 3})).is_err());
}
#[test]
fn anchors_go_on_the_named_open_subpath_and_set_anchor_reshapes_one() {
let (mut s, id) = session_with_path();
s.execute("path.appendAnchor", &json!({"id": id, "subpath": 0, "anchor": {"p": [400, 250]}})).unwrap();
assert_eq!(anchors(&s, id).last().unwrap().p, Point::new(400.0, 250.0));
assert!(s.execute("path.appendAnchor", &json!({"id": id, "subpath": 1, "anchor": {"p": [0, 0]}})).is_err());
// Direction lines on the last anchor: collinear and opposite make it smooth.
s.execute("path.setAnchor", &json!({"id": id, "anchor": "last", "in": [380, 230], "out": [420, 270]})).unwrap();
let last = *anchors(&s, id).last().unwrap();
assert_eq!((last.h_in, last.h_out, last.kind), (Point::new(380.0, 230.0), Point::new(420.0, 270.0), designcraft_geom::AnchorKind::Smooth));
s.execute("path.setAnchor", &json!({"id": id, "anchor": 0, "p": [90, 110]})).unwrap();
assert_eq!(anchors(&s, id)[0].p, Point::new(90.0, 110.0));
assert!(s.execute("path.setAnchor", &json!({"id": id, "anchor": 9, "p": [0, 0]})).is_err());
assert!(s.execute("path.setAnchor", &json!({"id": id, "anchor": "last"})).is_err(), "nothing to set");
// Closing one subpath; then it takes no more anchors.
s.execute("path.close", &json!({"id": id, "subpath": 0})).unwrap();
assert!(s.execute("path.appendAnchor", &json!({"id": id, "anchor": {"p": [0, 0]}})).is_err());
assert!(s.execute("path.close", &json!({"id": id, "subpath": 4})).is_err());
}
}
#[cfg(test)]
mod spread_target_tests {
use super::*;
#[test]
fn creating_on_a_parent_spread_and_rejecting_unknown_spreads() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap();
let parent = json!({"kind": "parent", "index": 0});
let r = s.execute("frame.create", &json!({"spread": parent, "rect": [0, 0, 100, 100]})).unwrap();
let id = ItemId(r["id"].as_u64().unwrap());
assert_eq!(s.doc().unwrap().doc.find(id).unwrap().spread, SpreadRef::Parent(0));
s.execute("line.create", &json!({"spread": parent, "a": [0, 0], "b": [50, 50]})).unwrap();
assert_eq!(s.doc().unwrap().doc.parents[0].items.len(), 2);
// Each of these used to land on the first body page.
for bad in
[json!("A-Parent"), json!("0"), json!(-1), json!(1.5), json!({"kind": "master", "index": 0}), json!({"kind": "parent"}), json!([0])]
{
assert!(s.execute("frame.create", &json!({"spread": bad, "rect": [0, 0, 100, 100]})).is_err(), "frame.create spread {bad}");
assert!(s.execute("line.create", &json!({"spread": bad, "a": [0, 0], "b": [50, 50]})).is_err(), "line.create spread {bad}");
}
assert!(s.doc().unwrap().doc.spreads[0].items.is_empty());
}
}

View File

@ -1,15 +1,194 @@
//! File › Package and Links › Copy Links To: gather a document with the files it uses.
use std::fs::Metadata;
use std::path::{Path, PathBuf};
use std::sync::Arc;
#[cfg(not(target_arch = "wasm32"))]
use std::fs::File;
#[cfg(not(target_arch = "wasm32"))]
use std::io::Write;
use super::{CommandSpec, bad, cmd, has_doc, str_param};
use crate::{EngineError, Result, Session};
use designcraft_compose::ComposedStory;
use designcraft_doc::{AssetId, Document};
use designcraft_fonts::{FaceRef, FontFace, FontSource};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, str_param};
use crate::{EngineError, Result, Session};
const MAX_OUTPUT_COMPONENT_BYTES: usize = 200;
fn path_error(path: &Path, message: impl std::fmt::Display) -> EngineError {
EngineError::Other(format!("{}: {message}", path.display()))
}
fn is_redirect(metadata: &Metadata) -> bool {
// Rust reports Windows name-surrogate reparse points (including directory junctions) as
// symlinks, while leaving non-redirecting reparse metadata such as cloud placeholders alone.
metadata.file_type().is_symlink()
}
fn ensure_real_directory(path: &Path, ancestor: bool) -> Result<()> {
match std::fs::symlink_metadata(path) {
Ok(metadata) => {
if is_redirect(&metadata) {
// An existing folder above the package may be reached through a link the user
// set up (`/tmp` on macOS is one); the package folder itself may not.
if ancestor && std::fs::metadata(path).is_ok_and(|m| m.is_dir()) {
return Ok(());
}
return Err(path_error(path, "refusing a redirected output directory"));
}
if !metadata.is_dir() {
return Err(path_error(path, "output path is not a directory"));
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
if let Some(parent) = path.parent().filter(|parent| !parent.as_os_str().is_empty())
&& parent != path
{
ensure_real_directory(parent, true)?;
}
if let Err(e) = std::fs::create_dir(path)
&& e.kind() != std::io::ErrorKind::AlreadyExists
{
return Err(path_error(path, e));
}
let metadata = std::fs::symlink_metadata(path).map_err(|e| path_error(path, e))?;
if is_redirect(&metadata) || !metadata.is_dir() {
return Err(path_error(path, "output directory was redirected while it was being created"));
}
}
Err(e) => return Err(path_error(path, e)),
}
Ok(())
}
/// A selected output directory and its resolved identity. Every package path is checked against
/// it before use. Outputs are staged in the same directory and atomically persisted over an
/// existing regular file, preserving overwrite behavior without following file links.
struct OutputRoot {
path: PathBuf,
canonical: PathBuf,
}
impl OutputRoot {
fn new(path: PathBuf) -> Result<Self> {
ensure_real_directory(&path, false)?;
let canonical = std::fs::canonicalize(&path).map_err(|e| path_error(&path, e))?;
Ok(Self { path, canonical })
}
fn validate_directory(&self, path: &Path) -> Result<()> {
if !path.starts_with(&self.path) {
return Err(path_error(path, "output path is outside the selected directory"));
}
ensure_real_directory(path, false)?;
let canonical = std::fs::canonicalize(path).map_err(|e| path_error(path, e))?;
if !canonical.starts_with(&self.canonical) {
return Err(path_error(path, "output directory resolves outside the selected directory"));
}
Ok(())
}
fn child(&self, name: &str) -> Result<PathBuf> {
let path = self.path.join(name);
if path.parent() != Some(self.path.as_path()) {
return Err(path_error(&path, "invalid output directory name"));
}
Ok(path)
}
fn validate_file_entry(&self, path: &Path) -> Result<bool> {
let parent = path.parent().ok_or_else(|| path_error(path, "output file has no parent directory"))?;
self.validate_directory(parent)?;
if !path.starts_with(&self.path) {
return Err(path_error(path, "output file is outside the selected directory"));
}
match std::fs::symlink_metadata(path) {
Ok(metadata) => {
if is_redirect(&metadata) {
return Err(path_error(path, "refusing to replace a redirected output file"));
}
if !metadata.is_file() {
return Err(path_error(path, "output path is not a regular file"));
}
Ok(true)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(path_error(path, e)),
}
}
#[cfg(not(target_arch = "wasm32"))]
fn temporary_file(&self, path: &Path, permissions: Option<std::fs::Permissions>) -> Result<tempfile::NamedTempFile> {
let parent = path.parent().ok_or_else(|| path_error(path, "output file has no parent directory"))?;
self.validate_directory(parent)?;
let mut builder = tempfile::Builder::new();
builder.prefix(".designcraft-package-").suffix(".tmp");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
// Match fs::write for a new output: 0666 restricted by the caller's umask.
builder.permissions(std::fs::Permissions::from_mode(0o666));
}
let file = builder.tempfile_in(parent).map_err(|e| path_error(path, e))?;
#[cfg(unix)]
if let Some(permissions) = permissions {
// Overwrites retain their prior mode; copied fonts retain their source mode.
file.as_file().set_permissions(permissions).map_err(|e| path_error(path, e))?;
}
#[cfg(not(unix))]
// The replacement inherits its directory's ACL. Rust's portable permission API cannot
// reproduce a destination file's Windows DACL without platform-specific code.
let _ = permissions;
Ok(file)
}
fn write(&self, path: &Path, bytes: &[u8]) -> Result<()> {
#[cfg(target_arch = "wasm32")]
{
let _ = (path, bytes);
return Err(EngineError::Other("package files are unavailable on the web".into()));
}
#[cfg(not(target_arch = "wasm32"))]
{
let existed = self.validate_file_entry(path)?;
let permissions = if existed { Some(std::fs::symlink_metadata(path).map_err(|e| path_error(path, e))?.permissions()) } else { None };
let mut file = self.temporary_file(path, permissions)?;
file.write_all(bytes).map_err(|e| path_error(path, e))?;
// Recheck immediately before replacement. If a name is raced after this check,
// persist replaces that directory entry rather than following it.
self.validate_file_entry(path)?;
file.persist(path).map_err(|e| path_error(path, e.error))?;
Ok(())
}
}
fn copy(&self, from: &Path, to: &Path) -> Result<()> {
if self.validate_file_entry(to)?
&& std::fs::canonicalize(to).is_ok_and(|target| std::fs::canonicalize(from).is_ok_and(|source| source == target))
{
return Ok(());
}
#[cfg(target_arch = "wasm32")]
{
let _ = from;
return Err(EngineError::Other("package files are unavailable on the web".into()));
}
#[cfg(not(target_arch = "wasm32"))]
{
let mut source = File::open(from).map_err(|e| path_error(from, e))?;
let permissions = Some(source.metadata().map_err(|e| path_error(from, e))?.permissions());
let mut target = self.temporary_file(to, permissions)?;
std::io::copy(&mut source, &mut target).map_err(|e| path_error(to, e))?;
self.validate_file_entry(to)?;
target.persist(to).map_err(|e| path_error(to, e.error))?;
Ok(())
}
}
}
pub fn specs() -> Vec<CommandSpec> {
vec![
@ -34,14 +213,57 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// A file name for asset `a` in a Links folder, unique among `taken`.
fn link_name(name: &str, id: AssetId, taken: &mut Vec<String>) -> String {
let base =
Path::new(name).file_name().map(|n| n.to_string_lossy().to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("asset-{}", id.0));
unique_name(base, taken)
fn is_reserved_windows_name(name: &str) -> bool {
let stem = name.split('.').next().unwrap_or(name).trim_end_matches([' ', '.']);
["con", "prn", "aux", "nul"].into_iter().any(|reserved| caseless::compatibility_caseless_match_str(stem, reserved))
|| (1..=9).any(|n| {
caseless::compatibility_caseless_match_str(stem, &format!("com{n}"))
|| caseless::compatibility_caseless_match_str(stem, &format!("lpt{n}"))
})
}
/// `base`, or `base` numbered ("logo 2.png"), unique among `taken` (case-insensitively).
fn portable_names_equal(a: &str, b: &str) -> bool {
caseless::canonical_caseless_match_str(a, b)
}
/// A portable single path component made from document metadata.
fn safe_component(name: &str, fallback: &str) -> String {
let leaf = name.rsplit(['/', '\\']).find(|part| !part.is_empty()).unwrap_or("");
let mut safe = String::new();
for ch in leaf.trim().chars() {
let replacement = ch.is_control() || matches!(ch, '<' | '>' | ':' | '"' | '|' | '?' | '*' | '/' | '\\');
let ch = if replacement { '_' } else { ch };
if safe.len() + ch.len_utf8() > MAX_OUTPUT_COMPONENT_BYTES {
break;
}
safe.push(ch);
}
safe.truncate(safe.trim_end_matches([' ', '.']).len());
if safe.is_empty() || safe == "." || safe == ".." {
safe = fallback.to_string();
}
if is_reserved_windows_name(&safe) {
safe.insert(0, '_');
}
safe
}
fn package_output_path(dir: &Path, title: &str, extension: &str) -> Result<PathBuf> {
let path = dir.join(format!("{title}.{extension}"));
if path.parent() != Some(dir) {
return Err(bad("file.package", "an output filename would be outside the selected directory"));
}
Ok(path)
}
/// A file name for asset `a` in a Links folder, unique among `taken`.
fn link_name(name: &str, id: AssetId, taken: &mut Vec<String>) -> String {
let fallback = format!("asset-{}", id.0);
unique_name(safe_component(name, &fallback), taken)
}
/// `base`, or `base` numbered ("logo 2.png"), unique under portable Unicode normalization and
/// case-insensitive comparison.
fn unique_name(base: String, taken: &mut Vec<String>) -> String {
let (stem, ext) = match base.rsplit_once('.') {
Some((s, e)) => (s.to_string(), format!(".{e}")),
@ -49,7 +271,7 @@ fn unique_name(base: String, taken: &mut Vec<String>) -> String {
};
let mut n = base;
let mut k = 2;
while taken.iter().any(|t| t.eq_ignore_ascii_case(&n)) {
while taken.iter().any(|t| portable_names_equal(t, &n)) {
n = format!("{stem} {k}{ext}");
k += 1;
}
@ -58,21 +280,21 @@ fn unique_name(base: String, taken: &mut Vec<String>) -> String {
}
/// Write each asset's bytes into `dir`; returns the relinked document and the paths written.
fn write_links(d: &Document, dir: &Path, only: Option<&[AssetId]>) -> Result<(Document, Vec<PathBuf>)> {
std::fs::create_dir_all(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
fn write_links(d: &Document, root: &OutputRoot, dir: &Path, only: Option<&[AssetId]>) -> Result<(Document, Vec<PathBuf>)> {
root.validate_directory(dir)?;
let mut out = d.clone();
let mut taken = Vec::new();
let mut written = Vec::new();
let mut ids: Vec<AssetId> = d.assets.keys().copied().collect();
ids.sort_by_key(|i| i.0);
// Only files something places; the saved document leaves the others out.
let ids = d.used_assets();
for id in ids {
if only.is_some_and(|o| !o.contains(&id)) {
continue;
}
let a = &d.assets[&id];
let Some(a) = d.assets.get(&id) else { continue };
let name = link_name(a.link.as_deref().unwrap_or(&a.name), id, &mut taken);
let path = dir.join(&name);
std::fs::write(&path, a.data.as_slice()).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?;
root.write(&path, a.data.as_slice())?;
let mut na = (**a).clone();
na.link = Some(path.to_string_lossy().to_string());
out.assets.insert(id, Arc::new(na));
@ -84,7 +306,7 @@ fn write_links(d: &Document, dir: &Path, only: Option<&[AssetId]>) -> Result<(Do
/// Copy the font file of `face` into `dir` (created when first needed) unless its licence
/// restricts it or it is already there (`copied`: the source files copied) → what the package
/// report adds after the font's name (nothing when copied).
fn copy_font(face: &FontFace, dir: &Path, copied: &mut Vec<PathBuf>, taken: &mut Vec<String>, files: &mut Vec<PathBuf>) -> String {
fn copy_font(root: &OutputRoot, face: &FontFace, dir: &Path, copied: &mut Vec<PathBuf>, taken: &mut Vec<String>, files: &mut Vec<PathBuf>) -> String {
let from = match &face.source {
FontSource::Bundled => return " — included with DesignCraft".into(),
FontSource::Memory => return " — not copied: no font file".into(),
@ -96,13 +318,10 @@ fn copy_font(face: &FontFace, dir: &Path, copied: &mut Vec<PathBuf>, taken: &mut
if copied.contains(from) {
return String::new();
}
let name = from.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "font".into());
let name = safe_component(&from.file_name().map(|n| n.to_string_lossy()).unwrap_or_default(), "font");
let to = dir.join(unique_name(name, taken));
// Packaging into the folder the font is in: it is there already (and copying a file onto
// itself empties it on some systems).
let same = std::fs::canonicalize(&to).is_ok_and(|t| std::fs::canonicalize(from).is_ok_and(|f| f == t));
match std::fs::create_dir_all(dir).and_then(|_| if same { Ok(0) } else { std::fs::copy(from, &to) }) {
Ok(_) => {
match root.validate_directory(dir).and_then(|_| root.copy(from, &to)) {
Ok(()) => {
copied.push(from.clone());
files.push(to);
String::new()
@ -136,18 +355,22 @@ fn drawn_faces(cs: &ComposedStory, depth: usize, out: &mut Vec<FaceRef>) {
fn package(s: &mut Session, p: &Value) -> Result<Value> {
let dir = PathBuf::from(str_param(p, "dir").ok_or_else(|| bad("file.package", "missing `dir`"))?);
let st = s.doc()?;
let title = st.doc.title.clone();
let root = OutputRoot::new(dir.clone())?;
let display_title = st.doc.title.clone();
let title = safe_component(&display_title, "Untitled");
let fonts = s.execute("font.list", &json!({}))?;
let links = s.execute("links.list", &json!({}))?;
let pre = s.execute("preflight.run", &json!({})).unwrap_or(Value::Null);
let d = s.doc()?.doc.clone();
let (packed, mut files) = write_links(&d, &dir.join("Links"), None)?;
let doc_path = dir.join(format!("{title}.designcraft"));
std::fs::write(&doc_path, super::to_bytes(&packed)).map_err(|e| EngineError::Other(format!("{}: {e}", doc_path.display())))?;
let links_dir = root.child("Links")?;
let (packed, mut files) = write_links(&d, &root, &links_dir, None)?;
let doc_path = package_output_path(&dir, &title, "designcraft")?;
root.write(&doc_path, &super::to_bytes(&packed))?;
files.insert(0, doc_path);
// The font files, and what the report says about each font.
let db = designcraft_fonts::FontDb::global().scoped(d.font_scope);
let (fonts_dir, mut copied, mut taken) = (dir.join(designcraft_fonts::DOCUMENT_FONTS_FOLDER), Vec::new(), Vec::new());
let fonts_dir = root.child(designcraft_fonts::DOCUMENT_FONTS_FOLDER)?;
let (mut copied, mut taken) = (Vec::new(), Vec::new());
let (mut font_lines, mut named) = (Vec::new(), Vec::new());
for f in fonts.as_array().into_iter().flatten() {
let (family, style) = (f["family"].as_str().unwrap_or(""), f["style"].as_str().unwrap_or(""));
@ -155,7 +378,7 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
// A missing font's text is drawn in its substitute, which isn't a fallback font.
let face = db.face(family, style);
named.push(face.id());
let note = if missing { " — MISSING".to_string() } else { copy_font(&face, &fonts_dir, &mut copied, &mut taken, &mut files) };
let note = if missing { " — MISSING".to_string() } else { copy_font(&root, &face, &fonts_dir, &mut copied, &mut taken, &mut files) };
font_lines.push(format!(" {family} {style}{note}\n"));
}
// Fallback fonts: the faces that draw characters the named fonts lack.
@ -165,21 +388,23 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
}
drawn.retain(|f| !named.contains(&f.id()));
drawn.sort_by(|a, b| (&a.family, &a.style).cmp(&(&b.family, &b.style)));
let fallback_lines: Vec<String> =
drawn.iter().map(|f| format!(" {} {}{}\n", f.family, f.style, copy_font(f, &fonts_dir, &mut copied, &mut taken, &mut files))).collect();
let fallback_lines: Vec<String> = drawn
.iter()
.map(|f| format!(" {} {}{}\n", f.family, f.style, copy_font(&root, f, &fonts_dir, &mut copied, &mut taken, &mut files)))
.collect();
if p.get("idml").and_then(Value::as_bool).unwrap_or(true) {
let path = dir.join(format!("{title}.idml"));
std::fs::write(&path, designcraft_idml::export_idml(&packed)).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?;
let path = package_output_path(&dir, &title, "idml")?;
root.write(&path, &designcraft_idml::export_idml(&packed))?;
files.push(path);
}
if p.get("pdf").and_then(Value::as_bool).unwrap_or(false) {
let path = dir.join(format!("{title}.pdf"));
let path = package_output_path(&dir, &title, "pdf")?;
let bytes = designcraft_pdf::export_pdf(&packed, &s.cache, &Default::default()).map_err(|e| EngineError::Other(e.to_string()))?;
std::fs::write(&path, bytes).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?;
root.write(&path, &bytes)?;
files.push(path);
}
// The report: fonts (missing first), links, preflight, the user's instructions.
let mut r = format!("Package report: {title}\n\n");
let mut r = format!("Package report: {display_title}\n\n");
if let Some(t) = str_param(p, "instructions").filter(|t| !t.trim().is_empty()) {
r += &format!("Instructions\n{t}\n\n");
}
@ -200,7 +425,7 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
}
}
let report = dir.join("Instructions.txt");
std::fs::write(&report, &r).map_err(|e| EngineError::Other(format!("{}: {e}", report.display())))?;
root.write(&report, r.as_bytes())?;
files.push(report);
Ok(json!({"dir": dir.to_string_lossy(), "files": files.iter().map(|f| f.to_string_lossy().to_string()).collect::<Vec<_>>(), "report": r}))
}
@ -209,7 +434,8 @@ fn copy_links(s: &mut Session, p: &Value) -> Result<Value> {
let dir = PathBuf::from(str_param(p, "dir").ok_or_else(|| bad("links.copyTo", "missing `dir`"))?);
let only: Option<Vec<AssetId>> = p.get("assets").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(AssetId).collect());
let d = s.doc()?.doc.clone();
let (relinked, written) = write_links(&d, &dir, only.as_deref())?;
let root = OutputRoot::new(dir.clone())?;
let (relinked, written) = write_links(&d, &root, &dir, only.as_deref())?;
s.edit(|doc, _| {
doc.assets = relinked.assets;
Ok(())
@ -291,6 +517,12 @@ mod tests {
std::fs::create_dir_all(&fonts).unwrap();
std::fs::write(fonts.join("free.ttf"), font_with(FREE, &['F']).unwrap()).unwrap();
std::fs::write(fonts.join("restricted.otf"), with_fs_type(font_with(RESTRICTED, &['R']).unwrap(), 0x0002).unwrap()).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(fonts.join("free.ttf"), std::fs::Permissions::from_mode(0o640)).unwrap();
}
// Text in both fonts, and in the bundled default font.
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
@ -308,6 +540,15 @@ mod tests {
let r = s.execute("file.package", &json!({"dir": out.to_string_lossy(), "idml": false})).unwrap();
let packed_fonts = out.join(DOCUMENT_FONTS_FOLDER);
assert!(packed_fonts.join("free.ttf").exists());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
std::fs::metadata(packed_fonts.join("free.ttf")).unwrap().permissions().mode() & 0o777,
std::fs::metadata(fonts.join("free.ttf")).unwrap().permissions().mode() & 0o777
);
}
assert!(!packed_fonts.join("restricted.otf").exists(), "a restricted licence keeps the font out");
assert!(r["files"].to_string().contains("free.ttf"), "{}", r["files"]);
let report = r["report"].as_str().unwrap();
@ -369,4 +610,142 @@ mod tests {
assert_eq!(Session::new().execute("file.open", &json!({"path": doc})).unwrap()["documentFonts"], 2);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn package_uses_portable_names_inside_the_selected_directory() {
assert_eq!(safe_component("Archive/Annual: Review", "Untitled"), "Annual_ Review");
assert_eq!(safe_component(".", "Untitled"), "Untitled");
assert_eq!(safe_component("CON", "Untitled"), "_CON");
assert_eq!(safe_component("COM¹.txt", "Untitled"), "_COM¹.txt");
assert_eq!(safe_component("LPT³", "Untitled"), "_LPT³");
for name in ["COM¹", "COM²", "COM³", "LPT¹", "LPT²", "LPT³"] {
assert!(is_reserved_windows_name(name), "{name}");
}
let mut taken = Vec::new();
assert_eq!(unique_name("Logo.svg".into(), &mut taken), "Logo.svg");
assert_eq!(unique_name("logo.svg".into(), &mut taken), "logo 2.svg");
assert_eq!(unique_name("Résumé.pdf".into(), &mut taken), "Résumé.pdf");
assert_eq!(unique_name("Re\u{301}sume\u{301}.pdf".into(), &mut taken), "Re\u{301}sume\u{301} 2.pdf");
assert_eq!(unique_name("ΟΣ.txt".into(), &mut taken), "ΟΣ.txt");
assert_eq!(unique_name("ος.txt".into(), &mut taken), "ος 2.txt");
let dir = std::env::temp_dir().join(format!("dc-package-safe-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut s = Session::new();
s.execute("file.new", &json!({"title": "Archive/Annual: Review"})).unwrap();
let r = s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false})).unwrap();
let files = r["files"].as_array().unwrap();
assert!(files.iter().all(|path| Path::new(path.as_str().unwrap()).starts_with(&dir)));
assert!(dir.join("Annual_ Review.designcraft").is_file());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn package_overwrites_existing_regular_outputs() {
let dir = std::env::temp_dir().join(format!("dc-package-overwrite-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let document = dir.join("Annual.designcraft");
std::fs::write(&document, b"previous package").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&document, std::fs::Permissions::from_mode(0o640)).unwrap();
}
let mut s = Session::new();
s.execute("file.new", &json!({"title": "Annual"})).unwrap();
s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false})).unwrap();
s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false})).unwrap();
assert!(super::super::from_bytes(&std::fs::read(&document).unwrap()).is_ok());
assert!(!dir.join(designcraft_fonts::DOCUMENT_FONTS_FOLDER).exists());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(std::fs::metadata(&document).unwrap().permissions().mode() & 0o777, 0o640);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn package_accepts_a_relative_destination() {
let dir = PathBuf::from(format!(".dc-package-relative-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut s = Session::new();
s.execute("file.new", &json!({"title": "Relative"})).unwrap();
s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false})).unwrap();
assert!(dir.join("Relative.designcraft").is_file());
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn package_refuses_redirected_destinations() {
use std::os::unix::fs::symlink;
let base = std::env::temp_dir().join(format!("dc-package-redirects-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
let outside = base.join("separate");
std::fs::create_dir_all(&outside).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({"title": "Annual"})).unwrap();
let redirected_root = base.join("redirected-package");
symlink(&outside, &redirected_root).unwrap();
assert!(s.execute("file.package", &json!({"dir": redirected_root.to_string_lossy(), "idml": false})).is_err());
assert!(!outside.join("Annual.designcraft").exists());
std::fs::remove_file(&redirected_root).unwrap();
// A linked folder above the package is followed, as `/tmp` is on macOS.
let linked_parent = base.join("linked-parent");
symlink(&outside, &linked_parent).unwrap();
let nested_package = linked_parent.join("nested").join("Package");
s.execute("file.package", &json!({"dir": nested_package.to_string_lossy(), "idml": false})).unwrap();
assert!(outside.join("nested").join("Package").join("Annual.designcraft").is_file());
std::fs::remove_file(&linked_parent).unwrap();
let package = base.join("Package");
std::fs::create_dir_all(&package).unwrap();
symlink(&outside, package.join("Links")).unwrap();
assert!(s.execute("file.package", &json!({"dir": package.to_string_lossy(), "idml": false})).is_err());
assert!(!outside.join("Annual.designcraft").exists());
std::fs::remove_file(package.join("Links")).unwrap();
std::fs::create_dir(package.join("Links")).unwrap();
let kept = outside.join("kept.designcraft");
std::fs::write(&kept, b"kept").unwrap();
symlink(&kept, package.join("Annual.designcraft")).unwrap();
assert!(s.execute("file.package", &json!({"dir": package.to_string_lossy(), "idml": false})).is_err());
assert_eq!(std::fs::read(&kept).unwrap(), b"kept");
let _ = std::fs::remove_dir_all(&base);
}
#[cfg(windows)]
#[test]
fn package_refuses_windows_directory_redirects() {
use std::os::windows::fs::symlink_dir;
let base = std::env::temp_dir().join(format!("dc-package-windows-redirect-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
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}"),
}
let mut s = Session::new();
s.execute("file.new", &json!({"title": "Annual"})).unwrap();
assert!(s.execute("file.package", &json!({"dir": redirected.to_string_lossy(), "idml": false})).is_err());
assert!(!outside.join("Annual.designcraft").exists());
std::fs::remove_dir(&redirected).unwrap();
let _ = std::fs::remove_dir_all(&base);
}
}

View File

@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Type on a Path Options…",
["Type", "Type on a Path"],
None,
"{id?, start?, flip?, align?: baseline|ascender|descender|center, delete?: true (Delete Type from Path)}",
"{id?, start?, end?: distance along the path or null (the path's end), flip?, align?: baseline|ascender|descender|center, delete?: true (Delete Type from Path)} — start and end are the brackets: start ≤ end, both on the path",
has_doc,
path_options
),
@ -36,7 +36,7 @@ fn align_param(p: &Value) -> Result<Option<PathAlign>> {
fn on_path(s: &mut Session, p: &Value) -> Result<Value> {
let id = super::id_param(p, "id").ok_or_else(|| bad("type.onPath", "missing id"))?;
let text = super::str_param(p, "text").unwrap_or("").to_string();
let text = super::text_param(p, "text");
let mut pt = PathType {
start: p.get("start").and_then(Value::as_f64).unwrap_or(0.0).max(0.0),
flip: p.get("flip").and_then(Value::as_bool).unwrap_or(false),
@ -86,6 +86,7 @@ fn path_options(s: &mut Session, p: &Value) -> Result<Value> {
let p = p.clone();
s.edit(|d, sel| {
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
let len = designcraft_geom::warp::PathWarp::new(&it.path.to_bezpath(), false).length();
let Content::Text(tf) = &mut it.content else { return Err(bad("type.pathOptions", "not type on a path")) };
let Some(pt) = tf.options.path.as_mut() else { return Err(bad("type.pathOptions", "not type on a path")) };
if delete {
@ -95,8 +96,26 @@ fn path_options(s: &mut Session, p: &Value) -> Result<Value> {
sel.text = None;
return Ok(json!({"deleted": true}));
}
if let Some(v) = p.get("start").and_then(Value::as_f64) {
pt.start = v.max(0.0);
if let Some(v) = p.get("start").and_then(Value::as_f64).filter(|v| v.is_finite()) {
pt.start = v.max(0.0).min(len);
}
match p.get("end") {
Some(Value::Null) => pt.end = None,
Some(v) => {
let e = v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad("type.pathOptions", "end: a distance or null"))?;
pt.end = (e < len - 0.01).then_some(e.max(0.0));
}
None => {}
}
// The brackets can't cross: the one being moved stops at the other.
if let Some(e) = pt.end
&& pt.start > e
{
if p.get("end").is_some() {
pt.end = Some(pt.start);
} else {
pt.start = e;
}
}
if let Some(v) = p.get("flip").and_then(Value::as_bool) {
pt.flip = v;
@ -104,7 +123,7 @@ fn path_options(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(a) = align {
pt.align = a;
}
Ok(json!({"start": pt.start, "flip": pt.flip, "align": pt.align}))
Ok(json!({"start": pt.start, "end": pt.end, "flip": pt.flip, "align": pt.align}))
})
}
@ -160,4 +179,93 @@ mod tests {
let pdf = designcraft_pdf::export_pdf(&d, &s2.cache, &Default::default()).unwrap();
assert!(pdf.len() > 1000);
}
#[test]
fn the_brackets_set_where_the_text_starts_and_ends() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = s.execute("line.create", &json!({"a": [100, 300], "b": [500, 300]})).unwrap()["id"].as_u64().unwrap();
let r = s.execute("type.onPath", &json!({"id": id, "text": "Sixteen letters!"})).unwrap();
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
let overset = |s: &Session| designcraft_compose::compose_story(&s.doc().unwrap().doc, sid, &Default::default()).overset_at.is_some();
let undo = s.doc().unwrap().history.undo.len();
assert!(!overset(&s));
// The end bracket close to the start: the text no longer fits, and is overset.
let r = s.execute("type.pathOptions", &json!({"id": id, "start": 50, "end": 70})).unwrap();
assert_eq!((r["start"].as_f64(), r["end"].as_f64()), (Some(50.0), Some(70.0)));
assert!(overset(&s));
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
// Brackets can't cross: the start stops at the end, the end at the start.
let r = s.execute("type.pathOptions", &json!({"id": id, "start": 90})).unwrap();
assert_eq!(r["start"].as_f64(), Some(70.0));
let r = s.execute("type.pathOptions", &json!({"id": id, "start": 10, "end": 5})).unwrap();
assert_eq!((r["start"].as_f64(), r["end"].as_f64()), (Some(10.0), Some(10.0)));
// Past the path, or null: the path's end again; the start stays on the path.
let r = s.execute("type.pathOptions", &json!({"id": id, "end": 1000})).unwrap();
assert!(r["end"].is_null());
let r = s.execute("type.pathOptions", &json!({"id": id, "end": 200})).unwrap();
assert_eq!(r["end"].as_f64(), Some(200.0));
let r = s.execute("type.pathOptions", &json!({"id": id, "end": null, "start": 5000})).unwrap();
assert_eq!((r["start"].as_f64(), r["end"].is_null()), (Some(400.0), true));
assert!(s.execute("type.pathOptions", &json!({"id": id, "end": "far"})).is_err());
// Saved with the document.
let d = s.doc().unwrap().doc.clone();
let back = crate::cmd::file_from(&crate::cmd::file_bytes(&d)).unwrap();
let path = |d: &designcraft_doc::Document| d.item(designcraft_doc::ItemId(id)).unwrap().text_frame().unwrap().options.path.clone();
assert_eq!(path(&back), path(&d));
}
#[test]
fn the_type_tool_clicks_and_selects_along_a_curved_path() {
for flip in [false, true] {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
// An arch: up from (100, 400) over (300, 200) and down to (500, 400).
let id = s
.execute("path.create", &json!({"anchors": [{"p": [100, 400], "out": [100, 250]}, {"p": [300, 200], "in": [200, 200], "out": [400, 200]}, {"p": [500, 400], "in": [500, 250]}]}))
.unwrap()["id"]
.as_u64()
.unwrap();
let r = s.execute("type.onPath", &json!({"id": id, "text": "AAAA BBBB CCCC DDDD EEEE FFFF GGGG", "flip": flip})).unwrap();
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
// Where each glyph sits on the path, from the composed line.
let d = s.doc().unwrap().doc.clone();
let it = d.item(designcraft_doc::ItemId(id)).unwrap();
let cs = designcraft_compose::compose_story(&d, sid, &Default::default());
let ft = &cs.frames[0];
let map = designcraft_compose::PathTextMap::new(it, ft).unwrap();
let l = &ft.lines[0];
let glyph = |byte: usize| l.glyphs.iter().find(|g| g.byte == byte).unwrap();
let on_path = |byte: usize| {
let g = glyph(byte);
// A little outside the path, as a click near the letter would be.
map.to_path(designcraft_geom::Point::new(g.x + g.adv * 0.3, l.baseline - 4.0))
};
// A click by the first C (byte 10), on the curve: the caret goes there.
let p = on_path(10);
let r = s.execute("text.placeCaret", &json!({"frame": id, "point": [p.x, p.y]})).unwrap();
assert_eq!(r["pos"], 10, "flip={flip}: {r}");
// Dragging to the first E (byte 20) selects C to D.
let q = on_path(20);
s.execute("text.extendTo", &json!({"frame": id, "point": [q.x, q.y]})).unwrap();
let t = s.doc().unwrap().selection.text.unwrap();
assert_eq!(t.range(), 10..20, "flip={flip}");
// A double-click on a word (its first click places the caret) selects it.
let w = on_path(16);
s.execute("text.select", &json!({"story": sid.0, "anchor": 0})).unwrap();
s.execute("text.selectWord", &json!({"frame": id, "point": [w.x, w.y]})).unwrap();
assert_eq!(s.doc().unwrap().selection.text.unwrap().range(), 15..19, "flip={flip}");
// A click on a letter, above the path, hits the frame (the type tool's hit test).
let above = map.to_path(designcraft_geom::Point::new(glyph(10).x, l.baseline - l.ascent * 0.7));
let sp = s.doc().unwrap().doc.clone();
assert_eq!(
sp.hit_item(0, sp.parent_xf(&sp.find(designcraft_doc::ItemId(id)).unwrap()) * above, 1.0),
Some(designcraft_doc::ItemId(id)),
"flip={flip}"
);
// The map runs both ways along the path.
let back = map.to_line(map.to_path(designcraft_geom::Point::new(123.0, l.baseline)));
assert!((back.x - 123.0).abs() < 0.01, "{back:?}");
}
}
}

View File

@ -124,19 +124,25 @@ fn fit(d: &Document, sid: StoryId) -> (Option<usize>, usize, usize) {
}
/// Thread new frames on new pages (margin rectangles) until the story fits. Returns pages added.
pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize) -> Result<usize> {
/// A story on a parent spread doesn't autoflow: its frames thread only on that parent. Errors
/// name `cmd`, the command that autoflows.
pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &str) -> Result<usize> {
let last = d.story(sid).and_then(|s| s.frames.last().copied()).ok_or_else(|| bad(cmd, "the story has no frame"))?;
if !matches!(d.find(last).map(|l| l.spread), Some(SpreadRef::Doc(_))) {
return Err(bad(cmd, "autoflow works only from a frame on a document page"));
}
let mut added = 0;
loop {
let (overset, held, full) = fit(d, sid);
let Some(at) = overset else { return Ok(added) };
if added >= max_pages {
return Err(bad("file.place", format!("autoflow stopped after {max_pages} pages")));
return Err(bad(cmd, format!("autoflow stopped after {max_pages} pages")));
}
let len = d.story(sid).map_or(0, |s| s.len());
// Frames still needed at the rate the full ones are filled (at least one); with no full
// frame to go by yet, one more page.
let want = held.checked_div(full).map_or(1, |per_frame| len.saturating_sub(at).div_ceil(per_frame.max(1))).clamp(1, max_pages - added);
let last = *d.story(sid).and_then(|s| s.frames.last()).ok_or_else(|| bad("file.place", "the story has no frame"))?;
let last = *d.story(sid).and_then(|s| s.frames.last()).ok_or_else(|| bad(cmd, "the story has no frame"))?;
let mut page = d.page_of_item(last).unwrap_or(d.page_count().saturating_sub(1));
let parent = d.page_loc(page).and_then(|(si, pi)| d.spreads[si].pages[pi].parent);
d.insert_pages(Some(page), want, parent)?;
@ -144,7 +150,7 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize) -> Resu
let mut prev = last;
for _ in 0..want {
page += 1;
let (si, pi) = d.page_loc(page).ok_or_else(|| bad("file.place", "page insert failed"))?;
let (si, pi) = d.page_loc(page).ok_or_else(|| bad(cmd, "page insert failed"))?;
let r = d.spreads[si].pages[pi].margin_rect();
let (fid, _) = d.add_text_frame(SpreadRef::Doc(si), r, layer, "", ParaFormat::default())?;
d.thread(prev, fid)?;
@ -230,7 +236,7 @@ pub(super) fn place_text(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) -
*sel = designcraft_doc::Selection::items(vec![fid]);
sid
};
let pages = if autoflow_on { autoflow(d, sid, 2000)? } else { 0 };
let pages = if autoflow_on { autoflow(d, sid, 2000, "file.place")? } else { 0 };
let overset = fit(d, sid).0.is_some();
Ok(json!({"story": sid.0, "pagesAdded": pages, "overset": overset, "warnings": warnings}))
})

View File

@ -5,6 +5,7 @@
use designcraft_compose::ComposedStory;
use designcraft_doc::{Content, Document, Intent, Item, SpreadRef};
use designcraft_fonts::FaceRef;
use designcraft_geom::Affine;
use serde::Serialize;
use serde_json::{Value, json};
@ -29,10 +30,12 @@ pub fn specs() -> Vec<CommandSpec> {
"{minPpi?: 150} → issues [{severity, kind, message, item?, page?}]", has_doc, run)]
}
fn page_of(d: &Document, sr: SpreadRef, it: &Item) -> Option<usize> {
fn page_of(d: &Document, sr: SpreadRef, it: &Item, parent: Affine) -> Option<usize> {
let SpreadRef::Doc(si) = sr else { return None };
let sp = d.spreads.get(si)?;
Some(d.first_page_of_spread(si) + sp.page_at_x(it.bounds().center().x).unwrap_or(0))
// Item bounds include its own transform; ancestors bring the frame into spread space.
let bounds = parent.transform_rect_bbox(it.bounds());
Some(d.first_page_of_spread(si) + sp.page_at_x(bounds.center().x).unwrap_or(0))
}
/// Characters a missing-glyph report names before "and N more".
@ -85,7 +88,7 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> {
missing_glyphs(&cs, &story.text, 0, &mut missing);
if cs.is_overset() {
let last = story.frames.last().copied();
let page = last.and_then(|f| d.find(f).zip(d.item(f))).and_then(|(loc, it)| page_of(d, loc.spread, it));
let page = last.and_then(|f| d.find(f).zip(d.item(f))).and_then(|(loc, it)| page_of(d, loc.spread, it, Affine::IDENTITY));
let n = story.text[cs.overset_at.unwrap_or(0).min(story.len())..].chars().count();
out.push(Issue { severity: "error", kind: "overset", message: format!("Overset text: {n} characters"), item: last.map(|i| i.0), page });
}
@ -161,48 +164,52 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> {
page: None,
});
}
// Items.
// Carry the ancestor transform in the same order as rendering, retaining pre-order.
for (si, sp) in d.spreads.iter().enumerate() {
for top in &sp.items {
top.walk(&mut |it| {
let page = page_of(d, SpreadRef::Doc(si), it);
match &it.content {
Content::Graphic(g) => match d.assets.get(&g.asset) {
None => {
out.push(Issue { severity: "error", kind: "missingLink", message: "Missing graphic".into(), item: Some(it.id.0), page })
}
Some(a) if a.data.is_empty() => out.push(Issue {
severity: "error",
kind: "missingLink",
message: format!("Missing link: {}", a.link.as_deref().unwrap_or(&a.name)),
item: Some(it.id.0),
page,
}),
Some(a) => {
// Effective ppi: pixels per inch at the placed size. Placed PDF and
// SVG pages export as vectors; their `pixels` is only the preview's.
let vector = designcraft_images::is_pdf(&a.data) || designcraft_images::is_svg(&a.data);
if let Some((pw, _)) = a.pixels.filter(|_| !vector) {
let placed_w = (g.xf * it.xf).as_coeffs()[0].hypot((g.xf * it.xf).as_coeffs()[1]) * g.size.0;
let ppi = pw as f64 / (placed_w / 72.0).max(1e-6);
if d.settings.intent == Intent::Print && ppi < min_ppi {
out.push(Issue {
severity: "warning",
kind: "lowResolution",
message: format!("{}: effective {ppi:.0} ppi (< {min_ppi:.0})", a.name),
item: Some(it.id.0),
page,
});
}
let mut pending: Vec<_> = sp.items.iter().rev().map(|it| (it.as_ref(), Affine::IDENTITY)).collect();
while let Some((it, parent)) = pending.pop() {
let xf = parent * it.xf;
pending.extend(it.children().iter().rev().map(|child| (child.as_ref(), xf)));
let page = page_of(d, SpreadRef::Doc(si), it, parent);
match &it.content {
Content::Graphic(g) => match d.assets.get(&g.asset) {
None => out.push(Issue { severity: "error", kind: "missingLink", message: "Missing graphic".into(), item: Some(it.id.0), page }),
Some(a) if a.data.is_empty() => out.push(Issue {
severity: "error",
kind: "missingLink",
message: format!("Missing link: {}", a.link.as_deref().unwrap_or(&a.name)),
item: Some(it.id.0),
page,
}),
Some(a) => {
// Placed PDF and SVG pages export as vectors; `pixels` is only the preview's.
let vector = designcraft_images::is_pdf(&a.data) || designcraft_images::is_svg(&a.data);
if let Some((pw, ph)) = a.pixels.filter(|_| !vector) {
// Measure each image axis in points, not its rotated bounding box or
// cropped frame. Either axis can fall below the minimum resolution.
let [xx, yx, xy, yy, _, _] = (xf * g.xf).as_coeffs();
let placed_w = xx.hypot(yx) * g.size.0;
let placed_h = xy.hypot(yy) * g.size.1;
let ppi_x = pw as f64 / (placed_w / 72.0).max(1e-6);
let ppi_y = ph as f64 / (placed_h / 72.0).max(1e-6);
let ppi = ppi_x.min(ppi_y);
if d.settings.intent == Intent::Print && ppi < min_ppi {
out.push(Issue {
severity: "warning",
kind: "lowResolution",
message: format!("{}: effective {ppi:.0} ppi (< {min_ppi:.0})", a.name),
item: Some(it.id.0),
page,
});
}
}
},
Content::Text(tf) if d.story(tf.story).is_some_and(|s| s.is_empty()) => {
out.push(Issue { severity: "warning", kind: "emptyText", message: "Empty text frame".into(), item: Some(it.id.0), page });
}
_ => {}
},
Content::Text(tf) if d.story(tf.story).is_some_and(|s| s.is_empty()) => {
out.push(Issue { severity: "warning", kind: "emptyText", message: "Empty text frame".into(), item: Some(it.id.0), page });
}
});
_ => {}
}
}
}
// RGB swatches in use in a print document.

View File

@ -1,7 +1,4 @@
//! File › Print: the pages go to a printer through the system print spooler (`lpr`), as PDF.
#[cfg(not(target_arch = "wasm32"))]
use std::io::Write;
//! File › Print: the pages go to a printer through the system print spooler (craft-print), as PDF.
use serde_json::{Value, json};
@ -20,91 +17,60 @@ pub fn specs() -> Vec<CommandSpec> {
"Print…",
["File"],
Some("Cmd+P"),
"{printer?, copies?: 1, pages?: \"1-3,5\", spreads?, marks?, bleed?, dryRun?: bool (only return the spool command)} → {printer, copies, pages, command}",
"{printer?, copies?: 1, pages?: \"1-3,5\", spreads?, marks?, bleed?, dryRun?: bool (export and preview submission without printing)} → {printer, copies, pages, command, bytes}",
has_doc,
print
),
]
}
#[cfg(not(target_arch = "wasm32"))]
fn printers() -> Value {
let run = |args: &[&str]| {
std::process::Command::new("lpstat").args(args).output().ok().map(|o| String::from_utf8_lossy(&o.stdout).to_string()).unwrap_or_default()
};
let names: Vec<String> = run(&["-e"]).lines().map(str::trim).filter(|l| !l.is_empty()).map(str::to_string).collect();
// "system default destination: NAME"
let default = run(&["-d"]).rsplit(':').next().map(str::trim).filter(|s| !s.is_empty() && !s.contains("no system default")).map(str::to_string);
let printers = craft_print::printers();
let default = printers.iter().find(|p| p.default).map(|p| p.name.clone());
let names: Vec<_> = printers.into_iter().map(|p| p.name).collect();
json!({"printers": names, "default": default})
}
#[cfg(target_arch = "wasm32")]
fn printers() -> Value {
json!({"printers": [], "default": null})
}
fn print(s: &mut Session, p: &Value) -> Result<Value> {
let d = &s.doc()?.doc;
let opts = super::export::options(p, d.page_count())?;
#[cfg(target_arch = "wasm32")]
{
// Even a dry run cannot construct a native spool command in the browser.
let _ = opts;
super::export::options(p, s.doc()?.doc.page_count())?;
Err(bad("file.print", "printing isn't available on the web: export a PDF and print it"))
}
#[cfg(not(target_arch = "wasm32"))]
{
let copies = p.get("copies").and_then(Value::as_u64).unwrap_or(1).clamp(1, 999);
let printer = str_param(p, "printer").map(str::to_string);
let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false);
let pdf = designcraft_pdf::export_pdf(d, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
let mut file = secure_print_file()?;
let file_path = file.path().to_path_buf();
let mut cmd: Vec<String> = vec!["lpr".into()];
if let Some(pr) = &printer {
cmd.extend(["-P".into(), pr.clone()]);
}
if copies > 1 {
cmd.extend(["-#".into(), copies.to_string()]);
}
cmd.extend(["-T".into(), d.title.clone(), file_path.to_string_lossy().to_string()]);
let pages = opts.pages.as_ref().map_or(d.page_count(), |v| v.len());
let out = json!({"printer": printer, "copies": copies, "pages": pages, "command": cmd, "bytes": pdf.len()});
if dry {
return Ok(out);
}
file.as_file_mut().write_all(&pdf).map_err(|e| EngineError::Other(format!("{}: {e}", file.path().display())))?;
file.as_file_mut().flush().map_err(|e| EngineError::Other(format!("{}: {e}", file.path().display())))?;
let st = std::process::Command::new(&cmd[0]).args(&cmd[1..]).output().map_err(|e| bad("file.print", format!("lpr: {e}")))?;
if !st.status.success() {
return Err(bad("file.print", String::from_utf8_lossy(&st.stderr).trim().to_string()));
}
Ok(out)
}
print_with(s, p, craft_print::submit)
}
#[cfg(not(target_arch = "wasm32"))]
fn secure_print_file() -> Result<tempfile::NamedTempFile> {
let file = tempfile::Builder::new()
.prefix("designcraft-print-")
.suffix(".pdf")
.tempfile()
.map_err(|e| EngineError::Other(format!("temporary print file: {e}")))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
file.as_file()
.set_permissions(std::fs::Permissions::from_mode(0o600))
.map_err(|e| EngineError::Other(format!("{}: {e}", file.path().display())))?;
fn print_with(
s: &mut Session,
p: &Value,
submit: impl FnOnce(&[u8], &craft_print::Job) -> std::result::Result<String, craft_print::Error>,
) -> Result<Value> {
let d = &s.doc()?.doc;
let opts = super::export::options(p, d.page_count())?;
let copies = p.get("copies").and_then(Value::as_u64).unwrap_or(1).clamp(1, 999) as u32;
let job = craft_print::Job {
printer: str_param(p, "printer").map(str::to_string),
copies,
scaling: craft_print::Scaling::ShrinkToFit,
title: d.title.clone(),
..craft_print::Job::default()
};
let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false);
let pdf = designcraft_pdf::export_pdf(d, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
let command = craft_print::command_preview(&job).map_err(|e| bad("file.print", e.to_string()))?;
let pages = opts.pages.as_ref().map_or(d.page_count(), |v| v.len());
let out = json!({"printer": job.printer, "copies": copies, "pages": pages, "command": command, "bytes": pdf.len()});
if !dry {
submit(&pdf, &job).map_err(|e| bad("file.print", e.to_string()))?;
}
Ok(file)
Ok(out)
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use serde_json::json;
use crate::Session;
use super::*;
#[test]
fn print_builds_the_spool_command() {
@ -112,37 +78,45 @@ mod tests {
s.execute("file.new", &json!({"pages": 4})).unwrap();
let r = s.execute("file.print", &json!({"printer": "Office", "copies": 2, "pages": "2-3", "dryRun": true})).unwrap();
assert_eq!(r["pages"], 2);
let cmd: Vec<&str> = r["command"].as_array().unwrap().iter().filter_map(|v| v.as_str()).collect();
assert_eq!(&cmd[..5], ["lpr", "-P", "Office", "-#", "2"]);
assert!(cmd.last().unwrap().ends_with(".pdf"));
assert!(!std::path::Path::new(cmd.last().unwrap()).exists());
let command: Vec<&str> = r["command"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
if craft_print::backend() == Some(craft_print::Backend::Cups) {
assert_eq!(&command[..5], ["lp", "-d", "Office", "-n", "2"]);
assert!(command.contains(&"print-scaling=auto-fit"));
assert!(!command.iter().any(|a| a.starts_with("sides=")));
} else {
assert_eq!(command[0], "powershell");
}
assert!(r["bytes"].as_u64().unwrap() > 500);
assert!(s.execute("file.print", &json!({"pages": "9", "dryRun": true})).is_err());
assert!(s.execute("file.printers", &json!({})).unwrap()["printers"].is_array());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn print_uses_distinct_ephemeral_files() {
let first = super::secure_print_file().unwrap();
let second = super::secure_print_file().unwrap();
let first_path = first.path().to_path_buf();
let second_path = second.path().to_path_buf();
assert_ne!(first_path, second_path);
drop((first, second));
assert!(!first_path.exists());
assert!(!second_path.exists());
}
#[cfg(all(not(target_arch = "wasm32"), unix))]
#[test]
fn print_file_is_private_and_removed_on_drop() {
use std::os::unix::fs::PermissionsExt;
let file = super::secure_print_file().unwrap();
let path = file.path().to_path_buf();
assert_eq!(file.as_file().metadata().unwrap().permissions().mode() & 0o777, 0o600);
drop(file);
assert!(!path.exists());
fn print_submits_the_selected_export_and_reports_refusal() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 4})).unwrap();
let params = json!({"printer": "Office (east)", "copies": 2, "pages": "2-3", "marks": true, "bleed": true});
let expected = {
let d = &s.doc().unwrap().doc;
let options = super::super::export::options(&params, d.page_count()).unwrap();
designcraft_pdf::export_pdf(d, &s.cache, &options).unwrap()
};
let r = print_with(&mut s, &params, |pdf, job| {
// The production export options and full bytes reach the shared spooler boundary.
assert_eq!(pdf, expected);
assert!(pdf.starts_with(b"%PDF-"));
assert_eq!(job.printer.as_deref(), Some("Office (east)"));
assert_eq!(job.copies, 2);
assert_eq!(job.duplex, None);
assert_eq!(job.scaling, craft_print::Scaling::ShrinkToFit);
assert!(!job.title.is_empty());
Ok("request id is Office-1".into())
})
.unwrap();
assert_eq!(r["pages"], 2);
let refusal = print_with(&mut s, &params, |_, _| Err(craft_print::Error::Spool("printer offline".into()))).unwrap_err();
assert!(refusal.to_string().contains("printer offline"));
print_with(&mut s, &json!({"dryRun": true}), |_, _| panic!("dry runs must not spool")).unwrap();
assert!(print_with(&mut s, &json!({"pages": "9"}), |_, _| panic!("invalid ranges must not spool")).is_err());
}
}

View File

@ -58,7 +58,7 @@ fn qr_code(s: &mut Session, p: &Value) -> Result<Value> {
let text = encode(p).map_err(|e| bad(ID, e))?;
let color = str_param(p, "color").unwrap_or(designcraft_color::swatch::BLACK).to_string();
let rect = rect_param(p, "rect");
let sr = spread_param(p, "spread");
let sr = spread_param(p, ID)?;
let st = s.doc()?;
let lid = st.active_layer;
let sel = st.selection.items.clone();

View File

@ -105,7 +105,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Character Style…",
[],
None,
"{name, basedOn?: style | \"[None]\", chars?: {…}} — the style sets only `chars` → {name} (made unique)",
"{name, basedOn?: style | \"[None]\", chars?: {…}, fromSelection?: bool (default false)} — if true, capture the first selected character's style/local overrides (caret: typing format), then overlay chars (null unsets); only explicit fill names are validated; paragraph formatting stays inherited; no text target: chars only → {name} (made unique)",
has_doc,
create_char
),
@ -180,10 +180,19 @@ pub fn specs() -> Vec<CommandSpec> {
"Apply Color",
[],
None,
"{color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255), target?: fill|stroke, ids?} — an unnamed colour (not in the Swatches panel until Add to Swatches) on the selection",
super::has_selection,
"{color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255), target?: fill|stroke, text?: bool, ids?} — an unnamed colour (not in the Swatches panel until Add to Swatches) on the selection; `text`: on the selected text, or the text of the selected frames (Formatting Affects Text)",
has_text_or_selection,
apply_color
),
cmd!(
"color.sample",
"Sample Color (Eyedropper)",
[],
None,
"{at: [x,y] (spread coords), spread?, target?: fill|stroke, ids?} → {color: \"#rrggbb\", swatch, from: image id} — the Eyedropper on a placed image: the image's colour at the point (an unnamed colour) fills the selected text, else the selected objects",
has_text_or_selection,
sample_color
),
cmd!(
"swatch.addToSwatches",
"Add to Swatches",
@ -444,6 +453,7 @@ fn create_para(s: &mut Session, p: &Value) -> Result<Value> {
chars = full;
}
}
super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.create")?;
let based_on = str_param(p, "basedOn").map(str::to_string);
let next = str_param(p, "nextStyle").map(str::to_string);
s.edit(|d, _| {
@ -470,6 +480,7 @@ fn CharProps_to_attrs(p: &designcraft_doc::CharProps) -> CharAttrs {
fn edit_para(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("style.paragraph.edit", "missing name"))?.to_string();
let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.edit")?;
let rename = str_param(p, "rename").map(str::to_string);
let based = p.get("basedOn").cloned();
s.edit(|d, _| {
@ -620,7 +631,26 @@ fn delete_para(s: &mut Session, p: &Value) -> Result<Value> {
fn create_char(s: &mut Session, p: &Value) -> Result<Value> {
let base = str_param(p, "name").unwrap_or("Character Style 1").to_string();
let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
let mut chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
// Validate caller-supplied fills; captured formatting may retain a deleted swatch name.
super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.character.create")?;
if p.get("fromSelection").and_then(Value::as_bool).unwrap_or(false)
&& let Some(target) = super::text::format_targets_pub(s).first()
{
let st = s.doc()?;
// Match paragraph fromSelection's first-character/caret policy, but keep paragraph
// attributes inherited instead of freezing the fully resolved character properties.
let format = super::text::effective_char_format(s, target)?;
let mut captured = st.doc.styles.char_style_attrs(&format.style);
captured.merge(&format.over);
// Replay the explicit fields so null clears a captured value rather than looking absent.
if let Some(overrides) = p.get("chars").and_then(Value::as_object) {
for (key, value) in overrides {
captured.set_json(key, value).map_err(|e| bad("style", e))?;
}
}
chars = captured;
}
// Based on [None] is based on nothing.
let based_on = str_param(p, "basedOn").filter(|b| *b != designcraft_doc::NO_CHAR_STYLE).map(str::to_string);
s.edit(|d, _| {
@ -641,6 +671,7 @@ fn edit_char(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad("style.character.edit", "[None] can't be edited"));
}
let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.character.edit")?;
// `null` unsets an attribute (the style no longer sets it).
let unset: Vec<String> = p
.get("chars")
@ -716,13 +747,18 @@ pub fn parse_color(v: &Value) -> Option<designcraft_color::Color> {
use designcraft_color::Color;
match v {
Value::String(s) => Color::from_hex(s),
// One scale per colour, not per component: {c: 0.7, y: 12.2} is C 0.7 %, Y 12.2 %. Only a colour whose
// components are all within 0..1 is read as fractions (the older form).
Value::Array(a) if a.len() >= 3 => {
let f = |i: usize| a[i].as_f64().map(|x| if x > 1.0 { x / 255.0 } else { x } as f32);
Some(Color::rgb(f(0)?, f(1)?, f(2)?))
let v = [a[0].as_f64()?, a[1].as_f64()?, a[2].as_f64()?];
let scale = if v.iter().any(|&x| x > 1.0) { 255.0 } else { 1.0 };
Some(Color::rgb((v[0] / scale) as f32, (v[1] / scale) as f32, (v[2] / scale) as f32))
}
Value::Object(o) => {
let g = |k: &str| o.get(k).and_then(Value::as_f64).map(|x| if x > 1.0 { x / 100.0 } else { x } as f32);
Some(Color::cmyk(g("c")?, g("m")?, g("y")?, g("k")?))
let g = |k: &str| o.get(k).and_then(Value::as_f64);
let v = [g("c")?, g("m")?, g("y")?, g("k")?];
let scale = if v.iter().any(|&x| x > 1.0) { 100.0 } else { 1.0 };
Some(Color::cmyk((v[0] / scale) as f32, (v[1] / scale) as f32, (v[2] / scale) as f32, (v[3] / scale) as f32))
}
_ => None,
}
@ -835,7 +871,11 @@ fn save_swatches(s: &mut Session, p: &Value) -> Result<Value> {
let bytes = designcraft_color::ase::write(&chosen);
match str_param(p, "path") {
Some(path) => {
std::fs::write(path, &bytes).map_err(|e| bad("swatch.save", format!("{path}: {e}")))?;
#[cfg(not(target_arch = "wasm32"))]
let written = crate::atomic::write_atomic(std::path::Path::new(path), &bytes);
#[cfg(target_arch = "wasm32")]
let written = std::fs::write(path, &bytes);
written.map_err(|e| bad("swatch.save", format!("{path}: {e}")))?;
Ok(json!({"path": path, "bytes": bytes.len()}))
}
None => Ok(json!({"base64": super::file::base64_encode(&bytes)})),
@ -942,41 +982,146 @@ fn apply_gradient(s: &mut Session, p: &Value) -> Result<Value> {
fn apply_color(s: &mut Session, p: &Value) -> Result<Value> {
let color = p.get("color").and_then(parse_color).ok_or_else(|| bad("object.color", "missing or bad color"))?;
let stroke = str_param(p, "target") == Some("stroke");
let ids = super::targets(s, p)?;
s.edit(|d, _| {
// Reuse a swatch holding exactly this colour, else add an unnamed one.
let existing = d
.swatches
.iter()
.find(|w| matches!(&w.value, SwatchValue::Color { color: c, color_type: designcraft_color::ColorType::Process } if *c == color))
.map(|w| w.name.clone());
let name = match existing {
Some(n) => n,
None => {
let name = Styles::unique_name(|n| d.swatch(n).is_some(), &value_name(color));
d.swatches.push(Swatch {
name: name.clone(),
value: SwatchValue::Color { color, color_type: designcraft_color::ColorType::Process },
locked: false,
named: false,
hidden: true,
});
name
}
};
for id in &ids {
if let Some(it) = d.item_mut(*id) {
if stroke {
it.stroke.swatch = name.clone();
it.stroke.tint = 1.0;
} else {
it.fill.swatch = name.clone();
it.fill.tint = 1.0;
}
}
let named = p.get("ids").is_some() || p.get("id").is_some();
let text = !named
&& (p.get("text").and_then(Value::as_bool).unwrap_or(false) || s.doc()?.selection.text.is_some() || s.doc()?.selection.cells.is_some());
let (ids, texts) = if text { (vec![], super::text::format_targets_pub(s)) } else { (super::targets(s, p)?, vec![]) };
if text && texts.is_empty() {
return Err(bad("object.color", "select the text to colour"));
}
// A caret: the colour is for the next typing (once the swatch exists).
let caret = matches!(texts.as_slice(), [t] if t.range.is_empty());
let r = s.edit(|d, _| {
let name = color_swatch(d, color);
color_items(d, &ids, &name, stroke);
if !caret {
let a = if stroke {
CharAttrs { stroke: Some(name.clone()), stroke_tint: Some(1.0), ..Default::default() }
} else {
CharAttrs { fill: Some(name.clone()), fill_tint: Some(1.0), ..Default::default() }
};
super::text::apply_char_attrs(d, &texts, &a, &[]);
}
Ok(json!({"swatch": name}))
})
})?;
if caret && let Some(name) = r.get("swatch").cloned() {
let attrs = if stroke { json!({"stroke": name, "strokeTint": 1.0}) } else { json!({"fill": name, "fillTint": 1.0}) };
super::text::format_chars(s, &attrs)?;
}
Ok(r)
}
/// A process swatch holding exactly `color`, else a new unnamed one (hidden from the Swatches
/// panel until Add to Swatches).
fn color_swatch(d: &mut designcraft_doc::Document, color: designcraft_color::Color) -> String {
let existing = d
.swatches
.iter()
.find(|w| matches!(&w.value, SwatchValue::Color { color: c, color_type: designcraft_color::ColorType::Process } if *c == color))
.map(|w| w.name.clone());
if let Some(n) = existing {
return n;
}
let name = Styles::unique_name(|n| d.swatch(n).is_some(), &value_name(color));
d.swatches.push(Swatch {
name: name.clone(),
value: SwatchValue::Color { color, color_type: designcraft_color::ColorType::Process },
locked: false,
named: false,
hidden: true,
});
name
}
fn color_items(d: &mut designcraft_doc::Document, ids: &[designcraft_doc::ItemId], name: &str, stroke: bool) {
for id in ids {
if let Some(it) = d.item_mut(*id) {
if stroke {
it.stroke.swatch = name.to_string();
it.stroke.tint = 1.0;
} else {
it.fill.swatch = name.to_string();
it.fill.tint = 1.0;
}
}
}
}
fn has_text_or_selection(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if s.active().is_some_and(|d| d.selection.text.is_some() || d.selection.cells.is_some()) { Ok(()) } else { super::has_selection(s) }
}
/// The Eyedropper on a placed image (InDesign samples the pixel under the pointer): that colour,
/// as an unnamed colour, fills the selected text (characters), or the selected objects.
fn sample_color(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "color.sample";
let at = super::point_param(p, "at").ok_or_else(|| bad(ID, "missing at"))?;
let sr = super::spread_param(p, "spread")?;
let st = s.doc()?;
let (from, color) = image_color_at(&st.doc, sr, at).ok_or_else(|| bad(ID, "no placed image under the point"))?;
let hex = color.to_hex();
let mut params = p.clone();
params["color"] = json!(hex);
let mut result = apply_color(s, &params)?;
result["color"] = json!(hex);
result["from"] = json!(from.0);
Ok(result)
}
/// The colour of the placed raster image at `at` on spread `sr`: the frontmost visible image whose
/// frame shows that point (locked ones too, as sampling changes nothing). (image frame, colour);
/// None over no image, a transparent pixel, or a graphic that is not a raster (PDF, EPS).
pub(crate) fn image_color_at(
d: &designcraft_doc::Document,
sr: designcraft_doc::SpreadRef,
at: designcraft_geom::Point,
) -> Option<(designcraft_doc::ItemId, designcraft_color::Color)> {
let sp = d.spread(sr)?;
let mut items: Vec<&designcraft_doc::Item> = sp.items.iter().map(|i| &**i).collect();
items.sort_by_key(|i| d.layer_rank(i.layer));
items
.into_iter()
.rev()
.filter(|it| d.layer(it.layer).is_none_or(|l| l.visible))
.find_map(|it| sample_item(d, it, designcraft_geom::Affine::IDENTITY, at, 0))
}
fn sample_item(
d: &designcraft_doc::Document,
it: &designcraft_doc::Item,
outer: designcraft_geom::Affine,
at: designcraft_geom::Point,
depth: usize,
) -> Option<(designcraft_doc::ItemId, designcraft_color::Color)> {
use designcraft_doc::Content;
if it.hidden || depth > 64 {
return None;
}
let xf = outer * it.xf;
match &it.content {
Content::Group { items } => items.iter().rev().find_map(|c| sample_item(d, c, xf, at, depth + 1)),
Content::Graphic(g) => {
let inner = xf.inverse() * at;
if !designcraft_geom::hit::fill_contains(&it.path.to_bezpath(), designcraft_geom::FillRule::NonZero, inner) {
return None;
}
let gp = g.xf.inverse() * inner;
let (w, h) = g.size;
// Written so that NaN (a degenerate transform) is outside too.
if !(w > 0.0 && h > 0.0 && gp.x >= 0.0 && gp.y >= 0.0 && gp.x < w && gp.y < h) {
return None;
}
let asset = d.assets.get(&g.asset).filter(|a| a.is_raster())?;
let img = designcraft_images::decode_rgba(&asset.data)?;
let (pw, ph) = img.dimensions();
let px = ((gp.x / w * f64::from(pw)) as u32).min(pw.checked_sub(1)?);
let py = ((gp.y / h * f64::from(ph)) as u32).min(ph.checked_sub(1)?);
let [r, gr, b, a] = img.get_pixel_checked(px, py)?.0;
(a > 0).then(|| (it.id, designcraft_color::Color::rgb8(r, gr, b)))
}
_ => None,
}
}
fn add_to_swatches(s: &mut Session, p: &Value) -> Result<Value> {
@ -990,7 +1135,7 @@ fn add_to_swatches(s: &mut Session, p: &Value) -> Result<Value> {
}
};
let rename = str_param(p, "name").map(str::to_string);
s.edit(|d, _| {
let result = s.edit(|d, _| {
let w = d.swatches.iter_mut().find(|w| w.name == name).ok_or_else(|| bad("swatch.addToSwatches", format!("no swatch `{name}`")))?;
if w.locked {
return Err(bad("swatch.addToSwatches", "special swatches are already listed"));
@ -1017,8 +1162,84 @@ fn add_to_swatches(s: &mut Session, p: &Value) -> Result<Value> {
rename_in(std::sync::Arc::make_mut(top), &name, &new);
}
}
for st in d.stories.values_mut() {
let mut copy = (**st).clone();
if rename_in_story(&mut copy, &name, &new, 0)? {
*st = std::sync::Arc::new(copy);
}
}
Ok(json!({"name": new}))
})
})?;
if let Some(new) = result.get("name").and_then(Value::as_str) {
super::text::rename_typing_swatch(s, &name, new);
}
Ok(result)
}
/// Point text colours (character fill, stroke, underline and strikethrough; paragraph shading,
/// border and rules; table cells, borders and alternating fills) at a renamed swatch, in a story
/// and the texts inside it (table cells, footnotes, endnotes). Returns whether anything changed.
fn rename_in_story(st: &mut designcraft_doc::Story, from: &str, to: &str, depth: usize) -> Result<bool> {
// Refuse the whole edit instead of leaving deeper references pointing at a removed name.
if depth > 32 {
return Err(bad("swatch.addToSwatches", "story nesting exceeds 32 levels"));
}
fn fix(s: &mut String, from: &str, to: &str) -> bool {
let hit = s == from;
if hit {
*s = to.to_string();
}
hit
}
let mut n = 0;
let mut f = |s: &mut String| n += usize::from(fix(s, from, to));
for r in &mut st.chars {
let o = &mut r.format.over;
for c in [&mut o.fill, &mut o.stroke, &mut o.underline_color, &mut o.strikethrough_color].into_iter().flatten() {
f(c);
}
}
for pf in &mut st.paras {
let a = &mut pf.para;
for c in [&mut a.shading_color, &mut a.border_color].into_iter().flatten() {
f(c);
}
for r in [&mut a.rule_above, &mut a.rule_below].into_iter().flatten() {
f(&mut r.color);
}
}
let mut nested = false;
for t in st.tables.values_mut() {
let t = std::sync::Arc::make_mut(t);
let o = &mut t.options;
f(&mut o.border.color);
for b in o.borders.iter_mut().flatten() {
f(&mut b.color);
}
for a in [&mut o.alt_rows, &mut o.alt_cols].into_iter().flatten() {
f(&mut a.first_color);
f(&mut a.next_color);
}
for k in [&mut o.row_strokes, &mut o.column_strokes].into_iter().flatten() {
f(&mut k.first_stroke.color);
f(&mut k.next_stroke.color);
}
for c in &mut t.cells {
f(&mut c.fill);
for k in &mut c.strokes {
f(&mut k.color);
}
nested |= rename_in_story(&mut c.text, from, to, depth + 1)?;
}
}
for note in st.notes.iter_mut().chain(st.endnotes.iter_mut()) {
nested |= rename_in_story(&mut std::sync::Arc::make_mut(note).text, from, to, depth + 1)?;
}
let changed = n > 0 || nested;
if changed {
st.rev = st.rev.wrapping_add(1);
}
Ok(changed)
}
/// Point fills and strokes of an item (and its children) at a renamed swatch.
@ -1040,6 +1261,168 @@ fn rename_in(it: &mut designcraft_doc::Item, from: &str, to: &str) {
mod color_tests {
use super::*;
fn fill_session() -> Session {
let mut session = Session::new();
session.execute("file.new", &json!({})).unwrap();
let frame = session.execute("frame.create", &json!({"rect": [0, 0, 200, 100], "content": "text", "text": "Hi"})).unwrap();
session.execute("text.select", &json!({"story": frame["story"], "anchor": 0, "focus": 2})).unwrap();
session.execute("style.paragraph.create", &json!({"name": "Existing"})).unwrap();
session.execute("style.character.create", &json!({"name": "Existing"})).unwrap();
session.execute("type.char", &json!({"size": 18})).unwrap();
session.execute("edit.undo", &json!({})).unwrap();
session
}
fn assert_fill_rejected(session: &mut Session, command: &str, params: Value) {
let before = session.doc().unwrap().clone();
let journal = session.journal.clone();
let fill = params
.pointer("/chars/fill")
.or_else(|| params.pointer("/attrs/fill"))
.or_else(|| params.get("fill"))
.and_then(Value::as_str)
.unwrap_or("Missing Ink");
let result = session.execute(command, &params);
match result {
Err(crate::EngineError::BadParams { cmd, msg }) => {
assert_eq!(cmd, command);
assert!(msg.contains(fill), "{msg}");
assert!(msg.contains("swatch.create") && msg.contains("returned name"), "{msg}");
}
other => panic!("expected BadParams for {command}, got {other:?}"),
}
let after = session.doc().unwrap();
assert!(std::sync::Arc::ptr_eq(&before.doc, &after.doc));
assert_eq!(before.selection, after.selection);
assert_eq!(before.revision, after.revision);
assert_eq!(format!("{:?}", before.history), format!("{:?}", after.history));
assert_eq!(journal, session.journal);
}
#[test]
fn char_fill_rejects_unknown_flat_and_nested_names_when_formatting() {
for fill in ["Missing Ink", "#ff0000", "[black]", " [Black]"] {
for params in [json!({"fill": fill, "size": 24}), json!({"attrs": {"fill": fill, "size": 24}})] {
let mut session = fill_session();
assert_fill_rejected(&mut session, "type.char", params);
}
}
}
#[test]
fn char_fill_rejects_unknown_names_when_creating_or_editing_styles() {
for command in ["style.paragraph.create", "style.paragraph.edit", "style.character.create", "style.character.edit"] {
for fill in ["Missing Ink", "#ff0000"] {
let mut session = fill_session();
session.execute("style.paragraph.apply", &json!({"name": "Existing"})).unwrap();
session.execute("style.character.apply", &json!({"name": "Existing"})).unwrap();
assert_fill_rejected(&mut session, command, json!({"name": "Existing", "rename": "Renamed", "chars": {"fill": fill, "size": 24}}));
}
}
}
#[test]
fn char_fill_rejects_inherited_unknown_name_when_creating_from_selection() {
let mut session = fill_session();
session
.edit(|document, selection| {
let selected = selection.text.unwrap();
document.story_mut(selected.story).unwrap().format_chars(selected.range(), |format| format.over.fill = Some("Missing Ink".into()));
Ok(())
})
.unwrap();
assert_fill_rejected(&mut session, "style.paragraph.create", json!({"name": "Inherited", "fromSelection": true}));
}
#[test]
fn char_fill_accepts_valid_override_when_selection_has_unknown_name() {
let mut session = fill_session();
session
.edit(|document, selection| {
let selected = selection.text.unwrap();
document.story_mut(selected.story).unwrap().format_chars(selected.range(), |format| format.over.fill = Some("Missing Ink".into()));
Ok(())
})
.unwrap();
session.execute("style.paragraph.create", &json!({"name": "Override", "fromSelection": true, "chars": {"fill": "[Paper]"}})).unwrap();
assert_eq!(session.doc().unwrap().doc.styles.para("Override").unwrap().chars.fill.as_deref(), Some("[Paper]"));
}
#[test]
fn char_fill_rejects_unknown_name_when_changing_text_even_without_output() {
for (find, change) in [("Hi", "Bye"), ("Hi", ""), ("Absent", "Bye")] {
for fill in ["Missing Ink", "#ff0000"] {
let mut session = fill_session();
assert_fill_rejected(&mut session, "find.change", json!({"find": find, "change": change, "attrs": {"fill": fill}}));
}
}
}
#[test]
fn char_fill_preserves_exact_registered_names_when_used_at_all_boundaries() {
for fill in ["Brand Ink", "#ff0000", "[None]", "[Black]"] {
let mut session = fill_session();
if !fill.starts_with('[') {
let created = session.execute("swatch.create", &json!({"name": fill, "color": "#108080"})).unwrap();
assert_eq!(created["name"], fill);
}
for params in [json!({"fill": fill}), json!({"attrs": {"fill": fill}})] {
session.execute("type.char", &params).unwrap();
assert_eq!(session.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], fill);
}
for kind in ["paragraph", "character"] {
session.execute(&format!("style.{kind}.create"), &json!({"name": "Colored", "chars": {"fill": fill}})).unwrap();
session.execute(&format!("style.{kind}.edit"), &json!({"name": "Existing", "chars": {"fill": fill}})).unwrap();
let document = &session.doc().unwrap().doc;
for name in ["Colored", "Existing"] {
let attrs = if kind == "paragraph" {
&document.styles.para(name).unwrap().chars
} else {
&document.styles.char_style(name).unwrap().chars
};
assert_eq!(attrs.fill.as_deref(), Some(fill));
}
}
assert_eq!(session.execute("find.change", &json!({"find": "Hi", "change": "Bye", "attrs": {"fill": fill}})).unwrap()["count"], 1);
let selected = session.doc().unwrap().selection.text.unwrap();
let story = session.doc().unwrap().doc.story(selected.story).unwrap();
assert_eq!(story.text, "Bye");
assert_eq!(story.format_after(0).over.fill.as_deref(), Some(fill));
}
}
#[test]
fn char_fill_accepts_omitted_and_null_attributes_when_used_at_all_boundaries() {
for chars in [json!({"size": 18}), json!({"fill": null})] {
let mut session = fill_session();
session.execute("type.char", &json!({"attrs": chars})).unwrap();
for kind in ["paragraph", "character"] {
session.execute(&format!("style.{kind}.create"), &json!({"name": "Nullable", "chars": chars})).unwrap();
session.execute(&format!("style.{kind}.edit"), &json!({"name": "Existing", "chars": chars})).unwrap();
}
assert_eq!(session.execute("find.change", &json!({"find": "Hi", "change": "Bye", "attrs": chars})).unwrap()["count"], 1);
}
}
#[test]
fn percent_components_at_most_one_stay_percent() {
use designcraft_color::Color;
// {"c": 0.7, ..., "y": 12.2} is a pale cream (C 0.7 %, Y 12.2 %), not C 70 %.
let Some(Color::Cmyk { c, m, y, k }) = parse_color(&json!({"c": 0.7, "m": 0, "y": 12.2, "k": 0})) else { panic!("not CMYK") };
assert!((c - 0.007).abs() < 1e-6 && m == 0.0 && (y - 0.122).abs() < 1e-6 && k == 0.0, "got {c} {m} {y} {k}");
// Fractions (every component within 0..1) keep working.
let Some(Color::Cmyk { c, k, .. }) = parse_color(&json!({"c": 0.5, "m": 0, "y": 0, "k": 1})) else { panic!("not CMYK") };
assert!((c - 0.5).abs() < 1e-6 && (k - 1.0).abs() < 1e-6);
// RGB arrays follow the same rule: [1, 128, 255] is 0..255.
let Some(Color::Rgb { r, g, b }) = parse_color(&json!([1, 128, 255])) else { panic!("not RGB") };
assert!((r - 1.0 / 255.0).abs() < 1e-6 && (g - 128.0 / 255.0).abs() < 1e-6 && (b - 1.0).abs() < 1e-6);
}
#[test]
fn style_groups_rename_every_use() {
let mut s = Session::new();
@ -1170,6 +1553,40 @@ mod color_tests {
s.execute("object.color", &json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}})).unwrap();
assert_eq!(s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch, "Sand");
}
#[test]
fn add_to_swatches_renames_the_colour_in_text_cells_and_notes() {
use std::sync::Arc;
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": "Hello world"})).unwrap();
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
s.execute("text.select", &json!({"story": sid.0, "anchor": 11})).unwrap();
s.execute("footnote.insert", &json!({"text": "note"})).unwrap();
s.execute("text.select", &json!({"story": sid.0, "anchor": 0})).unwrap();
s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap();
s.execute("text.select", &json!({"story": sid.0, "anchor": 1, "focus": 4})).unwrap();
let old = s.execute("object.color", &json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}})).unwrap()["swatch"].as_str().unwrap().to_string();
// The same unnamed colour on a cell, the cell's text and the footnote's text.
s.edit(|d, _| {
let st = d.story_mut(sid).unwrap();
let t = Arc::make_mut(st.tables.values_mut().next().unwrap());
t.cells[0].fill = old.clone();
t.cells[0].text.chars[0].format.over.fill = Some(old.clone());
Arc::make_mut(&mut st.notes[0]).text.chars[0].format.over.stroke = Some(old.clone());
Ok(())
})
.unwrap();
s.execute("swatch.addToSwatches", &json!({"swatch": old, "name": "Sand"})).unwrap();
let d = &s.doc().unwrap().doc;
let st = d.story(sid).unwrap();
assert_eq!(st.char_format_at(2).over.fill.as_deref(), Some("Sand"));
let cell = &st.tables.values().next().unwrap().cells[0];
assert_eq!(cell.fill, "Sand");
assert_eq!(cell.text.chars[0].format.over.fill.as_deref(), Some("Sand"));
assert_eq!(st.notes[0].text.chars[0].format.over.stroke.as_deref(), Some("Sand"));
d.check().unwrap();
}
}
#[cfg(test)]
@ -1318,3 +1735,116 @@ mod cjk_tests {
assert!(s.execute("style.compositeFont.set", &definition(-1.0)).is_err());
}
}
#[cfg(test)]
mod sample_color_tests {
use designcraft_color::{Color, SwatchValue};
use designcraft_doc::ItemId;
use serde_json::json;
use crate::Session;
/// A 2×1 image, red then blue, placed 144 pt wide at (72, 72): each pixel is 72 pt.
fn session_with_image() -> (Session, u64) {
let png = designcraft_render::Rendered { width: 2, height: 1, pixels: vec![255, 0, 0, 255, 0, 0, 255, 255] }.to_png();
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let b64 = super::super::file::base64_encode(&png);
let r = s.execute("file.place", &json!({"base64": b64, "name": "rb.png", "x": 72, "y": 72, "width": 144})).unwrap();
(s, r["id"].as_u64().unwrap())
}
fn swatch_color(s: &Session, name: &str) -> Color {
match &s.doc().unwrap().doc.swatch(name).unwrap().value {
SwatchValue::Color { color, .. } => *color,
other => panic!("{other:?}"),
}
}
#[test]
fn the_eyedropper_colours_selected_text_with_the_image_under_it() {
let (mut s, image) = session_with_image();
let f = s.execute("frame.create", &json!({"rect": [300, 300, 500, 360], "content": "text", "text": "Elevate."})).unwrap();
let fid = ItemId(f["id"].as_u64().unwrap());
let story = s.doc().unwrap().doc.item(fid).unwrap().text_frame().unwrap().story;
s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 7})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
let r = s.execute("color.sample", &json!({"at": [180, 100]})).unwrap();
assert_eq!((r["color"].as_str(), r["from"].as_u64()), (Some("#0000ff"), Some(image)), "{r}");
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
let name = r["swatch"].as_str().unwrap().to_string();
assert_eq!(swatch_color(&s, &name), Color::rgb8(0, 0, 255));
assert!(s.doc().unwrap().doc.swatch(&name).unwrap().hidden, "an unnamed colour");
let st = s.doc().unwrap().doc.story(story).unwrap().clone();
assert_eq!(st.format_after(0).over.fill.as_deref(), Some(name.as_str()));
assert_eq!(st.format_after(7).over.fill, None, "the full stop keeps its colour");
// The frame itself is not coloured.
assert!(s.doc().unwrap().doc.item(fid).unwrap().fill.is_none());
}
#[test]
fn the_eyedropper_fills_selected_objects_and_samples_locked_images() {
let (mut s, _) = session_with_image();
let lid = s.doc().unwrap().doc.layers[0].id.0;
s.execute("layer.set", &json!({"id": lid, "locked": true})).unwrap();
s.execute("layer.new", &json!({"name": "TEXT"})).unwrap();
let rect = s.execute("frame.create", &json!({"rect": [300, 300, 400, 400]})).unwrap()["id"].as_u64().unwrap();
s.execute("selection.set", &json!({"ids": [rect]})).unwrap();
let r = s.execute("color.sample", &json!({"at": [100, 100], "target": "stroke"})).unwrap();
assert_eq!(r["color"], "#ff0000", "{r}");
let it = s.doc().unwrap().doc.item(ItemId(rect)).unwrap().clone();
assert_eq!(swatch_color(&s, &it.stroke.swatch), Color::rgb8(255, 0, 0));
// Sampling the same colour again reuses its swatch.
let again = s.execute("color.sample", &json!({"at": [90, 90]})).unwrap();
assert_eq!(again["swatch"], r["swatch"]);
}
#[test]
fn nothing_to_sample_is_refused_without_an_edit() {
let (mut s, _) = session_with_image();
let f = s.execute("frame.create", &json!({"rect": [300, 300, 500, 360], "content": "text", "text": "Hi"})).unwrap();
let fid = ItemId(f["id"].as_u64().unwrap());
let story = s.doc().unwrap().doc.item(fid).unwrap().text_frame().unwrap().story;
s.execute("text.select", &json!({"story": story.0, "anchor": 1, "focus": 1})).unwrap();
let sampled = s.execute("color.sample", &json!({"at": [100, 100]})).unwrap();
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], sampled["swatch"]);
assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "Hi", "caret color waits for typing");
let undo = s.doc().unwrap().history.undo.len();
s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 2})).unwrap();
let none = s.execute("color.sample", &json!({"at": [600, 600]})).unwrap_err().to_string();
assert!(none.contains("no placed image"), "{none}");
// Just past the image's right edge.
assert!(s.execute("color.sample", &json!({"at": [216.5, 100]})).is_err());
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
}
#[test]
fn sampling_and_explicit_colour_use_the_same_text_application_path() {
for command in ["color.sample", "object.color"] {
let (mut s, _) = session_with_image();
let f = s.execute("frame.create", &json!({"rect": [300, 300, 500, 360], "content": "text"})).unwrap();
let story = designcraft_doc::StoryId(f["story"].as_u64().unwrap());
s.execute("text.select", &json!({"story": story.0, "anchor": 0})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
let r = s.execute(command, &json!({"at": [100, 100], "color": "#ff0000"})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{command}");
s.execute("text.insert", &json!({"text": "Hi"})).unwrap();
let text = s.doc().unwrap().doc.story(story).unwrap();
assert_eq!(text.format_after(0).over.fill.as_deref(), r["swatch"].as_str(), "empty-story typing: {command}");
// Explicit ids override text targeting, including with a text selection active.
let fid = ItemId(f["id"].as_u64().unwrap());
s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 2})).unwrap();
let r = s.execute(command, &json!({"at": [180, 100], "color": "#0000ff", "id": fid.0})).unwrap();
assert_eq!(s.doc().unwrap().doc.item(fid).unwrap().fill.swatch, r["swatch"].as_str().unwrap());
let text = s.doc().unwrap().doc.story(story).unwrap();
assert_ne!(text.format_after(0).over.fill.as_deref(), r["swatch"].as_str(), "text unchanged: {command}");
let r = s.execute(command, &json!({"at": [180, 100], "color": "#0000ff", "target": "stroke"})).unwrap();
let text = s.doc().unwrap().doc.story(story).unwrap();
assert_eq!(text.format_after(0).over.stroke.as_deref(), r["swatch"].as_str(), "selected text stroke: {command}");
}
}
}
#[cfg(test)]
#[path = "text_color_tests.rs"]
mod text_color_tests;

View File

@ -178,7 +178,7 @@ fn table_specs() -> Vec<CommandSpec> {
"Split Cell Horizontally",
["Table"],
None,
"{} — the target cell becomes two, one above the other",
"{} — each target cell becomes two, one above the other",
in_table,
|s, p| split(s, p, true)
),
@ -187,7 +187,7 @@ fn table_specs() -> Vec<CommandSpec> {
"Split Cell Vertically",
["Table"],
None,
"{} — the target cell becomes two, side by side",
"{} — each target cell becomes two, side by side",
in_table,
|s, p| split(s, p, false)
),
@ -589,7 +589,7 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> {
fn split(s: &mut Session, p: &Value, horizontal: bool) -> Result<Value> {
let g = target(s, p, "table.split")?;
edit_table(s, &g, "table.split", |t| {
t.split_cell(g.range.r0, g.range.c0, horizontal);
t.split_cells(g.range, horizontal).map_err(|e| bad("table.split", e))?;
Ok(json!({"rows": t.nrows(), "cols": t.ncols()}))
})
}
@ -645,7 +645,7 @@ fn set_cell(s: &mut Session, p: &Value) -> Result<Value> {
Some(o) => return Err(bad("table.setCell", format!("unknown vj `{o}`"))),
None => None,
};
let text = str_param(p, "text").map(str::to_string);
let text = str_param(p, "text").map(|_| super::text_param(p, "text"));
let stroke = p.get("stroke").cloned();
edit_table(s, &g, "table.setCell", |t| {
let owners = t.owners();
@ -834,7 +834,7 @@ fn distribute_rows(s: &mut Session, p: &Value) -> Result<Value> {
}
for key in ["row", "story", "table"] {
if let Some(v) = p.get(key)
&& !v.as_u64().is_some_and(|n| usize::try_from(n).is_ok())
&& v.as_u64().is_none_or(|n| usize::try_from(n).is_err())
{
return Err(bad(ID, format!("{key} must be a non-negative integer")));
}
@ -1402,20 +1402,10 @@ fn place_graphic(s: &mut Session, p: &Value) -> Result<Value> {
let (pw, ph) = designcraft_render::image_size(&bytes).ok_or_else(|| bad(ID, "unsupported or corrupt image"))?;
let fill = p.get("fit").and_then(Value::as_str) == Some("fill");
s.edit(|d, sel| {
let aid = designcraft_doc::AssetId(d.alloc());
let fresh = designcraft_doc::AssetId(d.alloc());
let mime = designcraft_render::image_mime(&bytes).to_string();
d.assets.insert(
aid,
std::sync::Arc::new(designcraft_doc::Asset {
page: 0,
id: aid,
name,
mime,
link,
data: std::sync::Arc::new(bytes),
pixels: Some((pw, ph)),
}),
);
let aid =
d.add_asset(designcraft_doc::Asset { page: 0, id: fresh, name, mime, link, data: std::sync::Arc::new(bytes), pixels: Some((pw, ph)) });
let st = d.story_mut(g.story).ok_or_else(|| bad(ID, "no story"))?;
let t = st.table_mut(g.table).ok_or_else(|| bad(ID, "no table"))?;
let (r, c) = t.owner(g.range.r0, g.range.c0);

View File

@ -22,14 +22,26 @@ pub fn specs() -> Vec<CommandSpec> {
release
),
cmd!(noundo "text.extendTo", "Extend Text Selection", [], None, "{frame, point}", has_doc, |s, p| place(s, p, true)),
cmd!(noundo "text.selectWord", "Select Word", [], None, "{frame, point}", has_doc, |s, p| {
place(s, p, false)?;
let st = s.doc_mut()?;
let Some(t) = st.selection.text else { return ok() };
let text = &st.doc.text_story(t.story, t.cell).map(|x| x.text.clone()).unwrap_or_default();
let (a, b) = word_bounds(text, t.focus);
st.selection.text = Some(TextSel { anchor: a, focus: b, ..t });
ok()
cmd!(noundo "text.selectWord", "Select Word", [], None, "{frame, point} — the word (or run of spaces) under the point", has_doc, |s, p| {
select_at(s, p, "word")
}),
cmd!(
noundo "text.selectAt",
"Select Text Unit",
[],
None,
"{frame, point, unit: word|line|paragraph|story} — what a double, triple, quadruple or quintuple click selects: the word (or run of spaces) under the point, its line, its paragraph with the paragraph return, or the whole story",
has_doc,
|s, p| select_at(s, p, str_param(p, "unit").unwrap_or("word"))
),
cmd!(noundo "text.selectSentence", "Select Sentence", [], None, "{frame, point} — the sentence under the point", has_doc, |s, p| {
select_around(s, p, "text.selectSentence", sentence_bounds)
}),
cmd!(noundo "text.selectParagraph", "Select Paragraph", [], None, "{frame, point} — the paragraph under the point, without its paragraph return", has_doc, |s, p| {
select_around(s, p, "text.selectParagraph", para_bounds)
}),
cmd!(noundo "text.selectStory", "Select Story", [], None, "{frame, point} — all the text of the story under the point, threaded frames included", has_doc, |s, p| {
select_around(s, p, "text.selectStory", |t, _| (0, t.len()))
}),
cmd!(noundo "text.select", "Select Text", [], None, "{story, anchor, focus} (UTF-8 byte offsets into the story text, as find.find reports them: á or — counts 2 or 3)", has_doc, |s, p| {
let sid = StoryId(p.get("story").and_then(Value::as_u64).unwrap_or(0));
@ -39,6 +51,7 @@ pub fn specs() -> Vec<CommandSpec> {
let f = floor_char_boundary(text, p.get("focus").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(a));
st.selection = Selection::text(TextSel { story: sid, anchor: a, focus: f, frame: None, cell: None });
st.revision += 1;
s.typing_format = None;
ok()
}),
cmd!("text.insert", "Type", [], None, "{text, raw?: bool (no typographer's quotes)} — replaces the selected text", has_text, insert),
@ -76,7 +89,7 @@ pub fn specs() -> Vec<CommandSpec> {
),
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 = str_param(p, "text").unwrap_or("").to_string();
let text = super::text_param(p, "text");
s.edit(|d, sel| {
d.set_story_text(sid, &text)?;
if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid) {
@ -97,7 +110,7 @@ pub fn specs() -> Vec<CommandSpec> {
let sid = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("story.replaceRange", "missing story"))?);
let a = p.get("start").and_then(Value::as_u64).unwrap_or(0) as usize;
let b = p.get("end").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(a);
let text = str_param(p, "text").unwrap_or("").to_string();
let text = super::text_param(p, "text");
s.edit(|d, sel| {
let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?;
let (a, b) = (a.min(st.len()), b.min(st.len()).max(a.min(st.len())));
@ -135,7 +148,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Paragraph Formatting",
[],
None,
"{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, hyphenate?, 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?, composer?, 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))
),
@ -152,8 +165,33 @@ pub fn specs() -> Vec<CommandSpec> {
s,
&json!({"align": "leftJustified"})
)),
cmd!("type.bold", "Bold", [], Some("Cmd+Shift+B"), "{}", has_text_or_frames, |s, _| format_chars(s, &json!({"fontStyle": "Bold"}))),
cmd!("type.italic", "Italic", [], Some("Cmd+Shift+I"), "{}", has_text_or_frames, |s, _| format_chars(s, &json!({"fontStyle": "Italic"}))),
cmd!(
"type.bold",
"Bold",
[],
Some("Cmd+Shift+B"),
"{on?: bool} — the family's bold style, keeping italic (style linking); toggles when `on` is left out",
has_text_or_frames,
|s, p| link_style(s, p, true)
),
cmd!(
"type.italic",
"Italic",
[],
Some("Cmd+Shift+I"),
"{on?: bool} — the family's italic style, keeping the weight (style linking); toggles when `on` is left out",
has_text_or_frames,
|s, p| link_style(s, p, false)
),
cmd!(
"type.swapFillStroke",
"Swap Text Fill and Stroke",
[],
None,
"{} — swap each selected character's resolved fill/stroke and tints; at a caret, swap the next typing's colors",
has_text_or_frames,
swap_fill_stroke
),
cmd!("type.underline", "Underline", [], Some("Cmd+Shift+U"), "{}", has_text_or_frames, |s, _| format_chars(s, &json!({"underline": true}))),
cmd!("type.allCaps", "All Caps", [], Some("Cmd+Shift+K"), "{}", has_text_or_frames, |s, _| format_chars(
s,
@ -253,7 +291,7 @@ pub fn specs() -> Vec<CommandSpec> {
has_text_or_frames,
break_link
),
cmd!(query "type.selectionAttrs", "Selection Attributes", [], None, "{} → resolved character/paragraph attributes at the text selection", has_doc, selection_attrs),
cmd!(query "type.selectionAttrs", "Selection Attributes", [], None, "{} → resolved character/paragraph attributes at the text selection, and its start as `selectedText` (one line, up to 60 characters)", has_doc, selection_attrs),
]
}
@ -271,6 +309,17 @@ pub(crate) fn story_of(s: &Session, p: &Value) -> Option<StoryId> {
/// Story byte at spread point `pt` in text frame `frame` (in a table cell's story when the point is in a cell).
fn hit_byte(s: &Session, frame: ItemId, pt: Point) -> Option<(StoryId, usize, Option<CellAddr>)> {
hit_byte_with(s, frame, pt, compose::hit)
}
/// [`hit_byte`] with the hit test: [`compose::hit`] (caret position) or [`compose::hit_char`]
/// (the character under the point).
fn hit_byte_with(
s: &Session,
frame: ItemId,
pt: Point,
hit: fn(&compose::ComposedStory, usize, Point) -> Option<usize>,
) -> Option<(StoryId, usize, Option<CellAddr>)> {
let st = s.active()?;
let loc = st.doc.find(frame)?;
let it = st.doc.item_at(&loc)?;
@ -279,16 +328,94 @@ fn hit_byte(s: &Session, frame: ItemId, pt: Point) -> Option<(StoryId, usize, Op
let inner = xf.inverse() * pt;
let cs = s.cache.get(&st.doc, sid, None);
let fi = cs.frames.iter().position(|f| f.frame == frame)?;
if let Some((table, row, col, b)) = compose::hit_cell(&cs, fi, inner) {
// Map path-text hits along the curve before choosing the character unit.
let inner = cs.frames.get(fi).and_then(|ft| compose::PathTextMap::new(it, ft)).map_or(inner, |m| m.to_line(inner));
if let Some((table, row, col, b)) = compose::hit_cell_with(&cs, fi, inner, hit) {
return Some((sid, b, Some(CellAddr { table, row, col })));
}
if let Some((id, b)) = compose::hit_note(&cs, fi, inner) {
if let Some((id, b)) = compose::hit_note_with(&cs, fi, inner, hit) {
return Some((sid, b, Some(CellAddr::footnote(id))));
}
let b = compose::hit(&cs, fi, inner).unwrap_or(0);
let b = hit(&cs, fi, inner).unwrap_or(0);
Some((sid, b, None))
}
/// Select the word, line, paragraph or story at `point` in `frame` (multiple clicks).
fn select_at(s: &mut Session, p: &Value, unit: &str) -> Result<Value> {
const ID: &str = "text.selectAt";
let frame = id_param(p, "frame").ok_or_else(|| bad(ID, "missing frame"))?;
let pt = point_param(p, "point").ok_or_else(|| bad(ID, "missing point"))?;
let (sid, b, cell) = hit_byte_with(s, frame, pt, compose::hit_char).ok_or_else(|| bad(ID, "not a text frame"))?;
let st = s.doc()?;
let story = st.doc.text_story(sid, cell).ok_or(designcraft_doc::DocError::NoStory(sid))?;
let text = story.text.as_str();
let b = floor_char_boundary(text, b);
let (a, e) = match unit {
"word" => word_or_spaces_at(text, b),
"line" => {
let cs = s.cache.get(&st.doc, sid, None);
line_at(&text_composition(cs, cell), b).unwrap_or((b, b))
}
// The paragraph with its return (the last paragraph has none).
"paragraph" => story.para_ranges().get(story.para_at(b)).map_or((b, b), |r| (r.start, (r.end + 1).min(text.len()))),
"story" => (0, text.len()),
other => return Err(bad(ID, format!("unknown unit `{other}` (word, line, paragraph or story)"))),
};
s.text_drag = false;
s.typing_format = None;
let st = s.doc_mut()?;
st.selection = Selection::text(TextSel { story: sid, anchor: a, focus: e, frame: Some(frame), cell });
st.revision += 1;
Ok(json!({"story": sid.0, "anchor": a, "focus": e}))
}
/// The word at byte `i`; on spaces the run of spaces; on anything else that one character
/// (grapheme cluster). At the end of a paragraph, what ends it. Grapheme clusters are never split:
/// combining marks, viramas, variation selectors and emoji ZWJ sequences stay with their base.
fn word_or_spaces_at(s: &str, i: usize) -> (usize, usize) {
use unicode_segmentation::UnicodeSegmentation;
let i = floor_char_boundary(s, i.min(s.len()));
// Clusters never span a paragraph return, so the paragraph is enough.
let ps = s[..i].rfind('\n').map_or(0, |k| k + 1);
let pe = s[i..].find('\n').map_or(s.len(), |k| i + k);
let g: Vec<(usize, &str)> = s[ps..pe].grapheme_indices(true).collect();
let Some(k) = g.iter().rposition(|(o, _)| ps + o <= i) else { return (i, i) };
let kind = |c: &str| match c.chars().next() {
Some(c) if is_word(c) => 1,
Some(c) if c.is_whitespace() => 2,
_ => 0,
};
let span = |a: usize, e: usize| g.get(a).zip(g.get(e)).map_or((i, i), |((oa, _), (oe, ge))| (ps + oa, ps + oe + ge.len()));
let kd = g.get(k).map_or(0, |(_, c)| kind(c));
if kd == 0 {
return span(k, k);
}
let mut a = k;
while a > 0 && g.get(a - 1).is_some_and(|(_, c)| kind(c) == kd) {
a -= 1;
}
let mut e = k;
while g.get(e + 1).is_some_and(|(_, c)| kind(c) == kd) {
e += 1;
}
span(a, e)
}
/// The composition whose lines hold a story's text: the story's own, a table cell's or a footnote's.
fn text_composition(cs: std::sync::Arc<compose::ComposedStory>, cell: Option<CellAddr>) -> std::sync::Arc<compose::ComposedStory> {
match cell {
Some(c) if c.footnote_id().is_some() => compose::find_note(&cs, c.row as u64).map(|(_, n)| n.text.clone()).unwrap_or(cs),
Some(c) => compose::find_cell(&cs, c.table, c.row, c.col).map(|(_, _, pc)| pc.text.clone()).unwrap_or(cs),
None => cs,
}
}
/// The composed line holding the character at byte `pos`.
fn line_at(cs: &compose::ComposedStory, pos: usize) -> Option<(usize, usize)> {
let lines = || cs.frames.iter().flat_map(|f| f.lines.iter());
lines().find(|l| l.range.contains(&pos)).map(|l| (l.range.start, l.range.end)).or_else(|| line_of(cs, pos))
}
fn place(s: &mut Session, p: &Value, extend: bool) -> Result<Value> {
let frame = id_param(p, "frame").ok_or_else(|| bad("text.placeCaret", "missing frame"))?;
let pt = point_param(p, "point").unwrap_or(Point::ZERO);
@ -307,6 +434,7 @@ fn place(s: &mut Session, p: &Value, extend: bool) -> Result<Value> {
} else if s.text_drag {
return Ok(json!({"story": sid.0, "dragging": true}));
}
s.typing_format = None;
let st = s.doc_mut()?;
// Dragging from one cell into another selects cells.
if extend
@ -374,7 +502,8 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
let text = if s.prefs.typographers_quotes && !raw { smart_quotes(s, &text) } else { text };
let tracking = s.doc()?.doc.settings.track_changes;
let autocorrect = (s.prefs.autocorrect && !tracking && !raw).then(|| s.prefs.autocorrect_list.clone());
s.edit(|d, sel| {
let typing = typing_format(s).cloned();
let result = s.edit(|d, sel| {
let t = sel.text.ok_or_else(|| bad("text.insert", "no insertion point"))?;
let st = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
let r = t.range();
@ -434,6 +563,9 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
// Track Changes: the replaced text is marked deleted, the typing inserted after it.
let at = super::changes::mark_deleted(st, r.clone());
let mut fmt = st.char_format_at(at).clone();
if let Some(f) = &typing {
apply_typing_format(&mut fmt, f);
}
fmt.over.change = Some(designcraft_doc::ChangeMark::Inserted);
st.insert_with(at, &text, fmt);
let pos = at + text.len() - trail;
@ -441,10 +573,19 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
return Ok(json!({"pos": pos}));
}
st.replace(r.clone(), &text);
if let Some(f) = &typing
&& !text.is_empty()
{
st.format_chars(r.start..r.start + text.len(), |fmt| apply_typing_format(fmt, f));
}
let pos = r.start + text.len() - trail;
sel.text = Some(TextSel { anchor: pos, focus: pos, ..t });
Ok(json!({"pos": pos}))
})
});
if result.is_ok() {
s.typing_format = None;
}
result
}
fn smart_quotes(s: &Session, text: &str) -> String {
@ -621,12 +762,62 @@ pub fn prev_word(s: &str, i: usize) -> usize {
}
0
}
fn word_bounds(s: &str, i: usize) -> (usize, usize) {
let a = s[..i.min(s.len())].char_indices().rev().take_while(|(_, c)| is_word(*c)).last().map(|(k, _)| k).unwrap_or(i);
let b = s[i.min(s.len())..].char_indices().find(|(_, c)| !is_word(*c)).map(|(k, _)| i + k).unwrap_or(s.len());
/// Selects `bounds(text, byte)` around the byte under `point` in `frame`. Unlike a caret
/// placement, a press inside the current selection is no start of a text drag here: it is the
/// third or later click of a row, which lands in the word or sentence just selected.
fn select_around(s: &mut Session, p: &Value, id: &str, bounds: fn(&str, usize) -> (usize, usize)) -> Result<Value> {
let frame = id_param(p, "frame").ok_or_else(|| bad(id, "missing frame"))?;
let pt = point_param(p, "point").unwrap_or(Point::ZERO);
let (sid, b, cell) = hit_byte(s, frame, pt).ok_or_else(|| bad(id, "not a text frame"))?;
let st = s.doc_mut()?;
let text = st.doc.text_story(sid, cell).map(|x| x.text.clone()).unwrap_or_default();
let (anchor, focus) = bounds(&text, floor_char_boundary(&text, b));
st.selection = Selection::text(TextSel { story: sid, anchor, focus, frame: Some(frame), cell });
st.revision += 1;
Ok(json!({"story": sid.0, "anchor": anchor, "focus": focus}))
}
/// The paragraph around byte `i`, without its `\n`.
fn para_bounds(s: &str, i: usize) -> (usize, usize) {
let i = i.min(s.len());
let a = s.get(..i).and_then(|t| t.rfind('\n')).map_or(0, |k| k + 1);
let b = s.get(i..).and_then(|t| t.find('\n')).map_or(s.len(), |k| i + k);
(a, b)
}
/// The sentence around byte `i`, within its paragraph: it ends after `.`, `!`, `?` or `…` (and
/// any closing quotes or brackets) followed by a space or the paragraph end, or after a CJK full
/// stop, and takes the spaces after it, as deleting a sentence should leave none behind.
fn sentence_bounds(s: &str, i: usize) -> (usize, usize) {
let (pa, pb) = para_bounds(s, i);
let para = s.get(pa..pb).unwrap_or("");
let mut start = 0;
let mut it = para.char_indices().peekable();
while let Some((_, c)) = it.next() {
let cjk = matches!(c, '。' | '!' | '?');
if !cjk && !matches!(c, '.' | '!' | '?' | '…') {
continue;
}
while it.peek().is_some_and(|&(_, c)| {
matches!(c, '.' | '!' | '?' | '…' | '。' | '!' | '?' | '"' | '\'' | '”' | '’' | '»' | '›' | ')' | ']' | '」' | '』' | ')')
}) {
it.next();
}
if !cjk && it.peek().is_some_and(|&(_, c)| !c.is_whitespace()) {
continue;
}
while it.peek().is_some_and(|&(_, c)| c.is_whitespace()) {
it.next();
}
let end = it.peek().map_or(para.len(), |&(k, _)| k);
if pa + end > i || end == para.len() {
return (pa + start, pa + end);
}
start = end;
}
(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"))?;
@ -665,13 +856,8 @@ fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
let t = st.selection.text.ok_or_else(|| bad("text.move", "no caret"))?;
let story = st.doc.text_story(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
let text = story.text.clone();
let cs = s.cache.get(&st.doc, t.story, None);
// In a cell, lines come from the cell's own composition.
let cs = match t.cell {
Some(c) if c.footnote_id().is_some() => compose::find_note(&cs, c.row as u64).map(|(_, n)| n.text.clone()).unwrap_or(cs),
Some(c) => compose::find_cell(&cs, c.table, c.row, c.col).map(|(_, _, pc)| pc.text.clone()).unwrap_or(cs),
None => cs,
};
let cs = text_composition(s.cache.get(&st.doc, t.story, None), t.cell);
let pos = t.focus.min(text.len());
// Vertical lines run top to bottom and follow each other right to left: Down and Up move along
// the line (Right and Left of horizontal text), Left and Right to the next and previous line.
@ -712,6 +898,7 @@ fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
"storyEnd" => text.len(),
other => return Err(bad("text.move", format!("unknown dir `{other}`"))),
};
s.typing_format = None;
let stm = s.doc_mut()?;
stm.selection.text = Some(if extend { TextSel { focus: new, ..t } } else { TextSel { anchor: new, focus: new, ..t } });
stm.revision += 1;
@ -782,6 +969,23 @@ pub(crate) fn format_targets(s: &Session) -> Vec<Target> {
v
}
/// Character formatting chosen at a bare caret in text: applied to what is typed there next.
#[derive(Clone, Debug)]
pub(crate) struct TypingFormat {
doc: u64,
at: TextSel,
attrs: CharAttrs,
/// Overrides removed (`null` in `type.char`).
cleared: Vec<String>,
}
/// The typing format for the active document's caret, if it was chosen at this caret.
fn typing_format(s: &Session) -> Option<&TypingFormat> {
let st = s.active()?;
let t = st.selection.text?;
s.typing_format.as_ref().filter(|f| f.doc == st.uid && f.at.story == t.story && f.at.cell == t.cell && f.at.range() == t.range())
}
fn warichu_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let has_opts = ["lines", "size", "lineSpacing", "align", "charsBeforeBreak", "charsAfterBreak"].iter().any(|k| p.get(k).is_some());
let on = match p.get("on").and_then(Value::as_bool) {
@ -819,6 +1023,18 @@ fn warichu_cmd(s: &mut Session, p: &Value) -> Result<Value> {
format_chars(s, &Value::Object(body))
}
pub(crate) fn validate_char_fill(document: &designcraft_doc::Document, attrs: &CharAttrs, command_id: &str) -> Result<()> {
if let Some(fill) = &attrs.fill
&& document.swatch(fill).is_none()
{
return Err(bad(
command_id,
format!("unknown character fill `{fill}`; create a swatch with `swatch.create` and use its returned name as `fill`"),
));
}
Ok(())
}
pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result<Value> {
let mut a = CharAttrs::default();
if let Some(o) = attrs.as_object() {
@ -826,34 +1042,53 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result<Value> {
a.set_json(k, v).map_err(|e| bad("type.char", e))?;
}
}
validate_char_fill(&s.doc()?.doc, &a, "type.char")?;
let cleared: Vec<String> = attrs.as_object().map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()).unwrap_or_default();
let targets = format_targets(s);
// A caret in text: the format is for the next typing.
if let [t] = targets.as_slice()
&& t.range.is_empty()
&& let Some(at) = s.doc()?.selection.text
&& s.doc()?.doc.text_story(t.story, t.cell).is_some_and(|st| !st.is_empty())
{
let doc = s.doc()?.uid;
let mut f = typing_format(s).cloned().unwrap_or(TypingFormat { doc, at, attrs: CharAttrs::default(), cleared: vec![] });
f.attrs.merge(&a);
f.cleared.retain(|k| attrs.get(k).is_none_or(|v| v.is_null()) && !cleared.contains(k));
f.cleared.extend(cleared);
s.typing_format = Some(f);
return ok();
}
s.edit(|d, _| {
for t in &targets {
let r = &t.range;
let Some(st) = d.text_story_mut(t.story, t.cell) else { continue };
if r.is_empty() {
// Caret: change the typing format (the empty run at the caret).
let pos = r.start;
if st.is_empty() {
st.chars[0].format.over.merge(&a);
st.rev += 1;
} else {
let _ = pos;
}
continue;
}
st.format_chars(r.clone(), |f| {
f.over.merge(&a);
for k in &cleared {
let _ = f.over.set_json(k, &Value::Null);
}
});
}
apply_char_attrs(d, &targets, &a, &cleared);
ok()
})
}
/// Character attributes `a` (and the overrides `cleared` removed) over each target's text.
pub(crate) fn apply_char_attrs(d: &mut designcraft_doc::Document, targets: &[Target], a: &CharAttrs, cleared: &[String]) {
for t in targets {
let r = &t.range;
let Some(st) = d.text_story_mut(t.story, t.cell) else { continue };
if r.is_empty() {
// An empty story: its one (empty) run is the format typing takes.
if st.is_empty()
&& let Some(run) = st.chars.first_mut()
{
run.format.over.merge(a);
st.rev += 1;
}
continue;
}
st.format_chars(r.clone(), |f| {
f.over.merge(a);
for k in cleared {
let _ = f.over.set_json(k, &Value::Null);
}
});
}
}
pub(crate) fn format_paras(s: &mut Session, attrs: &Value) -> Result<Value> {
let empty = serde_json::Map::new();
let obj = attrs.as_object().unwrap_or(&empty);
@ -957,6 +1192,17 @@ fn open_type(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"features": list, "figures": otf::figures(&list)}))
}
/// How much of the selected text `type.selectionAttrs` reports.
const SELECTED_TEXT_CHARS: usize = 60;
fn selected_text_preview(chars: impl Iterator<Item = char>) -> String {
chars
.map(|c| if c.is_control() || c == designcraft_doc::OBJECT_MARK { ' ' } else { c })
.skip_while(|c| c.is_whitespace())
.take(SELECTED_TEXT_CHARS)
.collect()
}
fn selection_attrs(s: &mut Session, _p: &Value) -> Result<Value> {
let st = s.doc()?;
let targets = format_targets(s);
@ -965,10 +1211,176 @@ fn selection_attrs(s: &mut Session, _p: &Value) -> Result<Value> {
let pi = story.para_at(r.start);
let pf = &story.paras[pi];
let (pp, base) = st.doc.styles.resolve_para(pf);
let cf = if r.is_empty() { story.char_format_at(r.start) } else { story.format_after(r.start) };
let cp = st.doc.styles.resolve_char(&base, cf);
let cf = effective_char_format(s, &Target { story: sid, cell, range: r.clone() })?;
let cp = st.doc.styles.resolve_char(&base, &cf);
let selected = selected_text_preview(story.text.get(r.start..r.end).unwrap_or_default().chars());
Ok(json!({"story": sid.0, "paragraphStyle": pf.style, "characterStyle": cf.style, "para": pp, "chars": cp,
"paraOverrides": pf.para.count(), "charOverrides": cf.over.count()}))
"paraOverrides": pf.para.count(), "charOverrides": cf.over.count(), "selectedText": selected.trim()}))
}
/// The first target character, with any pending caret overrides. Keep character-style
/// inheritance separate from paragraph defaults for style creation from the selection.
pub(crate) fn effective_char_format(s: &Session, target: &Target) -> Result<designcraft_doc::CharFormat> {
let story = s.doc()?.doc.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?;
let mut format = if target.range.is_empty() { story.char_format_at(target.range.start) } else { story.format_after(target.range.start) }.clone();
if let Some(pending) = typing_format(s)
&& target.story == pending.at.story
&& target.cell == pending.at.cell
&& target.range == pending.at.range()
{
apply_typing_format(&mut format, pending);
}
Ok(format)
}
/// A swatch promoted to a named color keeps any not-yet-inserted text pointing at it.
pub(crate) fn rename_typing_swatch(s: &mut Session, from: &str, to: &str) {
let Some(uid) = s.active().map(|st| st.uid) else { return };
if let Some(pending) = &mut s.typing_format
&& pending.doc == uid
{
for name in [&mut pending.attrs.fill, &mut pending.attrs.stroke, &mut pending.attrs.underline_color, &mut pending.attrs.strikethrough_color]
.into_iter()
.flatten()
{
if name == from {
*name = to.to_string();
}
}
}
}
fn swapped_colors(cp: &designcraft_doc::CharProps) -> CharAttrs {
CharAttrs {
fill: Some(cp.stroke.clone()),
fill_tint: Some(cp.stroke_tint),
stroke: Some(cp.fill.clone()),
stroke_tint: Some(cp.fill_tint),
..Default::default()
}
}
/// Resolve each paragraph/run before editing: a mixed selection must not take the first run's colors.
fn swap_fill_stroke(s: &mut Session, _p: &Value) -> Result<Value> {
const ID: &str = "type.swapFillStroke";
let targets = format_targets(s);
if let [target] = targets.as_slice()
&& target.range.is_empty()
&& s.doc()?.selection.text.is_some()
{
let st = s.doc()?;
let story = st.doc.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?;
let pf = story.paras.get(story.para_at(target.range.start)).ok_or_else(|| bad(ID, "missing paragraph"))?;
let (_, base) = st.doc.styles.resolve_para(pf);
let cp = st.doc.styles.resolve_char(&base, &effective_char_format(s, target)?);
return format_chars(s, &json!({"fill": cp.stroke, "fillTint": cp.stroke_tint, "stroke": cp.fill, "strokeTint": cp.fill_tint}));
}
let mut edits = Vec::new();
let d = &s.doc()?.doc;
for target in targets {
let story = d.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?;
if story.is_empty() {
let pf = story.paras.first().ok_or_else(|| bad(ID, "missing paragraph"))?;
let (_, base) = d.styles.resolve_para(pf);
let attrs = swapped_colors(&d.styles.resolve_char(&base, story.char_format_at(0)));
validate_char_fill(d, &attrs, ID)?;
edits.push((target, attrs));
continue;
}
let mut runs = story.runs().peekable();
for (pi, para) in story.para_ranges().into_iter().enumerate() {
let start = para.start.max(target.range.start);
let end = para.end.saturating_add(1).min(story.len()).min(target.range.end);
if start >= end {
continue;
}
let pf = story.paras.get(pi).ok_or_else(|| bad(ID, "missing paragraph"))?;
let (_, base) = d.styles.resolve_para(pf);
while let Some((range, format)) = runs.peek() {
if range.end <= start {
runs.next();
continue;
}
if range.start >= end {
break;
}
let attrs = swapped_colors(&d.styles.resolve_char(&base, format));
validate_char_fill(d, &attrs, ID)?;
edits.push((Target { range: start.max(range.start)..end.min(range.end), ..target.clone() }, attrs));
if range.end > end {
break;
}
runs.next();
}
}
}
s.edit(|d, _| {
for (target, attrs) in &edits {
apply_char_attrs(d, std::slice::from_ref(target), attrs, &[]);
}
ok()
})
}
fn apply_typing_format(cf: &mut designcraft_doc::CharFormat, f: &TypingFormat) {
cf.over.merge(&f.attrs);
for k in &f.cleared {
let _ = cf.over.set_json(k, &Value::Null);
}
}
/// Bold (`bold`) or italic from the font family (style linking), per run of the selection: on or
/// off as asked, else the opposite of the first selected character.
fn link_style(s: &mut Session, p: &Value, bold: bool) -> Result<Value> {
let id = if bold { "type.bold" } else { "type.italic" };
let cur = selection_attrs(s, &json!({}))?;
let family = cur["chars"]["fontFamily"].as_str().unwrap_or_default().to_string();
let style = cur["chars"]["fontStyle"].as_str().unwrap_or_default().to_string();
// The document's own fonts count too.
let db = designcraft_fonts::FontDb::global().scoped(s.doc()?.doc.font_scope);
let (weight, italic) = db.traits_of(&family, &style);
let on = p.get("on").and_then(Value::as_bool).unwrap_or(if bold { weight < 650.0 } else { !italic });
let (want_bold, want_italic) = if bold { (Some(on), None) } else { (None, Some(on)) };
let targets = format_targets(s);
// A caret: the linked style of the format there, for the next typing.
if let [t] = targets.as_slice()
&& t.range.is_empty()
{
let linked = db.linked_style(&family, &style, want_bold, want_italic).ok_or_else(|| bad(id, format!("{family} has no style for that")))?;
format_chars(s, &json!({"fontStyle": linked}))?;
return Ok(json!({"on": on, "fontStyle": linked}));
}
let mut changed = 0;
let r = 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 };
for (pi, pr) in st.para_ranges().into_iter().enumerate() {
let (a, b) = (pr.start.max(t.range.start), pr.end.min(t.range.end));
if a >= b {
continue;
}
let Some(pf) = st.paras.get(pi) else { continue };
let (_, pc) = styles.resolve_para(pf);
st.format_chars(a..b, |f| {
let cp = styles.resolve_char(&pc, f);
if let Some(linked) = db.linked_style(&cp.font_family, &cp.font_style, want_bold, want_italic)
&& linked != cp.font_style
{
f.over.font_style = Some(linked);
changed += 1;
}
});
}
}
// No undo step for nothing.
if changed == 0 {
return Err(bad(id, "nothing to change"));
}
ok()
});
r?;
Ok(json!({"on": on, "changed": changed}))
}
/// Our own filler text (not Adobe's).
@ -1141,6 +1553,37 @@ mod open_type_tests {
s.execute("text.move", &json!({"dir": "right"})).unwrap();
}
#[test]
fn selected_text_preview_stops_at_its_character_budget() {
let read = std::cell::Cell::new(0usize);
let prefix = ['\t', '\u{2003}', designcraft_doc::OBJECT_MARK];
let chars = prefix.into_iter().chain(std::iter::repeat_n('字', 100_000)).inspect(|_| read.set(read.get() + 1));
let preview = super::selected_text_preview(chars);
assert_eq!(preview, "字".repeat(super::SELECTED_TEXT_CHARS));
assert_eq!(read.get(), prefix.len() + super::SELECTED_TEXT_CHARS, "the rest of a large selection is never copied or scanned");
assert_eq!(super::selected_text_preview("A\nB".chars()), "A B");
}
#[test]
fn selection_attrs_report_the_selected_text() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s
.execute(
"frame.create",
&json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Spring issue
What's new in typography this season, and why it matters to every designer"}),
)
.unwrap();
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 13})).unwrap();
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["selectedText"], "Spring issue", "on one line, trimmed");
s.execute("text.select", &json!({"story": r["story"], "anchor": 13, "focus": 200})).unwrap();
let long = s.execute("type.selectionAttrs", &json!({})).unwrap()["selectedText"].as_str().unwrap().to_string();
assert!(long.starts_with("What's new") && (55..=60).contains(&long.chars().count()), "the line break skipped, capped: {long}");
s.execute("text.select", &json!({"story": r["story"], "anchor": 3, "focus": 3})).unwrap();
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["selectedText"], "", "a caret selects nothing");
}
#[test]
fn open_type_toggles_reach_the_text() {
let mut s = Session::new();
@ -1239,6 +1682,202 @@ mod nested_style_tests {
}
}
#[cfg(test)]
mod multi_click_tests {
use serde_json::{Value, json};
use crate::Session;
#[test]
fn moving_the_caret_drops_the_pending_typing_format() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": "Hello world"})).unwrap();
let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap());
let size_at = |s: &Session, b: usize| s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().char_format_at(b).over.size;
let pending = |s: &mut Session| {
s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap();
s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap();
};
// Away and back by arrow keys, by a click, by selecting and by double-clicking: typing at the
// same caret is in the text's own format.
let moves: Vec<Box<dyn Fn(&mut Session)>> = vec![
Box::new(|s| {
s.execute("text.move", &json!({"dir": "right"})).unwrap();
s.execute("text.move", &json!({"dir": "left"})).unwrap();
}),
Box::new(|s| {
s.execute("text.placeCaret", &json!({"frame": fid, "point": [80, 80]})).unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap();
}),
Box::new(|s| {
s.execute("text.selectAt", &json!({"frame": fid, "point": [80, 80], "unit": "story"})).unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap();
}),
];
for m in &moves {
pending(&mut s);
m(&mut s);
s.execute("text.insert", &json!({"text": "x"})).unwrap();
assert_eq!(size_at(&s, 6), None);
s.execute("edit.undo", &json!({})).unwrap();
}
// Without a move it applies.
pending(&mut s);
s.execute("text.insert", &json!({"text": "x"})).unwrap();
assert_eq!(size_at(&s, 6), Some(30.0));
}
#[test]
fn word_selection_keeps_grapheme_clusters_whole() {
use super::word_or_spaces_at as word;
// Decomposed "café": the combining acute belongs to the word, wherever the click lands.
let s = "un cafe\u{301} noir";
let cafe = (3, 3 + "cafe\u{301}".len());
assert_eq!(word(s, 4), cafe);
assert_eq!(word(s, 7), cafe, "on the combining mark");
// Devanagari with a virama (U+094D) and a dependent vowel sign.
let s = "\u{928}\u{92E}\u{938}\u{94D}\u{924}\u{947} x";
let w = "\u{928}\u{92E}\u{938}\u{94D}\u{924}\u{947}".len();
assert_eq!(word(s, 0), (0, w));
assert_eq!(word(s, 9), (0, w), "on the virama");
// An emoji ZWJ sequence and a variation selector stay one character.
let s = "a \u{1F469}\u{200D}\u{1F4BB} b \u{263A}\u{FE0F}";
let e = (2, 2 + "\u{1F469}\u{200D}\u{1F4BB}".len());
assert_eq!(word(s, 2), e);
assert_eq!(word(s, 6), e, "on the joiner");
assert_eq!(word(s, s.len() - 1), (s.len() - 6, s.len()), "at the end: the last character whole");
// Empty paragraphs select nothing.
assert_eq!(word("a\n\nb", 2), (2, 2));
}
#[test]
fn clicks_select_words_spaces_lines_paragraphs_and_the_story() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let text = "Hello brave world.\nSecond paragraph here.";
let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": text})).unwrap();
let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap());
// Spread point over the right half of the glyph at byte `b`.
let at = |s: &Session, b: usize| {
let d = &s.doc().unwrap().doc;
let cs = designcraft_compose::compose_story(d, designcraft_doc::StoryId(sid), &Default::default());
let (l, g) = cs.frames[0].lines.iter().find_map(|l| l.glyphs.iter().find(|g| g.byte == b && g.len > 0).map(|g| (l, g))).unwrap();
json!([g.x + g.adv * 0.75, l.baseline - 3.0])
};
let mut select = |unit: &str, b: usize| {
let p = at(&s, b);
let r: Value = s.execute("text.selectAt", &json!({"frame": fid, "point": p, "unit": unit})).unwrap();
(r["anchor"].as_u64().unwrap() as usize, r["focus"].as_u64().unwrap() as usize)
};
assert_eq!(select("word", 1), (0, 5), "inside a word");
assert_eq!(select("word", 4), (0, 5), "its last letter");
assert_eq!(select("word", 5), (5, 7), "the run of spaces after it");
assert_eq!(select("word", 18), (18, 19), "punctuation alone");
assert_eq!(select("line", 9), (0, 19));
assert_eq!(select("paragraph", 9), (0, 20), "with its return");
assert_eq!(select("paragraph", 22), (20, text.len()));
assert_eq!(select("story", 9), (0, text.len()));
// A second click inside the selected word selects again rather than starting a text drag.
s.execute("text.select", &json!({"story": sid, "anchor": 7, "focus": 12})).unwrap();
let p = at(&s, 9);
s.execute("text.selectAt", &json!({"frame": fid, "point": p, "unit": "line"})).unwrap();
let t = s.doc().unwrap().selection.text.unwrap();
assert_eq!((t.anchor, t.focus), (0, 19));
s.execute("text.release", &json!({"frame": fid, "point": p, "moved": false})).unwrap();
let t = s.doc().unwrap().selection.text.unwrap();
assert_eq!((t.anchor, t.focus), (0, 19), "releasing keeps it");
// The older word command agrees.
let p = at(&s, 8);
s.execute("text.selectWord", &json!({"frame": fid, "point": p})).unwrap();
let t = s.doc().unwrap().selection.text.unwrap();
assert_eq!((t.anchor, t.focus), (7, 12));
assert!(s.execute("text.selectAt", &json!({"frame": fid, "point": p, "unit": "sentence"})).is_err());
assert!(s.execute("text.selectAt", &json!({"frame": fid, "unit": "word"})).is_err(), "no point");
}
}
#[cfg(test)]
mod style_link_tests {
use serde_json::json;
use crate::Session;
#[test]
fn bold_and_italic_follow_the_family_and_toggle() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": "plain words here"})).unwrap();
let sid = r["story"].as_u64().unwrap();
let style_at = |s: &mut Session, b: usize| {
s.execute("text.select", &json!({"story": sid, "anchor": b, "focus": b + 1})).unwrap();
s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fontStyle"].as_str().unwrap().to_string()
};
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 5})).unwrap();
s.execute("type.italic", &json!({})).unwrap();
assert_eq!(style_at(&mut s, 0), "Italic");
// Bold over italic and plain text: each run takes its own linked style.
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 11})).unwrap();
s.execute("type.bold", &json!({})).unwrap();
assert_eq!(style_at(&mut s, 0), "Bold Italic");
assert_eq!(style_at(&mut s, 7), "Bold");
// Again: off, back to each run's regular weight.
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 11})).unwrap();
s.execute("type.bold", &json!({})).unwrap();
assert_eq!(style_at(&mut s, 0), "Italic");
assert_eq!(style_at(&mut s, 7), "Regular");
// Nothing to change: an error and no undo step.
s.execute("text.select", &json!({"story": sid, "anchor": 6, "focus": 11})).unwrap();
let steps = s.doc().unwrap().history.undo.len();
assert!(s.execute("type.bold", &json!({"on": false})).is_err());
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
// At a caret the style is for what is typed next.
let end = "plain words here".len();
s.execute("text.select", &json!({"story": sid, "anchor": end, "focus": end})).unwrap();
s.execute("type.bold", &json!({})).unwrap();
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fontStyle"], "Bold");
s.execute("text.insert", &json!({"text": " now"})).unwrap();
assert_eq!(style_at(&mut s, end + 1), "Bold");
assert_eq!(style_at(&mut s, end - 1), "Regular", "text before the caret is unchanged");
// Moving the caret forgets it.
s.execute("text.select", &json!({"story": sid, "anchor": 2, "focus": 2})).unwrap();
s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 3, "focus": 3})).unwrap();
s.execute("text.insert", &json!({"text": "x"})).unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 3, "focus": 4})).unwrap();
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"], 12.0);
}
}
#[cfg(test)]
mod text_color_tests {
use serde_json::json;
use crate::Session;
#[test]
fn unnamed_colours_go_to_text_when_asked() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "colour me", "caret": false})).unwrap();
let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap());
s.execute("selection.set", &json!({"ids": [fid]})).unwrap();
let fill_of_frame = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(fid)).unwrap().fill.swatch.clone();
let before = fill_of_frame(&s);
let r = s.execute("object.color", &json!({"color": "#ff0000", "text": true})).unwrap();
let red = r["swatch"].as_str().unwrap().to_string();
assert_eq!(fill_of_frame(&s), before, "the frame keeps its fill");
let story = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().clone();
assert_eq!(story.char_format_at(3).over.fill.as_deref(), Some(red.as_str()));
// A text selection takes it without asking.
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 3})).unwrap();
let r = s.execute("object.color", &json!({"color": "#00ff00", "target": "stroke"})).unwrap();
let story = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().clone();
assert_eq!(story.format_after(0).over.stroke.as_deref(), r["swatch"].as_str());
assert_eq!(s.doc().unwrap().history.undo.len(), 3, "the frame, then one undo step per colour");
}
}
#[cfg(test)]
mod drag_text_tests {
use serde_json::json;
@ -1883,3 +2522,44 @@ mod story_query_contract_tests {
assert_eq!(s.execute("story.get", &json!({})).unwrap()["text"], "");
}
}
#[cfg(test)]
mod sentence_selection_tests {
use super::{para_bounds, sentence_bounds};
fn sentence(s: &str, at: &str) -> String {
let i = s.find(at).unwrap();
let (a, b) = sentence_bounds(s, i);
s[a..b].to_string()
}
#[test]
fn sentences_end_at_a_stop_before_a_space_or_the_paragraph_end() {
let s = "It costs 3.5 euros. \u{201C}Really?\u{201D} (Twice!) Yes\u{2026} fine.\nNext one. Last";
assert_eq!(sentence(s, "costs"), "It costs 3.5 euros. ");
assert_eq!(sentence(s, "Really"), "\u{201C}Really?\u{201D} ");
assert_eq!(sentence(s, "Twice"), "(Twice!) ");
assert_eq!(sentence(s, "Yes"), "Yes\u{2026} ");
assert_eq!(sentence(s, "fine"), "fine.");
assert_eq!(sentence(s, "one"), "Next one. ");
assert_eq!(sentence(s, "Last"), "Last");
// The caret at the very end of a paragraph is in its last sentence.
let end = s.find('\n').unwrap();
assert_eq!(sentence_bounds(s, end), (s.find("fine").unwrap(), end));
}
#[test]
fn cjk_full_stops_end_sentences_without_a_space() {
let s = "これは文です。次の文!最後";
assert_eq!(sentence(s, "次"), "次の文!");
assert_eq!(sentence(s, "最後"), "最後");
}
#[test]
fn paragraphs_stop_at_returns() {
let s = "One.\nTwo two.\n";
assert_eq!(para_bounds(s, 6), (5, 13));
assert_eq!(para_bounds(s, 0), (0, 4));
assert_eq!(para_bounds(s, s.len()), (s.len(), s.len()));
}
}

View File

@ -0,0 +1,181 @@
//! Cross-command caret color and character-style regressions.
use crate::Session;
use designcraft_doc::StoryId;
use serde_json::{Value, json};
fn setup(text: &str) -> (Session, u64) {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let f = s.execute("frame.create", &json!({"rect": [36,36,300,200], "content":"text", "text":text})).unwrap();
let sid = f["story"].as_u64().unwrap();
s.execute("text.select", &json!({"story":sid,"anchor":text.len()})).unwrap();
(s, sid)
}
fn attrs(s: &mut Session) -> Value {
s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"].clone()
}
#[test]
fn integration_color_character_style_captures_pending_and_null() {
let (mut s, sid) = setup("hello");
s.execute("type.char", &json!({"size":30,"tracking":40,"fill":"[Paper]"})).unwrap();
s.execute("style.character.create", &json!({"name":"Pending","fromSelection":true,"chars":{"tracking":null}})).unwrap();
let a = &s.doc().unwrap().doc.styles.char_style("Pending").unwrap().chars;
assert_eq!(a.size, Some(30.0));
assert_eq!(a.fill.as_deref(), Some("[Paper]"));
assert_eq!(a.tracking, None);
assert_eq!(s.doc().unwrap().doc.story(StoryId(sid)).unwrap().text, "hello");
}
#[test]
fn integration_color_pending_follows_promoted_swatch() {
for target in ["fill", "stroke"] {
let (mut s, sid) = setup("hello");
let old = s.execute("object.color", &json!({"color":"#123456","target":target})).unwrap()["swatch"].clone();
s.execute("swatch.addToSwatches", &json!({"swatch":old,"name":"Brand"})).unwrap();
assert_eq!(attrs(&mut s)[target], "Brand");
s.execute("text.insert", &json!({"text":"X"})).unwrap();
let st = s.doc().unwrap().doc.story(StoryId(sid)).unwrap();
let f = &st.format_after(5).over;
assert_eq!(if target == "fill" { f.fill.as_deref() } else { f.stroke.as_deref() }, Some("Brand"));
}
}
#[test]
fn integration_color_undo_discards_missing_pending_swatch() {
let (mut s, sid) = setup("hello");
let old = s.execute("object.color", &json!({"color":"#123456"})).unwrap()["swatch"].as_str().unwrap().to_string();
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.swatch(&old).is_none());
assert_ne!(attrs(&mut s)["fill"], old);
s.execute("text.insert", &json!({"text":"X"})).unwrap();
assert_ne!(s.doc().unwrap().doc.story(StoryId(sid)).unwrap().format_after(5).over.fill.as_deref(), Some(old.as_str()));
}
#[test]
fn integration_color_empty_story_uses_one_undo_step() {
let (mut s, sid) = setup("");
let before = serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap();
let n = s.doc().unwrap().history.undo.len();
s.execute("object.color", &json!({"color":"#123456"})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), n + 1);
let colored = serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(), before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(), colored);
s.execute("text.insert", &json!({"text":"X"})).unwrap();
assert!(s.doc().unwrap().doc.story(StoryId(sid)).unwrap().format_after(0).over.fill.is_some());
}
#[test]
fn integration_color_swap_resolves_each_paragraph_and_keeps_unselected_text() {
let (mut s, sid) = setup("ab\ncd");
for (name, fill, stroke, ft, st, start, end) in
[("First", "[Paper]", "[Black]", 0.2, 0.8, 0, 2), ("Second", "[Black]", "[Paper]", 0.4, 0.6, 3, 5)]
{
s.execute("style.paragraph.create", &json!({"name":name,"chars":{"fill":fill,"stroke":stroke,"fillTint":ft,"strokeTint":st}})).unwrap();
s.execute("text.select", &json!({"story":sid,"anchor":start,"focus":end})).unwrap();
s.execute("style.paragraph.apply", &json!({"name":name})).unwrap();
}
s.execute("text.select", &json!({"story":sid,"anchor":1,"focus":4})).unwrap();
let before = serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap();
let n = s.doc().unwrap().history.undo.len();
s.execute("type.swapFillStroke", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), n + 1);
for (pos, fill, stroke, ft, st) in [
(0, "[Paper]", "[Black]", 0.2, 0.8),
(1, "[Black]", "[Paper]", 0.8, 0.2),
(2, "[Black]", "[Paper]", 0.8, 0.2),
(3, "[Paper]", "[Black]", 0.6, 0.4),
(4, "[Black]", "[Paper]", 0.4, 0.6),
] {
s.execute("text.select", &json!({"story":sid,"anchor":pos,"focus":pos+1})).unwrap();
let a = attrs(&mut s);
assert_eq!(a["fill"], fill);
assert_eq!(a["stroke"], stroke);
assert!((a["fillTint"].as_f64().unwrap() - ft).abs() < 1e-6);
assert!((a["strokeTint"].as_f64().unwrap() - st).abs() < 1e-6);
}
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(), before);
}
#[test]
fn integration_color_swap_caret_applies_only_to_next_typing() {
let (mut s, sid) = setup("hello");
s.execute("type.char", &json!({"fill":"[Paper]","stroke":"[Black]","fillTint":0.2,"strokeTint":0.8})).unwrap();
let before = serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap();
s.execute("type.swapFillStroke", &json!({})).unwrap();
assert_eq!(serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(), before);
let a = attrs(&mut s);
assert_eq!(a["fill"], "[Black]");
assert_eq!(a["stroke"], "[Paper]");
s.execute("text.insert", &json!({"text":"X"})).unwrap();
let st = s.doc().unwrap().doc.story(StoryId(sid)).unwrap();
assert_eq!(st.text, "helloX");
assert_eq!(st.format_after(0).over.fill, None);
assert_eq!(st.format_after(5).over.stroke.as_deref(), Some("[Paper]"));
assert_eq!(st.format_after(5).over.fill_tint, Some(0.8));
}
#[test]
fn integration_color_swap_selected_empty_cells_keeps_each_format() {
let (mut s, sid) = setup("hello");
let result = s.execute("table.insert", &json!({"rows":1,"cols":2})).unwrap();
let tid = result["table"].as_u64().unwrap();
s.edit(|d, _| {
let table = std::sync::Arc::make_mut(d.story_mut(StoryId(sid)).unwrap().tables.get_mut(&tid).unwrap());
for (c, fill, stroke) in [(0, "[Paper]", "[Black]"), (1, "[Black]", "[Paper]")] {
let attrs = &mut table.cell_mut(0, c).unwrap().text.chars[0].format.over;
attrs.fill = Some(fill.into());
attrs.stroke = Some(stroke.into());
}
Ok(())
})
.unwrap();
s.execute("table.select", &json!({"story":sid,"table":tid,"what":"table"})).unwrap();
let before = serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap();
s.execute("type.swapFillStroke", &json!({})).unwrap();
let table = &s.doc().unwrap().doc.story(StoryId(sid)).unwrap().tables[&tid];
assert_eq!(table.cell(0, 0).unwrap().text.chars[0].format.over.fill.as_deref(), Some("[Black]"));
assert_eq!(table.cell(0, 1).unwrap().text.chars[0].format.over.fill.as_deref(), Some("[Paper]"));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(), before);
}
#[test]
fn integration_color_failed_promotion_preserves_pending_and_history() {
let (mut s, _) = setup("hello");
s.execute("swatch.create", &json!({"name":"Brand","color":"#654321"})).unwrap();
let old = s.execute("object.color", &json!({"color":"#123456"})).unwrap()["swatch"].clone();
let before = s.doc().unwrap().clone();
let pending = attrs(&mut s);
assert!(s.execute("swatch.addToSwatches", &json!({"swatch":old,"name":"Brand"})).is_err());
assert_eq!(attrs(&mut s), pending);
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before.doc));
assert_eq!(s.doc().unwrap().history.undo.len(), before.history.undo.len());
}
#[test]
fn integration_color_promote_undo_redo_never_reuses_a_missing_pending_name() {
let (mut s, _) = setup("hello");
let old = s.execute("object.color", &json!({"color":"#123456"})).unwrap()["swatch"].clone();
s.execute("swatch.addToSwatches", &json!({"swatch":old,"name":"Brand"})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
let fill = attrs(&mut s)["fill"].as_str().unwrap().to_string();
assert!(s.doc().unwrap().doc.swatch(&fill).is_some());
s.execute("edit.redo", &json!({})).unwrap();
let fill = attrs(&mut s)["fill"].as_str().unwrap().to_string();
assert!(s.doc().unwrap().doc.swatch(&fill).is_some());
}
#[test]
fn integration_color_swap_validates_new_fill_before_any_edit() {
let (mut s, sid) = setup("hello");
s.execute("text.select", &json!({"story":sid,"anchor":0,"focus":5})).unwrap();
s.execute("type.char", &json!({"fill":"[Paper]","stroke":"Missing Ink"})).unwrap();
let before = s.doc().unwrap().clone();
let journal = s.journal.clone();
let error = s.execute("type.swapFillStroke", &json!({})).unwrap_err().to_string();
assert!(error.contains("Missing Ink"), "{error}");
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before.doc));
assert_eq!(s.doc().unwrap().history.undo.len(), before.history.undo.len());
assert_eq!(s.journal, journal);
}

View File

@ -0,0 +1,396 @@
//! Threading text frames: thread a frame after another (their stories merge), break a thread,
//! take a frame out of its thread. The Selection tool's loaded text cursor runs these.
use designcraft_doc::{Document, ItemId, ParaFormat, Selection, StoryId};
use designcraft_geom::Rect;
use serde_json::{Value, json};
use super::{CommandSpec, bad, bool_or, cmd, has_doc, id_param, rect_param, spread_param, str_param};
use crate::{Result, Session};
/// Pages Shift-click autoflow may add before it gives up.
const AUTOFLOW_MAX_PAGES: usize = 2000;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"text.thread",
"Thread Text Frames",
[],
None,
"{from?, to?, rect?: [x0,y0,x1,y1] (spread coords), spread?, autoflow?: bool} — threads frame `to` right after frame `from` (between `from` and the frame that followed it, if any). `to` is an empty frame or a text frame nothing threads into yet; its text joins the end of the story. Give `rect` in place of `from` or `to` to thread a new text frame made there on `spread`: `{from, rect}` adds it after `from`, `{rect, to}` in front of `to`. autoflow: then add pages with threaded frames until the story fits. Selects the frame that joined the thread. → {story, from, to, overset, pagesAdded}",
has_doc,
thread
),
cmd!(
"text.unthread",
"Break Thread",
[],
None,
"{frame?, side?: after|before} — breaks the thread after (or before) `frame` (default: the selected text frame). The frames from the break on stay threaded to each other but become empty; the text stays in the story, overset if the earlier frames can't hold it",
has_doc,
unthread
),
cmd!(
"text.removeFromThread",
"Remove Frame from Thread",
[],
None,
"{frame?} — takes `frame` (default: the selected text frame) out of its thread: the frames before and after it join up and keep the text, `frame` stays empty",
has_doc,
remove_from_thread
),
]
}
/// `frame` param, or the first selected text frame.
fn frame_param(s: &Session, p: &Value, cmd: &str) -> Result<ItemId> {
let st = s.doc()?;
let id = id_param(p, "frame")
.or_else(|| st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| it.is_text_frame())))
.ok_or_else(|| bad(cmd, "no text frame given or selected"))?;
if !st.doc.item(id).is_some_and(|it| it.is_text_frame()) {
return Err(bad(cmd, format!("{id} is not a text frame")));
}
check_editable(&st.doc, id, cmd)?;
Ok(id)
}
fn check_editable(d: &Document, id: ItemId, cmd: &str) -> Result<()> {
let it = d.item(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
if it.locked || d.layer(it.layer).is_some_and(|l| l.locked) {
return Err(bad(cmd, format!("{id} is locked or on a locked layer")));
}
Ok(())
}
/// A rect param made safe for a frame: finite, ordered, at least half a point each way.
fn frame_rect(p: &Value) -> Result<Option<Rect>> {
let Some(r) = rect_param(p, "rect") else { return Ok(None) };
if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) {
return Err(bad("text.thread", "rect must be finite"));
}
let (x0, x1) = (r.x0.min(r.x1), r.x0.max(r.x1));
let (y0, y1) = (r.y0.min(r.y1), r.y0.max(r.y1));
Ok(Some(Rect::new(x0, y0, x1.max(x0 + 0.5), y1.max(y0 + 0.5))))
}
fn thread(s: &mut Session, p: &Value) -> Result<Value> {
let (from, to, rect) = (id_param(p, "from"), id_param(p, "to"), frame_rect(p)?);
let sr = spread_param(p, "spread")?;
let autoflow = bool_or(p, "autoflow", false);
let layer = s.doc()?.active_layer;
s.edit(|d, sel| {
let mut new_frame = || -> Result<ItemId> {
let r = rect.ok_or_else(|| bad("text.thread", "give two of from, to and rect"))?;
if d.layer(layer).is_none_or(|l| l.locked || !l.visible) {
return Err(bad("text.thread", "the active layer is locked or hidden"));
}
let para = ParaFormat { style: d.styles.default_paragraph.clone(), ..Default::default() };
Ok(d.add_text_frame(sr, r, layer, "", para)?.0)
};
let (from, to, joined) = match (from, to) {
(Some(f), Some(t)) if rect.is_none() => (f, t, t),
(Some(f), None) => {
let t = new_frame()?;
(f, t, t)
}
(None, Some(t)) => {
let f = new_frame()?;
(f, t, f)
}
_ => return Err(bad("text.thread", "give two of from, to and rect")),
};
d.check_thread(from, to)?;
d.thread_keeping_story(from, to)?;
let sid = d.item(from).and_then(|i| i.text_frame()).map(|t| t.story).ok_or_else(|| bad("text.thread", "threading lost the story"))?;
let pages = if autoflow { super::place_text_autoflow(d, sid, AUTOFLOW_MAX_PAGES, "text.thread")? } else { 0 };
*sel = Selection::items(vec![joined]);
Ok(json!({"story": sid.0, "from": from.0, "to": to.0, "overset": overset(d, sid), "pagesAdded": pages}))
})
}
fn overset(d: &Document, sid: StoryId) -> bool {
designcraft_compose::compose_story(d, sid, &Default::default()).overset_at.is_some()
}
fn unthread(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "text.unthread";
let frame = frame_param(s, p, CMD)?;
let d0 = &s.doc()?.doc;
let at = match str_param(p, "side").unwrap_or("after") {
"after" => d0.next_frame(frame).map(|_| frame).ok_or_else(|| bad(CMD, format!("no frame follows {frame} in its thread")))?,
"before" => d0.prev_frame(frame).ok_or_else(|| bad(CMD, format!("no frame comes before {frame} in its thread")))?,
other => return Err(bad(CMD, format!("unknown side `{other}` (after, before)"))),
};
check_editable(d0, at, CMD)?;
if let Some(next) = d0.next_frame(at) {
check_editable(d0, next, CMD)?;
}
s.edit(|d, _| {
d.unthread_after(at)?;
let sid = d.item(at).and_then(|i| i.text_frame()).map(|t| t.story).ok_or_else(|| bad(CMD, "no story"))?;
Ok(json!({"story": sid.0, "overset": overset(d, sid)}))
})
}
fn remove_from_thread(s: &mut Session, p: &Value) -> Result<Value> {
let frame = frame_param(s, p, "text.removeFromThread")?;
let d = &s.doc()?.doc;
for neighbour in [d.prev_frame(frame), d.next_frame(frame)].into_iter().flatten() {
check_editable(d, neighbour, "text.removeFromThread")?;
}
s.edit(|d, _| {
d.remove_from_thread(frame)?;
Ok(json!({}))
})
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::Session;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 2})).unwrap();
s
}
fn text_frame(s: &mut Session, rect: [f64; 4], text: &str) -> (u64, u64) {
let r = s.execute("frame.create", &json!({"rect": rect, "content": "text", "text": text, "caret": false})).unwrap();
(r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap())
}
fn story(s: &mut Session, frame: u64) -> serde_json::Value {
s.execute("story.get", &json!({"frame": frame})).unwrap()
}
fn frames(s: &mut Session, frame: u64) -> Vec<u64> {
story(s, frame)["frames"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect()
}
const LONG: &str = "Threaded text keeps flowing from one frame into the next one. ";
#[test]
fn thread_into_an_empty_frame_and_undo() {
let mut s = session();
let (a, sid) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &LONG.repeat(10));
let b = s.execute("frame.create", &json!({"rect": [72, 300, 200, 400], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
assert!(!story(&mut s, a)["overset"].is_null(), "the first frame is overset");
let r = s.execute("text.thread", &json!({"from": a, "to": b})).unwrap();
assert_eq!(r["story"].as_u64(), Some(sid));
assert_eq!(frames(&mut s, a), [a, b]);
let d = &s.doc().unwrap().doc;
assert!(d.item(designcraft_doc::ItemId(b)).unwrap().is_text_frame());
assert_eq!(s.doc().unwrap().selection.items, [designcraft_doc::ItemId(b)]);
// The text now reaches the second frame.
let cs = designcraft_compose::compose_story(&s.doc().unwrap().doc, designcraft_doc::StoryId(sid), &Default::default());
assert!(cs.frames.iter().any(|f| f.frame.0 == b && !f.range.is_empty()), "text flows into the new frame");
s.doc().unwrap().doc.check().unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(frames(&mut s, a), [a]);
assert!(!s.doc().unwrap().doc.item(designcraft_doc::ItemId(b)).unwrap().is_text_frame());
}
#[test]
fn thread_into_a_text_frame_merges_its_story() {
let mut s = session();
let (a, sa) = text_frame(&mut s, [72.0, 72.0, 200.0, 200.0], "First");
let (b, sb) = text_frame(&mut s, [72.0, 300.0, 200.0, 400.0], "Second");
s.execute("text.thread", &json!({"from": a, "to": b})).unwrap();
let st = story(&mut s, b);
assert_eq!(st["id"].as_u64(), Some(sa));
assert_eq!(st["text"], "First\nSecond");
assert!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sb)).is_none());
s.doc().unwrap().doc.check().unwrap();
}
#[test]
fn thread_into_the_middle_of_a_thread() {
let mut s = session();
let (a, _) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &LONG.repeat(8));
let (c, _) = text_frame(&mut s, [72.0, 500.0, 200.0, 600.0], "");
s.execute("text.thread", &json!({"from": a, "to": c})).unwrap();
let (b, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 400.0], "");
s.execute("text.thread", &json!({"from": a, "to": b})).unwrap();
assert_eq!(frames(&mut s, a), [a, b, c]);
s.doc().unwrap().doc.check().unwrap();
}
#[test]
fn bad_threads_are_errors_and_change_nothing() {
let mut s = session();
let (a, _) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], "a");
let (b, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 400.0], "b");
let (x, _) = text_frame(&mut s, [300.0, 72.0, 400.0, 120.0], "x");
let (y, _) = text_frame(&mut s, [300.0, 300.0, 400.0, 400.0], "y");
let g = s.execute("line.create", &json!({"a": [300, 500], "b": [400, 600]})).unwrap()["id"].as_u64().unwrap();
let group = {
let p = s.execute("frame.create", &json!({"rect": [500, 500, 550, 550], "content": "unassigned"})).unwrap()["id"].clone();
let q = s.execute("frame.create", &json!({"rect": [560, 500, 600, 550], "content": "unassigned"})).unwrap()["id"].clone();
s.execute("selection.set", &json!({"ids": [p, q]})).unwrap();
s.execute("object.group", &json!({})).unwrap();
s.doc().unwrap().selection.items[0].0
};
s.execute("text.thread", &json!({"from": a, "to": b})).unwrap();
s.execute("text.thread", &json!({"from": x, "to": y})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
let doc = s.doc().unwrap().doc.clone();
for p in [
json!({"from": a, "to": a}),
json!({"from": a, "to": g}),
json!({"from": g, "to": a}),
json!({"from": a, "to": group}),
// Earlier in the same thread.
json!({"from": b, "to": a}),
// Text already flows into y from x.
json!({"from": a, "to": y}),
json!({"from": a, "to": 999_999}),
json!({"from": a}),
json!({"from": a, "to": b, "rect": [0, 0, 10, 10]}),
json!({"rect": [0, 0, 10, 10]}),
json!({"from": a, "rect": [0, 0, 10, 10], "spread": 99}),
json!({"from": "a", "to": [1]}),
json!([1, 2]),
] {
assert!(s.execute("text.thread", &p).is_err(), "{p}");
}
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
assert_eq!(*s.doc().unwrap().doc, *doc);
}
#[test]
fn locked_frames_and_layers_refuse_threads() {
let mut s = session();
let (a, _) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &LONG.repeat(8));
let (b, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 400.0], "");
let layer = s.doc().unwrap().active_layer.0;
let undo = s.doc().unwrap().history.undo.len();
s.execute("object.lock", &json!({"ids": [b]})).unwrap();
assert!(s.execute("text.thread", &json!({"from": a, "to": b})).is_err(), "locked frame");
assert!(s.execute("text.thread", &json!({"from": b, "to": a})).is_err(), "locked frame");
s.execute("edit.undo", &json!({})).unwrap();
for (set, rect) in [(json!({"locked": true}), false), (json!({"locked": true}), true), (json!({"visible": false}), true)] {
let mut p = json!({"id": layer});
p.as_object_mut().unwrap().extend(set.as_object().unwrap().clone());
s.execute("layer.set", &p).unwrap();
let t = if rect { json!({"from": a, "rect": [300, 300, 400, 400]}) } else { json!({"from": a, "to": b}) };
assert!(s.execute("text.thread", &t).is_err(), "{p} {t}");
s.execute("edit.undo", &json!({})).unwrap();
}
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
assert_eq!(frames(&mut s, a), [a]);
}
#[test]
fn thread_a_new_frame_after_or_in_front() {
let mut s = session();
let (a, sa) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &LONG.repeat(8));
let r = s.execute("text.thread", &json!({"from": a, "rect": [72, 300, 200, 400]})).unwrap();
let n = r["to"].as_u64().unwrap();
assert_eq!(frames(&mut s, a), [a, n]);
assert_eq!(s.doc().unwrap().selection.items, [designcraft_doc::ItemId(n)]);
let b = s.doc().unwrap().doc.item(designcraft_doc::ItemId(n)).unwrap().bounds();
assert_eq!(b, Rect::new(72.0, 300.0, 200.0, 400.0));
// In front of the first frame: the story keeps its id and text.
let r = s.execute("text.thread", &json!({"rect": [300, 72, 400, 120], "to": a})).unwrap();
let m = r["from"].as_u64().unwrap();
assert_eq!(r["story"].as_u64(), Some(sa));
assert_eq!(frames(&mut s, a), [m, a, n]);
assert_eq!(story(&mut s, a)["text"], LONG.repeat(8));
s.doc().unwrap().doc.check().unwrap();
// One undo step each.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(frames(&mut s, a), [a, n]);
}
use designcraft_geom::Rect;
#[test]
fn thread_with_autoflow_adds_pages_until_the_text_fits() {
let mut s = session();
let (a, _) = text_frame(&mut s, [72.0, 72.0, 300.0, 200.0], &LONG.repeat(300));
let r = s.execute("text.thread", &json!({"from": a, "rect": [72, 300, 300, 700], "autoflow": true})).unwrap();
assert_eq!(r["overset"], false);
assert!(r["pagesAdded"].as_u64().unwrap() >= 1, "{r}");
assert!(frames(&mut s, a).len() >= 3);
s.doc().unwrap().doc.check().unwrap();
// One undo removes the frames and the pages.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(frames(&mut s, a), [a]);
assert_eq!(s.doc().unwrap().doc.page_count(), 2);
}
#[test]
fn autoflow_from_a_parent_frame_is_an_error() {
let mut s = session();
let parent = json!({"kind": "parent", "index": 0});
let r = s.execute(
"frame.create",
&json!({"rect": [72, 72, 300, 200], "content": "text", "text": LONG.repeat(300), "caret": false, "spread": parent}),
);
let a = r.unwrap()["id"].as_u64().unwrap();
let undo = s.doc().unwrap().history.undo.len();
let doc = s.doc().unwrap().doc.clone();
let e = s.execute("text.thread", &json!({"from": a, "rect": [72, 300, 300, 700], "spread": parent, "autoflow": true})).unwrap_err();
assert!(e.to_string().contains("text.thread"), "{e}");
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
assert_eq!(*s.doc().unwrap().doc, *doc);
assert_eq!(s.doc().unwrap().doc.page_count(), 2);
}
#[test]
fn unthread_after_and_before() {
let mut s = session();
let text = LONG.repeat(12);
let (a, sa) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &text);
let (b, _) = text_frame(&mut s, [72.0, 200.0, 200.0, 250.0], "");
let (c, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 350.0], "");
s.execute("text.thread", &json!({"from": a, "to": b})).unwrap();
s.execute("text.thread", &json!({"from": b, "to": c})).unwrap();
// Break after the first frame: b and c stay threaded to each other, empty.
let r = s.execute("text.unthread", &json!({"frame": a})).unwrap();
assert_eq!(r["story"].as_u64(), Some(sa));
assert_eq!(r["overset"], true);
assert_eq!(frames(&mut s, a), [a]);
assert_eq!(story(&mut s, a)["text"], text);
assert_eq!(frames(&mut s, b), [b, c]);
assert_eq!(story(&mut s, b)["text"], "");
s.doc().unwrap().doc.check().unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(frames(&mut s, a), [a, b, c]);
// Break before the last frame, with the frame selected.
s.execute("selection.set", &json!({"ids": [c]})).unwrap();
s.execute("text.unthread", &json!({"side": "before"})).unwrap();
assert_eq!(frames(&mut s, a), [a, b]);
assert_eq!(frames(&mut s, c), [c]);
// Nothing to break.
assert!(s.execute("text.unthread", &json!({"frame": b})).is_err());
assert!(s.execute("text.unthread", &json!({"frame": a, "side": "before"})).is_err());
assert!(s.execute("text.unthread", &json!({"frame": a, "side": "sideways"})).is_err());
assert!(s.execute("text.unthread", &json!({"frame": 999_999})).is_err());
}
#[test]
fn deleting_or_removing_a_threaded_frame_keeps_the_text_flowing() {
let mut s = session();
let text = LONG.repeat(12);
let (a, _) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &text);
let (b, _) = text_frame(&mut s, [72.0, 200.0, 200.0, 250.0], "");
let (c, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 350.0], "");
s.execute("text.thread", &json!({"from": a, "to": b})).unwrap();
s.execute("text.thread", &json!({"from": b, "to": c})).unwrap();
s.execute("text.removeFromThread", &json!({"frame": b})).unwrap();
assert_eq!(frames(&mut s, a), [a, c]);
assert_eq!(story(&mut s, a)["text"], text);
assert_eq!(story(&mut s, b)["text"], "");
assert!(s.execute("text.removeFromThread", &json!({"frame": b})).is_err());
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.clear", &json!({"ids": [b]})).unwrap();
assert_eq!(frames(&mut s, a), [a, c]);
assert_eq!(story(&mut s, a)["text"], text);
s.doc().unwrap().doc.check().unwrap();
}
}

View File

@ -129,6 +129,63 @@ fn define(s: &mut Session, p: &Value) -> Result<Value> {
mod tests {
use super::*;
/// A parent-page page-number marker follows Numbering & Section Options: changing a section's
/// start number renames its pages without changing their index or the number of sections, and
/// the marker composed for a page shows the new name.
#[test]
fn parent_page_number_follows_section_renumbering() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap();
let marker = designcraft_doc::story::PAGE_NUMBER.to_string();
let r = s
.execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 30, 500, 60], "content": "text", "text": marker}))
.unwrap();
let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story;
assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, marker);
let shown = |s: &Session, page: usize| -> usize {
let d = s.doc().unwrap().doc.clone();
let cs = s.cache.get(&d, sid, Some(page));
cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum()
};
assert_eq!(shown(&s, 2), 1, "page 3");
s.execute("layout.section", &json!({"page": 1, "startNumber": 100})).unwrap();
assert_eq!(s.doc().unwrap().doc.page_name(2), "102");
assert_eq!(shown(&s, 2), 3, "page 102 after renumbering");
}
/// Two sections that both number from 1 repeat page names ("1, 2, 1, 2"). A parent-page running
/// header is composed for the page it is shown on, not the first page with that name.
#[test]
fn running_header_on_parent_page_with_repeated_page_numbers() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 4, "facingPages": false})).unwrap();
s.execute("layout.section", &json!({"page": 3, "startNumber": 1})).unwrap();
let names: Vec<String> = (0..4).map(|i| s.doc().unwrap().doc.page_name(i)).collect();
assert_eq!(names, ["1", "2", "1", "2"]);
s.execute("style.paragraph.create", &json!({"name": "Heading"})).ok();
// The only heading is on the third page (the second section's page 1).
let sp = s.doc().unwrap().doc.page_loc(2).unwrap().0;
let r = s.execute("frame.create", &json!({"spread": sp, "rect": [72, 200, 400, 260], "content": "text", "text": "Epsilon"})).unwrap();
let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story;
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 r = s
.execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 30, 500, 60], "content": "text", "text": ""}))
.unwrap();
let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story;
s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap();
s.execute("variables.insert", &json!({"name": "Chapter Head"})).unwrap();
let d = s.doc().unwrap().doc.clone();
let shown = |page: usize| -> usize {
let cs = s.cache.get(&d, sid, Some(page));
cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum()
};
assert_eq!(shown(0), 0, "no heading on or before the first page");
assert_eq!(shown(2), "Epsilon".len(), "the third page (named 1) shows its own heading");
assert_eq!(shown(3), "Epsilon".len(), "the fourth page (named 2) carries it");
}
#[test]
fn running_header_on_parent_page_follows_headings() {
let mut s = Session::new();
@ -158,7 +215,7 @@ mod tests {
assert!(matches!(d.find(fid).unwrap().spread, designcraft_doc::SpreadRef::Parent(0)));
// Visible glyphs of the header composed for each page: "Alpha · 3" vs "Epsilon · 3".
let shown = |page: usize| -> usize {
let cs = s.cache.get(&d, sid, Some(&d.page_name(page)));
let cs = s.cache.get(&d, sid, Some(page));
cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum()
};
let (p1, p2, p3) = (shown(0), shown(1), shown(2));

View File

@ -305,7 +305,7 @@ pub fn specs() -> Vec<CommandSpec> {
match str_param(p, "path") {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
std::fs::write(path, text.as_bytes()).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?;
crate::atomic::write_file(path, text.as_bytes())?;
Ok(json!({"path": path, "bytes": text.len()}))
}
None => Ok(json!({"text": text, "bytes": text.len()})),

View File

@ -7,6 +7,8 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
#[cfg(not(target_arch = "wasm32"))]
pub mod atomic;
pub mod cmd;
pub mod dtd;
pub mod guard;
@ -91,6 +93,9 @@ pub struct DocState {
/// Editing parent spreads (Pages panel double-click on a parent).
pub editing_parents: bool,
pub uid: 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)>,
/// Unfilled document while data-merge preview is showing a record. Not saved.
pub preview_stash: Option<Arc<Document>>,
/// 1-based record currently previewed.
@ -129,6 +134,7 @@ impl DocState {
interaction: None,
editing_parents: false,
uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
recovery_entries: Vec::new(),
preview_stash: None,
preview_record: None,
fonts: None,
@ -177,6 +183,8 @@ pub struct Prefs {
pub rich_black_output: bool,
/// Font menu favourites (family names).
pub favorite_fonts: Vec<String>,
/// Fonts picked in the font menus, most recent first (shown at the menus' top).
pub recent_fonts: Vec<String>,
/// Preferences › Type › Show Font Names in English: the font menus show CJK families by their
/// English names instead of their native ones. Documents store the English name either way.
pub show_font_names_in_english: bool,
@ -233,6 +241,7 @@ impl Default for Prefs {
highlight_substituted_fonts: true,
rich_black_output: false,
favorite_fonts: Vec::new(),
recent_fonts: Vec::new(),
show_font_names_in_english: false,
smart_text_reflow: true,
autocorrect: false,
@ -267,6 +276,8 @@ pub struct Session {
pub(crate) transforms: (Vec<(String, Value)>, Vec<designcraft_doc::ItemId>, bool),
/// Drag and drop text editing: the press landed in the selected text.
pub(crate) text_drag: bool,
/// Character formatting chosen at a bare caret, for the text typed there next.
pub(crate) typing_format: Option<cmd::text::TypingFormat>,
/// The open Object Library (File › New / Open Library).
pub library: Option<cmd::library::Library>,
/// The open book (File › Open Book).
@ -298,6 +309,7 @@ impl Session {
loaded: None,
transforms: Default::default(),
text_drag: false,
typing_format: None,
library: None,
conveyor: Vec::new(),
book: None,
@ -374,8 +386,9 @@ impl Session {
self.stop_data_preview();
}
#[cfg(not(target_arch = "wasm32"))]
if let (Some(dir), Some(d)) = (&self.recovery_dir, self.docs.get(i)) {
recovery::discard(dir, d.uid);
{
let recovery_dir = self.recovery_dir.clone();
recovery::discard_document(self, i, recovery_dir.as_deref());
}
if i < self.docs.len() {
self.docs.remove(i);
@ -465,7 +478,7 @@ impl Session {
}
let mut d = (*st.doc).clone();
if overset {
let _ = cmd::place_text_autoflow(&mut d, sid, 500);
let _ = cmd::place_text_autoflow(&mut d, sid, 500, "Smart Text Reflow");
} else {
// Drop empty trailing frames on the last pages (one frame, nothing else there).
while let Some(story) = d.story(sid) {

File diff suppressed because it is too large Load Diff

View File

@ -9,7 +9,8 @@
//!
//! **Script text** ([`parse`]): a JSON array of steps, JSON lines (`{"command": …, "params": …}`),
//! or compact lines `command.id {json}` / `command.id={json}` / `command.id`; blank lines and
//! lines starting with `#` or `//` are ignored.
//! lines starting with `#` or `//` are ignored. A leading byte order mark and CRLF line endings are
//! accepted.
use serde_json::{Value, json};
@ -22,7 +23,9 @@ pub struct Step {
/// Parse script text (see the module docs).
pub fn parse(text: &str) -> Result<Vec<Step>, String> {
let t = text.trim();
// Windows tools save "UTF-8 with BOM"; `trim` keeps U+FEFF, which then became part of the
// first command id.
let t = text.trim_start_matches('\u{feff}').trim();
if t.starts_with('[') {
let v: Value = serde_json::from_str(t).map_err(|e| format!("script: {e}"))?;
return v
@ -174,6 +177,17 @@ mod tests {
assert!(parse("frame.create {bad").unwrap_err().contains("line 1"));
}
/// Windows editors and PowerShell 5.1 save "UTF-8 with BOM" with CRLF line endings; the BOM
/// became part of the first command id (#654).
#[test]
fn a_leading_bom_and_crlf_parse_like_plain_text() {
let plain = "file.new {\"width\": 100}\nframe.create {\"rect\": [0, 0, 10, 10]}\n";
let windows = format!("\u{feff}{}", plain.replace('\n', "\r\n"));
assert_eq!(parse(&windows).unwrap(), parse(plain).unwrap());
let array = r#"[{"command": "file.new"}]"#;
assert_eq!(parse(&format!("\u{feff}{array}")).unwrap(), parse(array).unwrap());
}
#[test]
fn resolves_references() {
let results = vec![json!({"id": 8, "story": 9, "ids": [4, 5]}), json!({"name": "A"})];

View File

@ -284,7 +284,15 @@ fn place_gun_click_drag_and_into_frame() {
let f = s.execute("frame.create", &json!({"rect": [300, 300, 400, 400], "content": "graphic"})).unwrap()["id"].as_u64().unwrap();
s.execute("place.load", &json!({"base64": b64})).unwrap();
s.execute("place.drop", &json!({"frame": f})).unwrap();
assert!(matches!(s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().content, designcraft_doc::Content::Graphic(_)));
let graphic_xf = |s: &Session| match &s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().content {
designcraft_doc::Content::Graphic(g) => g.xf,
_ => panic!("not a graphic"),
};
let placed = graphic_xf(&s);
// Dragging the top edge down crops the top only: the image stays where it is.
s.execute("selection.set", &json!({"ids": [f]})).unwrap();
s.execute("transform.resize", &json!({"from": [300, 300, 400, 400], "to": [300, 340, 400, 400]})).unwrap();
assert_eq!(graphic_xf(&s), placed);
}
#[test]
@ -378,9 +386,11 @@ fn step_and_repeat_grid() {
fn step_and_repeat_with_a_huge_grid_makes_no_grid() {
let mut s = session();
s.execute("frame.create", &json!({"rect": [36, 36, 66, 66], "content": "unassigned"})).unwrap();
let r = s.execute("edit.stepAndRepeat", &json!({"rows": 4_294_967_296_u64, "columns": 4_294_967_296_u64})).unwrap();
// As for any grid of more than 1000 copies: `count` (default 1) copies instead.
assert_eq!(r["created"], 1);
let items = |s: &Session| s.doc().unwrap().doc.spreads[0].items.len();
let before = items(&s);
// Counts past the limit are an error, and nothing is copied.
assert!(s.execute("edit.stepAndRepeat", &json!({"rows": 4_294_967_296_u64, "columns": 4_294_967_296_u64})).is_err());
assert_eq!(items(&s), before);
}
/// The column count is the caller's and sizes a list every time the page's columns are laid out.
@ -559,3 +569,103 @@ fn commands_take_non_object_params_without_panicking() {
assert!(r.is_ok(), "{p}: {r:?}");
}
}
/// Load the text from a frame's out port with the Selection tool and click on the pasteboard:
/// a new frame is threaded there and the overset text flows into it.
#[test]
fn threading_with_the_loaded_text_cursor_on_the_sample() {
use designcraft_tools::{PointerEvent, PointerKind};
let mut s = Session::new();
s.execute("file.newSample", &json!({})).unwrap();
let d = s.doc().unwrap().doc.clone();
let (sid, first) = d.stories.values().find(|st| st.frames.len() >= 2).map(|st| (st.id, st.frames[0])).expect("a threaded story");
// Break the thread so the first frame is overset, and select it.
s.execute("text.unthread", &json!({"frame": first.0})).unwrap();
assert!(s.cache.get(&s.doc().unwrap().doc, sid, None).is_overset());
s.execute("selection.set", &json!({"ids": [first.0]})).unwrap();
s.set_tool("selection");
let v = ViewInfo::at_zoom(1.0);
let layout = s.layout();
let port = designcraft_tools::thread::port_center(&s.doc().unwrap().doc, &layout, first, true, v.zoom).unwrap();
let ev = |k, p: designcraft_geom::Point| PointerEvent::new(k, p.x, p.y);
s.pointer(&ev(PointerKind::Down, port), v).unwrap();
s.pointer(&ev(PointerKind::Up, port), v).unwrap();
assert_eq!(s.cursor(port + designcraft_geom::Vec2::new(-2000.0, 0.0), Default::default(), v), designcraft_tools::Cursor::LoadedText);
// On the pasteboard left of the first spread.
let b = layout.slots[0].bounds;
let at = designcraft_geom::Point::new(b.x0 - 300.0, b.y0 + 100.0);
let undo = s.doc().unwrap().history.undo.len();
s.pointer(&ev(PointerKind::Down, at), v).unwrap();
s.pointer(&ev(PointerKind::Up, at), v).unwrap();
let st = s.doc().unwrap();
assert_eq!(st.history.undo.len(), undo + 1, "one undo step");
let frames = st.doc.story(sid).unwrap().frames.clone();
assert_eq!(frames.len(), 2);
let new = frames[1];
assert_eq!(st.selection.items, [new]);
let cs = s.cache.get(&s.doc().unwrap().doc, sid, None);
assert!(cs.frames.iter().any(|f| f.frame == new && !f.range.is_empty()), "the text flows into the new frame");
let nb = s.doc().unwrap().doc.item(new).unwrap().bounds();
assert!((nb.y0 - layout.to_spread(designcraft_doc::SpreadRef::Doc(0), at).y).abs() < 1e-6, "{nb:?}");
}
/// Drag a selection handle of item 0 on spread 0 from the edge point `at` by `by` (canvas units).
fn drag_handle(s: &mut Session, at: designcraft_geom::Point, by: designcraft_geom::Vec2, v: ViewInfo) {
use designcraft_tools::{PointerEvent, PointerKind};
let p = s.layout().to_canvas(designcraft_doc::SpreadRef::Doc(0), at);
s.pointer(&PointerEvent::new(PointerKind::Down, p.x, p.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, p.x + by.x / 2.0, p.y + by.y / 2.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, p.x + by.x, p.y + by.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, p.x + by.x, p.y + by.y), v).unwrap();
}
#[test]
fn text_frame_resize_snaps_to_the_margins() {
use designcraft_geom::{Point, Vec2};
for zoom in [1.0, 0.5] {
let mut s = Session::new();
s.execute("file.newSample", &json!({})).unwrap();
let margin = s.doc().unwrap().doc.spreads[0].pages[0].margin_rect();
let (x0, y0) = (margin.x0 + 40.0, margin.y0 + 40.0);
s.execute("frame.create", &json!({"rect": [x0, y0, x0 + 200.0, y0 + 200.0], "content": "text"})).unwrap();
s.set_tool("selection");
let v = ViewInfo::at_zoom(zoom);
let b0 = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds();
// Right handle to 1.5 pt short of the right margin.
drag_handle(&mut s, Point::new(b0.x1, b0.center().y), Vec2::new(margin.x1 - 1.5 - b0.x1, 0.0), v);
let b = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds();
assert!((b.x1 - margin.x1).abs() < 1e-6, "zoom {zoom}: right edge {} not on the margin {}", b.x1, margin.x1);
// Bottom handle to 1.5 pt past the bottom margin.
drag_handle(&mut s, Point::new(b.center().x, b.y1), Vec2::new(0.0, margin.y1 + 1.5 - b.y1), v);
let b = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds();
assert!((b.y1 - margin.y1).abs() < 1e-6, "zoom {zoom}: bottom edge {} not on the margin {}", b.y1, margin.y1);
// Top-left corner handle onto the top and left margins.
drag_handle(&mut s, Point::new(b.x0, b.y0), Vec2::new(margin.x0 + 1.0 - b.x0, margin.y0 - 1.0 - b.y0), v);
let b = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds();
assert!((b.x0 - margin.x0).abs() < 1e-6 && (b.y0 - margin.y0).abs() < 1e-6, "zoom {zoom}: corner {b:?} not on the margins {margin:?}");
}
}
#[test]
fn type_tool_frames_snap_to_the_margins() {
use designcraft_geom::Point;
use designcraft_tools::{PointerEvent, PointerKind};
let mut s = session();
let margin = s.doc().unwrap().doc.spreads[0].pages[0].margin_rect();
s.set_tool("type");
let v = ViewInfo::at_zoom(1.0);
let c = |s: &Session, x: f64, y: f64| s.layout().to_canvas(designcraft_doc::SpreadRef::Doc(0), Point::new(x, y));
// From 1.5 pt off the top-left margin corner to 1.5 pt off the right margin.
let a = c(&s, margin.x0 + 1.5, margin.y0 + 1.5);
let b = c(&s, margin.x1 - 1.5, margin.y0 + 101.5);
let before = s.doc().unwrap().doc.spreads[0].items.len();
s.pointer(&PointerEvent::new(PointerKind::Down, a.x, a.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, (a.x + b.x) / 2.0, b.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, b.x, b.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, b.x, b.y), v).unwrap();
let items = &s.doc().unwrap().doc.spreads[0].items;
assert_eq!(items.len(), before + 1, "a frame was drawn");
let r = items.last().unwrap().bounds();
assert!((r.x0 - margin.x0).abs() < 1e-6 && (r.y0 - margin.y0).abs() < 1e-6, "press point {r:?} vs {margin:?}");
assert!((r.x1 - margin.x1).abs() < 1e-6, "right edge {} vs {}", r.x1, margin.x1);
}

View File

@ -91,6 +91,91 @@ fn magazine_round_trips_through_idml() {
assert_eq!(items(&again.spreads).len(), items(&back.spreads).len());
}
/// A one-page IDML package: two threaded frames side by side, 19 lines each (10 pt on 12 pt),
/// paragraphs of `lines` forced-break lines in style `Body`, which carries `body_attrs`.
fn keep_lines_idml(body_attrs: &str, lines: &[usize]) -> Vec<u8> {
use std::io::Write;
let paras: String = lines
.iter()
.enumerate()
.map(|(p, &n)| {
let text = (0..n).map(|k| format!("P{p} line {k}")).collect::<Vec<_>>().join("\u{2028}");
format!(
r#"<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/Body"><CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>{text}</Content>{}</CharacterStyleRange></ParagraphStyleRange>"#,
if p + 1 < lines.len() { "<Br/>" } else { "" }
)
})
.collect();
let frame = |me: &str, x: f64, prev: &str, next: &str| {
let pts: String = [(x, 36.0), (x, 276.0), (x + 180.0, 276.0), (x + 180.0, 36.0)]
.iter()
.map(|(x, y)| format!(r#"<PathPointType Anchor="{x} {y}" LeftDirection="{x} {y}" RightDirection="{x} {y}"/>"#))
.collect();
format!(
r#"<TextFrame Self="{me}" ParentStory="s1" PreviousTextFrame="{prev}" NextTextFrame="{next}" ItemTransform="1 0 0 1 0 0"><Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>{pts}</PathPointArray></GeometryPathType></PathGeometry></Properties></TextFrame>"#
)
};
let designmap = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Document xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0" Self="d">
<DocumentPreference PageWidth="450" PageHeight="312" FacingPages="false"/>
<RootCharacterStyleGroup Self="rc"><CharacterStyle Self="CharacterStyle/$ID/[No character style]" Name="$ID/[No character style]"/></RootCharacterStyleGroup>
<RootParagraphStyleGroup Self="rp">
<ParagraphStyle Self="ParagraphStyle/$ID/[No paragraph style]" Name="$ID/[No paragraph style]" PointSize="12"/>
<ParagraphStyle Self="ParagraphStyle/Body" Name="Body" PointSize="10" {body_attrs}><Properties><BasedOn type="object">ParagraphStyle/$ID/[No paragraph style]</BasedOn><Leading type="unit">12</Leading></Properties></ParagraphStyle>
</RootParagraphStyleGroup>
<Story Self="s1">{paras}</Story>
<Spread Self="sp1" ItemTransform="1 0 0 1 0 0"><Page Self="p1" GeometricBounds="0 0 312 450" ItemTransform="1 0 0 1 0 0"/>{}{}</Spread>
</Document>"#,
frame("t1", 36.0, "n", "t2"),
frame("t2", 234.0, "t1", "n")
);
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
w.start_file("mimetype", stored).unwrap();
w.write_all(b"application/vnd.adobe.indesign-idml-package").unwrap();
w.start_file("designmap.xml", zip::write::SimpleFileOptions::default()).unwrap();
w.write_all(designmap.as_bytes()).unwrap();
w.finish().unwrap().into_inner()
}
/// Lines of paragraph `para` in each frame of the story.
fn para_lines_per_frame(d: &Document, para: usize) -> Vec<usize> {
let sid = *d.stories.keys().next().unwrap();
let cs = designcraft_compose::compose_story(d, sid, &Default::default());
cs.frames.iter().map(|f| f.lines.iter().filter(|l| l.para == para).count()).collect()
}
/// Keep Lines Together without a mode in the IDML: InDesign's default, at start/end of paragraph.
/// A 10-line paragraph starting 6 lines above the first frame's bottom splits 6/4.
#[test]
fn idml_keep_lines_without_mode_split_at_start_and_end() {
let keep = r#"KeepLinesTogether="true" KeepFirstLines="2" KeepLastLines="2""#;
let d = designcraft_idml::import_idml(&keep_lines_idml(keep, &[13, 10, 2])).unwrap();
assert_eq!(para_lines_per_frame(&d, 0), [13, 0]);
assert_eq!(para_lines_per_frame(&d, 1), [6, 4]);
// All lines in paragraph, written explicitly: the paragraph moves whole.
let d = designcraft_idml::import_idml(&keep_lines_idml(&format!(r#"{keep} KeepAllLinesTogether="true""#), &[13, 10, 2])).unwrap();
assert_eq!(para_lines_per_frame(&d, 1), [0, 10]);
}
/// A document saved in format 1 with Keep Lines Together and no mode still keeps all lines
/// together after loading; saved now, it keeps lines at start/end of paragraph.
#[test]
fn format_1_keep_lines_without_mode_lays_out_all_lines() {
use std::io::Write;
let d = designcraft_idml::import_idml(&keep_lines_idml(r#"KeepLinesTogether="true""#, &[13, 10, 2])).unwrap();
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
w.start_file("meta.json", zip::write::SimpleFileOptions::default()).unwrap();
w.write_all(br#"{"format": "designcraft", "version": 1}"#).unwrap();
w.start_file("document.json", zip::write::SimpleFileOptions::default()).unwrap();
w.write_all(&serde_json::to_vec(&d).unwrap()).unwrap();
let v1 = designcraft_format::load(&w.finish().unwrap().into_inner()).unwrap();
assert_eq!(para_lines_per_frame(&v1, 1), [0, 10]);
let now = designcraft_format::load(&designcraft_format::save(&d).unwrap()).unwrap();
assert_eq!(para_lines_per_frame(&now, 1), [6, 4]);
}
#[test]
fn idml_commands() {
let mut s = Session::new();
@ -251,3 +336,30 @@ fn named_lists_round_trip_through_idml() {
assert_eq!(st.paras[0].para.list_name.as_deref(), Some("Steps"));
assert_eq!(st.paras[0].para.start_at, Some(Some(5)));
}
#[test]
fn type_para_sets_a_drop_cap_that_survives_idml() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("style.character.create", &json!({"name": "Initial", "chars": {"fontStyle": "Bold"}})).unwrap();
let text = "Once upon a time there was a paragraph long enough to run beside its drop cap for a few lines and then some more.";
let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 400], "content": "text", "text": text})).unwrap();
let sid = r["story"].as_u64().unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 3, "focus": 3})).unwrap();
s.execute("type.para", &json!({"attrs": {"dropCapLines": 3, "dropCapChars": 1, "dropCapStyle": "Initial", "dropCapAlignLeft": false}})).unwrap();
let d = s.doc().unwrap().doc.clone();
let cs = designcraft_compose::compose_story(&d, designcraft_doc::StoryId(sid), &Default::default());
let lines = &cs.frames[0].lines;
let dc = &lines[0].glyphs[0];
assert_eq!(dc.byte, 0);
assert!((lines[0].baseline + dc.y - lines[2].baseline).abs() < 1e-6, "on line 3's baseline");
assert!(lines[0].drop_cap.is_some_and(|b| b.end == 1));
let p = &d.story(designcraft_doc::StoryId(sid)).unwrap().paras[0].para;
assert_eq!((p.drop_cap_lines, p.drop_cap_chars, p.drop_cap_style.as_deref()), (Some(3), Some(1), Some("Initial")));
let back = designcraft_idml::import_idml(&designcraft_idml::export_idml(&d)).unwrap();
let p = &back.stories.values().find(|st| st.text.starts_with("Once")).unwrap().paras[0].para;
assert_eq!((p.drop_cap_lines, p.drop_cap_chars, p.drop_cap_align_left), (Some(3), Some(1), Some(false)));
// The drop cap's character style goes through IDML as a leading Dropcap nested style.
assert_eq!(p.drop_cap_style.as_deref(), Some("Initial"));
assert_eq!(p.nested_styles, Some(vec![designcraft_doc::NestedStyle::drop_cap("Initial")]));
}

View File

@ -399,3 +399,125 @@ fn editing_beside_a_merged_cell_only_changes_the_selected_edge_segment() {
}
}
}
#[test]
fn split_selected_row_or_column_splits_both_cells() {
for horizontal in [true, false] {
let (mut s, sid, _) = session_with_frame();
let tid = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap();
let params = if horizontal { json!({"rows": [0, 0], "cols": [0, 1]}) } else { json!({"rows": [0, 1], "cols": [0, 0]}) };
s.execute("table.select", &params).unwrap();
let command = if horizontal { "table.splitHorizontally" } else { "table.splitVertically" };
s.execute(command, &json!({})).unwrap();
let table = table_for(&s, sid, tid);
assert_eq!((table.nrows(), table.ncols()), if horizontal { (3, 2) } else { (2, 3) });
for r in 0..2 {
for c in 0..2 {
assert_eq!(table.owner(r, c), (r, c), "both selected cells have independent halves");
}
}
check(&s);
}
}
#[test]
fn split_selected_block_and_merged_cells_preserves_geometry_and_history() {
for horizontal in [true, false] {
for merged in [false, true] {
let (mut s, sid, _) = session_with_frame();
let tid = s.execute("table.insert", &json!({"rows": 4, "cols": 4, "width": 240})).unwrap()["table"].as_u64().unwrap();
s.execute("table.setRowHeight", &json!({"table": tid, "height": 60, "mode": "exactly"})).unwrap();
s.execute("style.cell.create", &json!({"name": "Neighbor", "fill": "[Paper]", "insets": 3})).unwrap();
s.execute("style.cell.create", &json!({"name": "Chosen", "fill": "[Black]", "tint": 0.15, "insets": 2})).unwrap();
s.execute("style.cell.apply", &json!({"table": tid, "name": "Neighbor"})).unwrap();
for r in 0..4 {
for c in 0..4 {
s.execute("table.setCell", &json!({"row": r, "col": c, "text": format!("{r},{c}")})).unwrap();
}
}
let range = if !merged {
doc::CellRange::new(1, 1, 2, 2)
} else if horizontal {
doc::CellRange::new(0, 0, 2, 1)
} else {
doc::CellRange::new(0, 0, 1, 2)
};
let selection = json!({"rows": [range.r0, range.r1], "cols": [range.c0, range.c1]});
s.execute("table.select", &selection).unwrap();
s.execute("style.cell.apply", &json!({"name": "Chosen"})).unwrap();
if merged {
let merge = if horizontal { json!({"rows": [0, 2], "cols": [1, 1]}) } else { json!({"rows": [1, 1], "cols": [0, 2]}) };
s.execute("table.merge", &merge).unwrap();
s.execute("table.select", &selection).unwrap();
}
let before_doc = s.doc().unwrap().doc.clone();
let before_selection = s.doc().unwrap().selection.clone();
let before = table_for(&s, sid, tid).clone();
let before_layout = s.cache.get(&before_doc, doc::StoryId(sid), None);
let before_fragment = before_layout.frames.iter().flat_map(|f| &f.tables).find(|t| t.table == tid).unwrap();
let undo_len = s.doc().unwrap().history.undo.len();
let command = if horizontal { "table.splitHorizontally" } else { "table.splitVertically" };
s.execute(command, &json!({})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1, "one split command creates one undo entry");
let after_doc = s.doc().unwrap().doc.clone();
let after_selection = s.doc().unwrap().selection.clone();
let after = table_for(&s, sid, tid).clone();
let after_layout = s.cache.get(&after_doc, doc::StoryId(sid), None);
let after_fragment = after_layout.frames.iter().flat_map(|f| &f.tables).find(|t| t.table == tid).unwrap();
assert_eq!(after_fragment.rect, before_fragment.rect, "splitting does not resize the table");
assert_eq!(after_fragment.cells.len(), before_fragment.cells.len() + 4, "four original owners split once");
for placed in &before_fragment.cells {
let original = before.cell(placed.row, placed.col).unwrap();
let matches: Vec<_> = after_fragment.cells.iter().filter(|c| c.source == placed.source).collect();
assert_eq!(matches.len(), 1, "original cell text survives once: {}", placed.source);
let surviving = matches[0];
let cell = after.cell(surviving.row, surviving.col).unwrap();
let mut normalized = cell.clone();
normalized.row_span = original.row_span;
normalized.col_span = original.col_span;
assert_eq!(&normalized, original, "existing cell text and formatting are preserved");
if !range.contains(placed.row, placed.col) {
assert_eq!(surviving.rect, placed.rect, "unselected neighbor geometry is unchanged");
continue;
}
let second_addr = if horizontal {
(surviving.row + cell.row_span as usize, surviving.col)
} else {
(surviving.row, surviving.col + cell.col_span as usize)
};
let second = after.cell(second_addr.0, second_addr.1).unwrap();
assert_eq!(after.owner(second_addr.0, second_addr.1), second_addr);
assert!(second.text.is_empty());
assert_eq!(second.style, original.style);
assert_eq!(second.insets, original.insets);
assert_eq!(second.fill, original.fill);
assert_eq!(second.fill_tint, original.fill_tint);
let second_rect = after_fragment.cell(second_addr.0, second_addr.1).unwrap().rect;
assert_eq!(surviving.rect.union(second_rect), placed.rect, "the halves exactly cover the original cell");
if horizontal {
assert_eq!(surviving.rect.y1, second_rect.y0);
} else {
assert_eq!(surviving.rect.x1, second_rect.x0);
}
}
let text = s.doc().unwrap().selection.text.unwrap();
let address = text.cell.unwrap();
assert_eq!(after.owner(address.row, address.col), (address.row, address.col));
assert!(text.focus <= after.cell(address.row, address.col).unwrap().text.len());
let cells = s.doc().unwrap().selection.cells.unwrap();
assert!(cells.range.r1 < after.nrows() && cells.range.c1 < after.ncols());
check(&s);
s.execute("edit.undo", &json!({})).unwrap();
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before_doc));
assert_eq!(s.doc().unwrap().selection, before_selection);
assert_eq!(s.doc().unwrap().history.undo.len(), undo_len);
s.execute("edit.redo", &json!({})).unwrap();
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &after_doc));
assert_eq!(s.doc().unwrap().selection, after_selection);
let bytes = crate::cmd::file_bytes(&s.doc().unwrap().doc);
let reopened = crate::cmd::file_from(&bytes).unwrap();
reopened.check().unwrap();
assert_eq!(reopened.story(doc::StoryId(sid)).unwrap().tables.get(&tid).unwrap().as_ref(), &after);
}
}
}

View File

@ -0,0 +1,188 @@
#![cfg(not(target_arch = "wasm32"))]
use designcraft_engine::Session;
use serde_json::json;
use std::path::Path;
fn dir(name: &str) -> tempfile::TempDir {
tempfile::Builder::new().prefix(&format!("dc-book-safety-{name}-")).tempdir().unwrap()
}
fn document(start: u32, custom: bool) -> designcraft_doc::Document {
let mut s = Session::new();
s.execute("file.new", &json!({"pages":2})).unwrap();
let mut d = (*s.doc().unwrap().doc).clone();
d.sections.first_mut().unwrap().start_number = Some(start);
if custom {
let mut style = d.styles.paragraph.get(1).unwrap().clone();
style.name = "Synthetic Book Style".into();
d.styles_mut().paragraph.push(style);
}
d
}
fn native(p: &Path, start: u32, custom: bool) {
std::fs::write(p, designcraft_format::save(&document(start, custom)).unwrap()).unwrap();
}
fn idml(p: &Path, start: u32) {
std::fs::write(p, designcraft_idml::export_idml(&document(start, false))).unwrap();
assert!(designcraft_idml::import_idml(&std::fs::read(p).unwrap()).is_ok());
}
fn book(d: &Path, chapters: &[&Path]) -> Session {
let mut s = Session::new();
s.execute("book.new", &json!({"path":d.join("book.dcbook").to_string_lossy()})).unwrap();
for p in chapters {
s.execute("book.add", &json!({"path":p.to_string_lossy()})).unwrap();
}
s
}
#[test]
fn late_missing_chapter_does_not_partially_paginate_earlier_chapters() {
let d = dir("late-missing");
let first = d.path().join("first.designcraft");
let later = d.path().join("later.designcraft");
native(&first, 9, false);
native(&later, 1, false);
let mut s = book(d.path(), &[&first, &later]);
let before = std::fs::read(&first).unwrap();
std::fs::remove_file(later).unwrap();
let result = s.execute("book.paginate", &json!({}));
assert!(result.is_err());
let after = std::fs::read(&first).unwrap();
assert!(
before == after,
"book.paginate changed the first chapter before error: {result:?}; new first number {:?}",
designcraft_format::load(&after).unwrap().sections.first().unwrap().start_number
);
}
#[test]
fn late_corrupt_chapter_does_not_partially_sync_earlier_chapters() {
let d = dir("late-corrupt");
let source = d.path().join("source.designcraft");
let first = d.path().join("first.designcraft");
let later = d.path().join("later.designcraft");
native(&source, 1, true);
native(&first, 1, false);
native(&later, 1, false);
let mut s = book(d.path(), &[&source, &first, &later]);
let before = std::fs::read(&first).unwrap();
std::fs::write(later, b"not a document").unwrap();
let result = s.execute("book.syncStyles", &json!({}));
assert!(result.is_err());
let after = std::fs::read(&first).unwrap();
assert!(
before == after,
"book.syncStyles changed the first target before error: {result:?}; copied custom style {}",
designcraft_format::load(&after).unwrap().styles.paragraph.iter().any(|s| s.name == "Synthetic Book Style")
);
}
#[test]
fn paginate_does_not_replace_imported_idml_with_native_format() {
let d = dir("idml-paginate");
let chapter = d.path().join("chapter.idml");
idml(&chapter, 9);
let mut s = book(d.path(), &[&chapter]);
let before = std::fs::read(&chapter).unwrap();
let result = s.execute("book.paginate", &json!({}));
let after = std::fs::read(&chapter).unwrap();
assert!(
before == after,
"book.paginate rewrote imported IDML: {result:?}; IDML readable after {}, native readable after {}",
designcraft_idml::import_idml(&after).is_ok(),
designcraft_format::load(&after).is_ok()
);
}
#[test]
fn style_sync_does_not_replace_imported_idml_with_native_format() {
let d = dir("idml-sync");
let source = d.path().join("source.designcraft");
let chapter = d.path().join("chapter.idml");
native(&source, 1, true);
idml(&chapter, 1);
let mut s = book(d.path(), &[&source, &chapter]);
let before = std::fs::read(&chapter).unwrap();
let result = s.execute("book.syncStyles", &json!({}));
let after = std::fs::read(&chapter).unwrap();
assert!(
before == after,
"book.syncStyles rewrote imported IDML: {result:?}; IDML readable after {}, native readable after {}",
designcraft_idml::import_idml(&after).is_ok(),
designcraft_format::load(&after).is_ok()
);
}
#[test]
fn an_open_unsaved_chapter_blocks_batch_writes_before_any_files_change() {
let d = dir("open-unsaved");
let first = d.path().join("first.designcraft");
let later = d.path().join("later.designcraft");
native(&first, 9, false);
native(&later, 5, false);
let mut s = book(d.path(), &[&first, &later]);
s.execute("file.open", &json!({"path":later.to_string_lossy()})).unwrap();
s.execute("frame.create", &json!({"rect":[36,36,200,100],"content":"text","text":"Unsaved chapter edit"})).unwrap();
let first_before = std::fs::read(&first).unwrap();
let later_before = std::fs::read(&later).unwrap();
let revision = s.doc().unwrap().revision;
let result = s.execute("book.paginate", &json!({}));
assert!(
result.is_err(),
"book.paginate accepted an open dirty chapter: {result:?}; first changed {}, open backing file changed {}",
std::fs::read(&first).unwrap() != first_before,
std::fs::read(&later).unwrap() != later_before
);
assert_eq!(std::fs::read(&first).unwrap(), first_before);
assert_eq!(std::fs::read(&later).unwrap(), later_before);
assert_eq!(s.doc().unwrap().revision, revision);
assert!(s.doc().unwrap().is_dirty());
let e = result.unwrap_err().to_string();
assert!(e.contains("later.designcraft") && e.contains("close"), "{e}");
}
#[cfg(unix)]
#[test]
fn an_alias_of_an_open_chapter_blocks_all_batch_writes() {
let d = dir("open-alias");
let first = d.path().join("first.designcraft");
let real = d.path().join("real.designcraft");
let alias = d.path().join("alias.designcraft");
native(&first, 9, false);
native(&real, 5, false);
std::os::unix::fs::symlink("real.designcraft", &alias).unwrap();
let mut s = book(d.path(), &[&first, &alias]);
s.execute("file.open", &json!({"path":real.to_string_lossy()})).unwrap();
let first_before = std::fs::read(&first).unwrap();
let real_before = std::fs::read(&real).unwrap();
let result = s.execute("book.paginate", &json!({}));
assert!(result.is_err(), "book.paginate accepted alias of open chapter: {result:?}");
assert_eq!(std::fs::read(&first).unwrap(), first_before);
assert_eq!(std::fs::read(&real).unwrap(), real_before);
let e = result.unwrap_err().to_string();
assert!(e.contains("alias.designcraft") && e.contains("close"), "{e}");
assert!(std::fs::symlink_metadata(alias).unwrap().file_type().is_symlink());
}
#[test]
fn imported_idml_remains_a_read_only_style_source() {
let d = dir("idml-style-source");
let source = d.path().join("source.idml");
let target = d.path().join("target.designcraft");
std::fs::write(&source, designcraft_idml::export_idml(&document(1, true))).unwrap();
native(&target, 1, false);
let mut s = book(d.path(), &[&source, &target]);
let before = std::fs::read(&source).unwrap();
let result = s.execute("book.syncStyles", &json!({})).unwrap();
assert_eq!(result["synced"], 1);
assert_eq!(std::fs::read(&source).unwrap(), before);
assert!(designcraft_idml::import_idml(&before).is_ok());
let target = designcraft_format::load(&std::fs::read(target).unwrap()).unwrap();
assert!(target.styles.paragraph.iter().any(|s| s.name == "Synthetic Book Style"));
}
#[test]
fn imported_idml_still_exports_to_book_pdf_without_source_changes() {
let d = dir("idml-pdf");
let chapter = d.path().join("chapter.idml");
idml(&chapter, 1);
let mut s = book(d.path(), &[&chapter]);
let before = std::fs::read(&chapter).unwrap();
let result = s.execute("book.exportPdf", &json!({})).unwrap();
assert_eq!(result["pages"], 2);
assert!(result["base64"].as_str().is_some_and(|s| !s.is_empty()));
assert_eq!(std::fs::read(chapter).unwrap(), before);
}

View File

@ -0,0 +1,274 @@
//! Character Styles panel's existing `fromSelection` command contract.
use designcraft_doc::{CharAttrs, StoryId};
use designcraft_engine::Session;
use serde_json::{Value, json};
fn story(text: &str) -> (Session, u64) {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let frame = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": text})).unwrap();
(s, frame["story"].as_u64().unwrap())
}
fn select(s: &mut Session, sid: u64, anchor: usize, focus: usize) {
s.execute("text.select", &json!({"story": sid, "anchor": anchor, "focus": focus})).unwrap();
}
fn chars(s: &Session, name: &str) -> CharAttrs {
s.doc().unwrap().doc.styles.char_style(name).unwrap().chars.clone()
}
fn document(s: &Session) -> Value {
serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap()
}
#[test]
fn character_capture_overlays_style_chain_local_and_explicit_attrs_but_inherits_paragraph() {
let (mut s, sid) = story("alpha\nbeta");
s.execute("style.paragraph.create", &json!({"name": "Source paragraph", "chars": {"size": 18}})).unwrap();
s.execute("style.paragraph.create", &json!({"name": "Destination", "chars": {"size": 30, "underline": true}})).unwrap();
s.execute("style.character.create", &json!({"name": "Base", "chars": {"underline": true, "tracking": 10, "strikethrough": true}})).unwrap();
s.execute("style.character.create", &json!({"name": "Emphasis", "basedOn": "Base", "chars": {"fontStyle": "Italic"}})).unwrap();
select(&mut s, sid, 0, 5);
s.execute("style.paragraph.apply", &json!({"name": "Source paragraph"})).unwrap();
s.execute("style.character.apply", &json!({"name": "Emphasis"})).unwrap();
s.execute("type.char", &json!({"tracking": 20, "underline": false})).unwrap();
s.execute("style.character.create", &json!({"name": "Captured", "fromSelection": true, "chars": {"tracking": 7}})).unwrap();
assert_eq!(
chars(&s, "Captured"),
CharAttrs { font_style: Some("Italic".into()), underline: Some(false), tracking: Some(7.0), strikethrough: Some(true), ..Default::default() }
);
select(&mut s, sid, 6, 10);
s.execute("style.paragraph.apply", &json!({"name": "Destination"})).unwrap();
s.execute("style.character.apply", &json!({"name": "Captured"})).unwrap();
let resolved = s.execute("type.selectionAttrs", &json!({})).unwrap();
assert_eq!(resolved["chars"]["fontStyle"], "Italic");
assert_eq!(resolved["chars"]["strikethrough"], true);
assert_eq!(resolved["chars"]["underline"], false);
assert_eq!(resolved["chars"]["tracking"], 7.0);
assert_eq!(resolved["chars"]["size"], 30.0);
s.execute("style.paragraph.edit", &json!({"name": "Destination", "chars": {"size": 34}})).unwrap();
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"], 34.0);
// The captured attributes are a snapshot, independent of future source-style edits.
s.execute("style.character.edit", &json!({"name": "Emphasis", "chars": {"fontStyle": "Bold"}})).unwrap();
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fontStyle"], "Italic");
}
#[test]
fn character_capture_reproducer_is_one_undo_step_and_survives_native_reopen() {
let (mut s, sid) = story("alpha beta");
select(&mut s, sid, 0, 5);
s.execute("type.char", &json!({"underline": true})).unwrap();
let before = document(&s);
let selection = s.doc().unwrap().selection.clone();
let undo_len = s.doc().unwrap().history.undo.len();
let source = serde_json::to_value(s.doc().unwrap().doc.story(StoryId(sid)).unwrap()).unwrap();
s.execute("style.character.create", &json!({"name": "Captured", "fromSelection": true})).unwrap();
assert_eq!(chars(&s, "Captured"), CharAttrs { underline: Some(true), ..Default::default() });
assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1);
assert_eq!(s.doc().unwrap().selection, selection);
assert_eq!(serde_json::to_value(s.doc().unwrap().doc.story(StoryId(sid)).unwrap()).unwrap(), source);
let after = document(&s);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(document(&s), before);
assert_eq!(s.doc().unwrap().selection, selection);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(document(&s), after);
assert_eq!(s.doc().unwrap().selection, selection);
// Both commands use the native ZIP format, without a filesystem fixture.
let saved = s.execute("file.serialize", &json!({})).unwrap();
let mut reopened = Session::new();
reopened.execute("file.openBytes", &json!({"name": "capture.designcraft", "base64": saved["base64"]})).unwrap();
assert_eq!(chars(&reopened, "Captured"), chars(&s, "Captured"));
select(&mut reopened, sid, 6, 10);
assert_eq!(reopened.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["underline"], false);
let before_apply = document(&reopened);
reopened.execute("style.character.apply", &json!({"name": "Captured"})).unwrap();
assert_eq!(reopened.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["underline"], true);
assert_eq!(reopened.doc().unwrap().doc.story(StoryId(sid)).unwrap().text, "alpha beta");
let after_apply = document(&reopened);
reopened.execute("edit.undo", &json!({})).unwrap();
assert_eq!(document(&reopened), before_apply);
reopened.execute("edit.redo", &json!({})).unwrap();
assert_eq!(document(&reopened), after_apply);
}
#[test]
fn character_capture_matches_paragraph_selection_and_caret_boundary_policy() {
let (mut s, sid) = story("éX");
select(&mut s, sid, 0, 2);
s.execute("type.char", &json!({"underline": true})).unwrap();
select(&mut s, sid, 2, 3);
s.execute("type.char", &json!({"underline": false})).unwrap();
// A selected character takes the run on the right; a caret takes the typing run on the left.
// Reversed and mixed selections start at the lowest byte offset, just like paragraph capture.
for (anchor, focus, underlined) in [(3, 2, false), (2, 2, true), (3, 0, true)] {
select(&mut s, sid, anchor, focus);
let p = s.execute("style.paragraph.create", &json!({"name": "Paragraph", "fromSelection": true})).unwrap();
let c = s.execute("style.character.create", &json!({"name": "Character", "fromSelection": true})).unwrap();
let p = s.doc().unwrap().doc.styles.para(p["name"].as_str().unwrap()).unwrap();
let c = chars(&s, c["name"].as_str().unwrap());
assert_eq!(c.underline, Some(underlined));
assert_eq!(c.underline, p.chars.underline);
assert_eq!(c.size, None, "unlike paragraph capture, character capture must not freeze paragraph defaults");
}
}
#[test]
fn character_capture_is_opt_in_and_no_text_uses_only_explicit_chars() {
let (mut s, sid) = story("alpha");
select(&mut s, sid, 0, 5);
s.execute("type.char", &json!({"underline": true})).unwrap();
for options in [json!({"name": "Plain"}), json!({"name": "Plain", "fromSelection": false})] {
let created = s.execute("style.character.create", &options).unwrap();
assert!(chars(&s, created["name"].as_str().unwrap()).is_empty());
}
assert!(s.doc().unwrap().doc.styles.char_style("Plain 2").is_some());
s.execute("selection.set", &json!({"ids": []})).unwrap();
s.execute("style.character.create", &json!({"name": "No target", "fromSelection": true, "basedOn": "Plain", "chars": {"underline": false}}))
.unwrap();
assert_eq!(chars(&s, "No target"), CharAttrs { underline: Some(false), ..Default::default() });
assert_eq!(s.doc().unwrap().doc.styles.char_style("No target").unwrap().based_on.as_deref(), Some("Plain"));
let (mut empty, empty_sid) = story("");
select(&mut empty, empty_sid, 0, 0);
empty.execute("style.character.create", &json!({"name": "Empty", "fromSelection": true})).unwrap();
assert!(chars(&empty, "Empty").is_empty());
}
#[test]
fn character_capture_retains_deleted_fill_and_reuses_it_after_native_reopen() {
for named_source in [false, true] {
let (mut s, sid) = story("alpha beta gamma");
s.execute("swatch.create", &json!({"name": "Ink", "color": "#ff0000"})).unwrap();
select(&mut s, sid, 0, 5);
if named_source {
s.execute("style.character.create", &json!({"name": "Base", "chars": {"fill": "Ink"}})).unwrap();
s.execute("style.character.create", &json!({"name": "Source", "basedOn": "Base"})).unwrap();
s.execute("style.character.apply", &json!({"name": "Source"})).unwrap();
} else {
s.execute("type.char", &json!({"fill": "Ink"})).unwrap();
}
s.execute("swatch.delete", &json!({"name": "Ink"})).unwrap();
s.doc().unwrap().doc.check().unwrap();
let before = document(&s);
let selection = s.doc().unwrap().selection.clone();
let undo_len = s.doc().unwrap().history.undo.len();
s.execute("style.character.create", &json!({"name": "Captured", "fromSelection": true})).unwrap();
assert_eq!(chars(&s, "Captured"), CharAttrs { fill: Some("Ink".into()), ..Default::default() });
assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1);
assert_eq!(s.doc().unwrap().selection, selection);
let after = document(&s);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(document(&s), before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(document(&s), after);
// Non-fill overrides do not turn an existing captured reference into explicit input.
s.execute("style.character.create", &json!({"name": "Tweaked", "fromSelection": true, "chars": {"underline": false, "tracking": 0}}))
.unwrap();
assert_eq!(chars(&s, "Tweaked"), CharAttrs { fill: Some("Ink".into()), underline: Some(false), tracking: Some(0.0), ..Default::default() });
s.execute("style.character.create", &json!({"name": "Paper", "fromSelection": true, "chars": {"fill": "[Paper]"}})).unwrap();
assert_eq!(chars(&s, "Paper"), CharAttrs { fill: Some("[Paper]".into()), ..Default::default() });
s.execute("style.character.create", &json!({"name": "Cleared fill", "fromSelection": true, "chars": {"fill": null}})).unwrap();
assert!(chars(&s, "Cleared fill").is_empty());
select(&mut s, sid, 6, 10);
s.execute("style.character.apply", &json!({"name": "Captured"})).unwrap();
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "Ink");
s.doc().unwrap().doc.check().unwrap();
let saved = s.execute("file.serialize", &json!({})).unwrap();
let mut reopened = Session::new();
reopened.execute("file.openBytes", &json!({"name": "deleted-fill.designcraft", "base64": saved["base64"]})).unwrap();
reopened.doc().unwrap().doc.check().unwrap();
for name in ["Captured", "Tweaked", "Paper", "Cleared fill"] {
assert_eq!(chars(&reopened, name), chars(&s, name));
}
select(&mut reopened, sid, 6, 10);
assert_eq!(reopened.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "Ink");
select(&mut reopened, sid, 11, 16);
assert_eq!(reopened.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "[Black]");
reopened.execute("style.character.apply", &json!({"name": "Captured"})).unwrap();
assert_eq!(reopened.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "Ink");
reopened.execute("style.character.apply", &json!({"name": "Cleared fill"})).unwrap();
assert_eq!(reopened.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "[Black]");
assert_eq!(reopened.doc().unwrap().doc.story(StoryId(sid)).unwrap().text, "alpha beta gamma");
}
}
#[test]
fn character_capture_rejects_explicit_missing_fills_without_losing_redo() {
let (mut s, sid) = story("alpha");
s.execute("swatch.create", &json!({"name": "Ink", "color": "#ff0000"})).unwrap();
select(&mut s, sid, 0, 5);
s.execute("type.char", &json!({"fill": "Ink"})).unwrap();
s.execute("swatch.delete", &json!({"name": "Ink"})).unwrap();
s.execute("style.character.create", &json!({"name": "Redo sentinel"})).unwrap();
let redo_doc = s.doc().unwrap().doc.clone();
let redo_selection = s.doc().unwrap().selection.clone();
s.execute("edit.undo", &json!({})).unwrap();
let before = s.doc().unwrap().clone();
let journal = s.journal.clone();
for (capture, fill) in [(true, "Missing"), (true, "Ink"), (false, "Missing")] {
let error = s.execute("style.character.create", &json!({"name": "Invalid", "fromSelection": capture, "chars": {"fill": fill}})).unwrap_err();
assert!(error.to_string().contains(fill));
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before.doc));
assert_eq!(s.doc().unwrap().selection, before.selection);
assert_eq!(s.doc().unwrap().revision, before.revision);
assert_eq!(format!("{:?}", s.doc().unwrap().history), format!("{:?}", before.history));
assert_eq!(s.journal, journal);
}
s.execute("edit.redo", &json!({})).unwrap();
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &redo_doc));
assert_eq!(s.doc().unwrap().selection, redo_selection);
assert!(s.doc().unwrap().doc.styles.char_style("Redo sentinel").is_some());
}
#[test]
fn character_capture_explicit_null_restores_live_paragraph_inheritance() {
for named_source in [false, true] {
let (mut s, sid) = story("alpha\nbeta");
select(&mut s, sid, 0, 5);
let source = json!({"tracking": 20, "underline": true, "underlineWeight": 1.5, "fontStyle": "Italic"});
if named_source {
s.execute("style.character.create", &json!({"name": "Base", "chars": source})).unwrap();
s.execute("style.character.create", &json!({"name": "Source", "basedOn": "Base"})).unwrap();
s.execute("style.character.apply", &json!({"name": "Source"})).unwrap();
} else {
s.execute("type.char", &source).unwrap();
}
// Omitting chars still captures values, including a nullable attribute's numeric value.
s.execute("style.character.create", &json!({"name": "Unchanged", "fromSelection": true})).unwrap();
assert_eq!(chars(&s, "Unchanged").tracking, Some(20.0));
assert_eq!(chars(&s, "Unchanged").underline_weight, Some(Some(1.5)));
let before = document(&s);
let undo_len = s.doc().unwrap().history.undo.len();
s.execute(
"style.character.create",
&json!({"name": "Cleared", "fromSelection": true,
"chars": {"tracking": null, "underlineWeight": null, "underline": false, "baselineShift": 0}}),
)
.unwrap();
assert_eq!(
chars(&s, "Cleared"),
CharAttrs { font_style: Some("Italic".into()), underline: Some(false), baseline_shift: Some(0.0), ..Default::default() }
);
assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1);
let after = document(&s);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(document(&s), before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(document(&s), after);
s.execute("style.paragraph.create", &json!({"name": "Destination", "chars": {"tracking": 30, "underlineWeight": 2}})).unwrap();
select(&mut s, sid, 6, 10);
s.execute("style.paragraph.apply", &json!({"name": "Destination"})).unwrap();
s.execute("style.character.apply", &json!({"name": "Cleared"})).unwrap();
let effective = s.execute("type.selectionAttrs", &json!({})).unwrap();
assert_eq!(effective["chars"]["tracking"], 30.0);
assert_eq!(effective["chars"]["underlineWeight"], 2.0);
assert_eq!(effective["chars"]["underline"], false);
s.execute("style.paragraph.edit", &json!({"name": "Destination", "chars": {"tracking": 40, "underlineWeight": 3}})).unwrap();
let effective = s.execute("type.selectionAttrs", &json!({})).unwrap();
assert_eq!(effective["chars"]["tracking"], 40.0);
assert_eq!(effective["chars"]["underlineWeight"], 3.0);
}
}

View File

@ -0,0 +1,87 @@
#![cfg(not(target_arch = "wasm32"))]
use designcraft_engine::Session;
use serde_json::json;
fn library_failure(command: &str) {
let temp = tempfile::tempdir().unwrap();
let folder = temp.path().join("library");
std::fs::create_dir(&folder).unwrap();
let file = folder.join("items.dclib");
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap();
s.execute("library.new", &json!({"path": file})).unwrap();
s.execute("library.add", &json!({"name": "Keep"})).unwrap();
let before = serde_json::to_value(&s.library).unwrap();
let path = s.library.as_ref().unwrap().path.clone();
let journal = s.journal.clone();
let original_bytes = std::fs::read(&file).unwrap();
let kept = temp.path().join("kept-library");
std::fs::rename(&folder, &kept).unwrap();
assert!(s.execute(command, &json!({"name": "New", "index": 0})).is_err());
assert_eq!(before, serde_json::to_value(&s.library).unwrap(), "a failed library write must retain its original items");
assert_eq!(path, s.library.as_ref().unwrap().path);
assert_eq!(journal, s.journal);
assert_eq!(original_bytes, std::fs::read(kept.join("items.dclib")).unwrap());
}
#[test]
fn failed_library_add_keeps_existing_items() {
library_failure("library.add");
}
#[test]
fn failed_library_remove_keeps_existing_items() {
library_failure("library.remove");
}
fn book_failure(command: &str) {
let temp = tempfile::tempdir().unwrap();
let mut s = Session::new();
let mut chapters = Vec::new();
for i in 0..3 {
s.execute("file.new", &json!({})).unwrap();
let path = temp.path().join(format!("chapter-{i}.designcraft"));
s.execute("file.saveAs", &json!({"path": path})).unwrap();
chapters.push(path);
}
let folder = temp.path().join("book");
std::fs::create_dir(&folder).unwrap();
let file = folder.join("chapters.dcbook");
s.execute("book.new", &json!({"path": file})).unwrap();
s.execute("book.add", &json!({"path": chapters[0]})).unwrap();
s.execute("book.add", &json!({"path": chapters[1]})).unwrap();
s.execute("book.styleSource", &json!({"index": 1})).unwrap();
let before = serde_json::to_value(&s.book).unwrap();
let path = s.book.as_ref().unwrap().path.clone();
let journal = s.journal.clone();
let original_bytes = std::fs::read(&file).unwrap();
let kept = temp.path().join("kept-book");
std::fs::rename(&folder, &kept).unwrap();
let params = if command == "book.add" { json!({"path": chapters[2]}) } else { json!({"index": 0}) };
assert!(s.execute(command, &params).is_err());
assert_eq!(before, serde_json::to_value(&s.book).unwrap(), "a failed book write must retain its original chapter list and style source");
assert_eq!(path, s.book.as_ref().unwrap().path);
assert_eq!(journal, s.journal);
assert_eq!(original_bytes, std::fs::read(kept.join("chapters.dcbook")).unwrap());
}
#[test]
fn failed_book_add_keeps_existing_chapters() {
book_failure("book.add");
}
#[test]
fn failed_book_remove_keeps_existing_chapters() {
book_failure("book.remove");
}
#[test]
fn failed_book_style_source_keeps_existing_source() {
book_failure("book.styleSource");
}

View File

@ -0,0 +1,128 @@
//! Distribution preserves explicit item locks without changing reference/spacing calculations.
use std::sync::Arc;
use designcraft_doc::ItemId;
use designcraft_engine::Session;
use designcraft_geom::Affine;
use serde_json::{Value, json};
const STARTS: [f64; 4] = [10.0, 70.0, 150.0, 310.0];
const SIZES: [f64; 4] = [20.0, 30.0, 40.0, 50.0];
// An original, synthetic 1x1 RGBA PNG; used by both a locked and an unlocked frame.
const PIXEL: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGPYYqPxHwAE2AIYBVnQbwAAAABJRU5ErkJggg==";
fn fixture(horizontal: bool, locks: [bool; 4]) -> (Session, Vec<u64>) {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let mut ids = Vec::new();
for (i, (&start, &size)) in STARTS.iter().zip(&SIZES).enumerate() {
let cross = 40.0 + i as f64 * 35.0;
let rect = if horizontal { [start, cross, start + size, cross + 25.0] } else { [cross, start, cross + 25.0, start + size] };
let graphic = i == 1 || i == 2;
let created =
s.execute("frame.create", &json!({"rect": rect, "content": if graphic {"unassigned"} else {"text"}, "text": "Keep this text"})).unwrap();
let id = created["id"].as_u64().unwrap();
ids.push(id);
if graphic {
s.execute("file.place", &json!({"frame": id, "base64": PIXEL, "name": "pixel.png"})).unwrap();
}
}
// An unselected control must retain all geometry and content too.
s.execute("frame.create", &json!({"rect": [400, 400, 450, 450], "content": "text", "text": "Outside selection"})).unwrap();
s.execute("object.fill", &json!({"ids": ids, "swatch": "[Black]", "tint": 0.5})).unwrap();
let locked: Vec<u64> = ids.iter().zip(locks).filter_map(|(&id, lock)| lock.then_some(id)).collect();
if !locked.is_empty() {
s.execute("object.lock", &json!({"ids": locked})).unwrap();
}
// Layers selection may include locked items. Deliberately avoid geometric order.
s.execute("selection.set", &json!({"ids": [ids[3], ids[1], ids[0], ids[2]]})).unwrap();
assert!(!s.doc().unwrap().doc.assets.is_empty());
s.doc().unwrap().doc.check().unwrap();
(s, ids)
}
fn modes() -> [(Value, [f64; 4]); 3] {
[
// Endpoint centers 20 and 335 give a center step of 105.
(json!({"by": "centers"}), [10.0, 110.0, 210.0, 310.0]),
// Outer span 350 minus total widths 140 gives three gaps of 70.
(json!({"by": "spacing"}), [10.0, 100.0, 200.0, 310.0]),
// An explicit spacing value selects fixed-gap mode without requiring `by`.
(json!({"spacing": 15}), [10.0, 45.0, 90.0, 145.0]),
]
}
fn check_distribution(horizontal: bool, mut params: Value, positions: [f64; 4], locks: [bool; 4], explicit_ids: bool) {
let (mut s, ids) = fixture(horizontal, locks);
params["axis"] = json!(if horizontal { "horizontal" } else { "vertical" });
if explicit_ids {
params["ids"] = json!([ids[2], ids[0], ids[3], ids[1]]);
}
let before = s.doc().unwrap().doc.clone();
let selection = s.doc().unwrap().selection.clone();
let undo_len = s.doc().unwrap().history.undo.len();
let mut expected = (*before).clone();
for (i, &id) in ids.iter().enumerate() {
if !locks[i] {
let delta = positions[i] - STARTS[i];
let translation = if horizontal { (delta, 0.0) } else { (0.0, delta) };
let item = expected.item_mut(ItemId(id)).unwrap();
item.xf = Affine::translate(translation) * item.xf;
}
}
s.execute("object.distribute", &params).unwrap();
let after = s.doc().unwrap().doc.clone();
for (i, &id) in ids.iter().enumerate() {
let item = after.item(ItemId(id)).unwrap();
let b = item.bounds();
let start = if horizontal { b.x0 } else { b.y0 };
assert_eq!(start, if locks[i] { STARTS[i] } else { positions[i] }, "item {i}: {params}, locks={locks:?}");
if locks[i] {
assert_eq!(item, before.item(ItemId(id)).unwrap(), "locked item's transform, path, content and flags are unchanged");
}
}
// Only the specified translations may change: all paths, sizes, other-axis coordinates,
// paint, content, stories, layer membership, item order and embedded asset bytes are exact.
assert_eq!(*after, expected, "{params}, locks={locks:?}");
assert_eq!(s.doc().unwrap().selection, selection);
after.check().unwrap();
if expected != *before {
assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &before));
assert_eq!(s.doc().unwrap().selection, selection);
s.execute("edit.redo", &json!({})).unwrap();
assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &after));
assert_eq!(s.doc().unwrap().selection, selection);
}
}
#[test]
fn distribute_preserves_locked_items_and_moves_unlocked_controls() {
for horizontal in [true, false] {
for (params, positions) in modes() {
// Locked interior object still participates in the numeric reference calculation.
check_distribution(horizontal, params.clone(), positions, [false, true, false, false], false);
// Locked endpoints and an interior object; only the unlocked interior control moves.
check_distribution(horizontal, params, positions, [true, true, false, true], true);
}
}
}
#[test]
fn distribute_unlocked_spacing_modes_are_unchanged() {
for horizontal in [true, false] {
for (params, positions) in modes() {
check_distribution(horizontal, params, positions, [false; 4], true);
}
}
}
#[test]
fn distribute_all_locked_leaves_document_and_selection_unchanged() {
for horizontal in [true, false] {
for (params, positions) in modes() {
check_distribution(horizontal, params, positions, [true; 4], false);
}
}
}

View File

@ -0,0 +1,217 @@
//! Effective raster resolution follows image, frame and ancestor transforms in spread space.
use std::sync::Arc;
use designcraft_doc::{Content, Intent, ItemId};
use designcraft_engine::{Session, cmd::base64_encode};
use designcraft_geom::Affine;
use serde_json::{Value, json};
fn place(s: &mut Session, name: &str, bytes: &[u8]) -> ItemId {
s.execute("selection.set", &json!({"ids": []})).unwrap();
let placed = s.execute("file.place", &json!({"base64": base64_encode(bytes), "name": name, "x": 0, "y": 0, "width": 144})).unwrap();
ItemId(placed["id"].as_u64().unwrap())
}
fn raster() -> (Session, ItemId) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 1000, "height": 1000, "facingPages": false})).unwrap();
// 600 × 300 pixels at 144 × 72 points: 300 ppi in both directions.
let png = designcraft_render::Rendered { width: 600, height: 300, pixels: vec![200; 600 * 300 * 4] }.to_png();
let id = place(&mut s, "photo.png", &png);
assert_eq!(s.doc().unwrap().doc.item(id).unwrap().inner_bounds().size(), designcraft_geom::Size::new(144.0, 72.0));
(s, id)
}
fn facing_raster() -> (Session, ItemId) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 612, "height": 792, "pages": 3, "facingPages": true})).unwrap();
// Page 0 is the cover; spread 1 contains absolute pages 1 (left) and 2 (right).
let d = &s.doc().unwrap().doc;
assert_eq!(d.spreads.len(), 2);
assert_eq!(d.spreads[0].pages.len(), 1);
assert_eq!(d.spreads[1].pages.len(), 2);
assert_eq!(d.first_page_of_spread(1), 1);
assert_eq!(d.spreads[1].pages[0].x, 0.0);
assert_eq!(d.spreads[1].pages[1].x, 612.0);
let png = designcraft_render::Rendered { width: 600, height: 300, pixels: vec![200; 600 * 300 * 4] }.to_png();
let placed = s
.execute(
"file.place",
&json!({
"base64": base64_encode(&png), "name": "photo.png", "spread": 1,
"x": 0, "y": 0, "width": 144
}),
)
.unwrap();
let id = ItemId(placed["id"].as_u64().unwrap());
let d = &s.doc().unwrap().doc;
assert_eq!(d.find(id).unwrap().spread, designcraft_doc::SpreadRef::Doc(1));
assert_eq!(d.item(id).unwrap().inner_bounds().size(), designcraft_geom::Size::new(144.0, 72.0));
(s, id)
}
fn transform(s: &mut Session, id: ItemId, frame: Affine, image: Affine) {
s.edit(|d, _| {
let it = d.item_mut(id).unwrap();
it.xf = frame;
let Content::Graphic(g) = &mut it.content else { panic!("graphic fixture") };
g.xf = image * g.xf;
Ok(())
})
.unwrap();
}
fn group(s: &mut Session, child: ItemId, xf: Affine) -> ItemId {
let spread = s.doc().unwrap().doc.find(child).unwrap().spread;
let marker = s.execute("frame.create", &json!({"spread": spread, "rect": [0, 0, 1, 1]})).unwrap()["id"].clone();
let group = s.execute("object.group", &json!({"ids": [child.0, marker]})).unwrap();
let id = ItemId(group["id"].as_u64().unwrap());
s.edit(|d, _| {
d.item_mut(id).unwrap().xf = xf;
Ok(())
})
.unwrap();
id
}
fn resolutions(s: &mut Session, params: Value) -> Vec<Value> {
let before = s.doc().unwrap();
let doc = before.doc.clone();
let selection = before.selection.clone();
let state = (before.revision, before.history.undo.len(), before.history.redo.len(), s.journal.len());
let result = s.execute("preflight.run", &params).unwrap();
let after = s.doc().unwrap();
assert!(Arc::ptr_eq(&doc, &after.doc), "preflight must leave the document and assets untouched");
assert_eq!(selection, after.selection, "preflight must leave the selection untouched");
assert_eq!(state, (after.revision, after.history.undo.len(), after.history.redo.len(), s.journal.len()));
result["issues"].as_array().unwrap().iter().filter(|i| i["kind"] == "lowResolution").cloned().collect()
}
fn warning(s: &mut Session, id: ItemId, params: Value, ppi: u32, threshold: u32) {
warning_on_page(s, id, params, ppi, threshold, 0);
}
fn warning_on_page(s: &mut Session, id: ItemId, params: Value, ppi: u32, threshold: u32, page: usize) {
assert_eq!(
resolutions(s, params),
vec![json!({
"severity": "warning", "kind": "lowResolution", "item": id.0, "page": page,
"message": format!("photo.png: effective {ppi} ppi (< {threshold})")
})]
);
}
#[test]
fn either_raster_axis_can_fail_after_image_or_frame_scaling() {
for scale in [Affine::scale_non_uniform(4.0, 1.0), Affine::scale_non_uniform(1.0, 4.0)] {
for image_only in [false, true] {
let (mut s, id) = raster();
let (frame, image) = if image_only { (Affine::IDENTITY, scale) } else { (scale, Affine::IDENTITY) };
transform(&mut s, id, frame, image);
// One axis remains 300 ppi, the other is 75 ppi: report the worse axis once.
warning(&mut s, id, json!({}), 75, 150);
}
}
}
#[test]
fn nested_groups_accumulate_scale_and_keep_image_frame_parent_order() {
let (mut s, id) = raster();
let inner = group(&mut s, id, Affine::scale(2.0));
group(&mut s, inner, Affine::scale(2.0));
warning(&mut s, id, json!({}), 75, 150);
let (mut s, id) = raster();
// Double image X, then turn the frame 90 degrees, then triple spread X.
// The image axes become 288 and 216 pt, giving 150 and 100 ppi respectively.
transform(&mut s, id, Affine::new([0.0, 1.0, -1.0, 0.0, 400.0, 20.0]), Affine::scale_non_uniform(2.0, 1.0));
group(&mut s, id, Affine::scale_non_uniform(3.0, 1.0));
warning(&mut s, id, json!({}), 100, 150);
}
#[test]
fn translated_scaled_nested_raster_reports_right_facing_page() {
let (mut s, id) = facing_raster();
let inner = group(&mut s, id, Affine::scale(2.0));
group(&mut s, inner, Affine::translate((640.0, 0.0)) * Affine::scale(2.0));
// Local frame center x=72 is on the left page. Ancestors move its bounds to
// x=640..1216 on the right page, while scaling 300 ppi down to 75 ppi.
warning_on_page(&mut s, id, json!({}), 75, 150, 2);
}
#[test]
fn translating_group_changes_warning_page_without_changing_resolution() {
let (mut s, id) = facing_raster();
// A nonidentity frame transform also detects applying the frame transform twice.
transform(&mut s, id, Affine::translate((-240.0, 0.0)), Affine::IDENTITY);
warning_on_page(&mut s, id, json!({"minPpi": 301}), 300, 301, 1);
group(&mut s, id, Affine::translate((880.0, 0.0)));
// Translation moves the center to x=712, but does not change either image axis.
assert!(resolutions(&mut s, json!({})).is_empty());
warning_on_page(&mut s, id, json!({"minPpi": 301}), 300, 301, 2);
}
#[test]
fn rotation_translation_and_reflection_do_not_change_resolution() {
for xf in [
Affine::translate((250.0, 300.0)) * Affine::rotate(std::f64::consts::FRAC_PI_4),
Affine::translate((250.0, 300.0)) * Affine::scale_non_uniform(-1.0, 1.0),
] {
let (mut s, id) = raster();
transform(&mut s, id, xf, Affine::IDENTITY);
// Measuring the rotated bounding box instead would incorrectly warn here.
assert!(resolutions(&mut s, json!({"minPpi": 250})).is_empty());
warning(&mut s, id, json!({"minPpi": 301}), 300, 301);
}
}
#[test]
fn cropping_keeps_resolution_and_fitting_changes_it_with_content_scale() {
let (mut s, id) = raster();
s.execute("transform.resize", &json!({"ids": [id.0], "from": [0, 0, 144, 72], "to": [0, 0, 36, 18]})).unwrap();
warning(&mut s, id, json!({"minPpi": 301}), 300, 301);
s.execute("transform.resize", &json!({"ids": [id.0], "from": [0, 0, 36, 18], "to": [0, 0, 144, 216]})).unwrap();
warning(&mut s, id, json!({"minPpi": 301}), 300, 301);
s.execute("object.fit", &json!({"ids": [id.0], "mode": "fitProportionally"})).unwrap();
warning(&mut s, id, json!({"minPpi": 301}), 300, 301);
// Stretching just Y to 216 pt leaves X at 300 ppi but reduces Y to 100 ppi.
s.execute("object.fit", &json!({"ids": [id.0], "mode": "fitContentToFrame"})).unwrap();
warning(&mut s, id, json!({}), 100, 150);
// Proportional fill is 432 × 216 pt: 100 ppi in both directions.
s.execute("object.fit", &json!({"ids": [id.0], "mode": "fillProportionally"})).unwrap();
warning(&mut s, id, json!({}), 100, 150);
s.execute("object.fit", &json!({"ids": [id.0], "mode": "centerContent"})).unwrap();
warning(&mut s, id, json!({}), 100, 150);
}
#[test]
fn threshold_is_strict_and_resolution_remains_print_only() {
let (mut s, id) = raster();
transform(&mut s, id, Affine::scale_non_uniform(1.0, 2.0), Affine::IDENTITY);
assert!(resolutions(&mut s, json!({})).is_empty(), "150 ppi meets the default threshold");
assert!(resolutions(&mut s, json!({"minPpi": 149})).is_empty());
warning(&mut s, id, json!({"minPpi": 151}), 150, 151);
for intent in [Intent::Web, Intent::Mobile] {
s.edit(|d, _| {
d.settings.intent = intent;
Ok(())
})
.unwrap();
assert!(resolutions(&mut s, json!({"minPpi": 151})).is_empty());
}
}
#[test]
fn transformed_vector_previews_are_excluded_beside_rasters() {
let (mut s, id) = raster();
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="144" height="72"><rect width="144" height="72" fill="red"/></svg>"#;
let vector = place(&mut s, "vector.svg", svg);
let parent = s.execute("object.group", &json!({"ids": [id.0, vector.0]})).unwrap()["id"].as_u64().unwrap();
s.edit(|d, _| {
d.item_mut(ItemId(parent)).unwrap().xf = Affine::scale_non_uniform(1.0, 4.0);
Ok(())
})
.unwrap();
warning(&mut s, id, json!({}), 75, 150);
}

View File

@ -0,0 +1,34 @@
//! Primary text frames retain all columns supported by the new-document command.
use designcraft_doc::Document;
use designcraft_engine::Session;
use serde_json::json;
fn assert_primary_columns(doc: &Document, expected: u32) {
let mut frames = 0;
for story in doc.stories.values() {
for spec in designcraft_compose::frame_specs(doc, story.id) {
assert_eq!(spec.opts.columns, expected);
let columns = spec.columns();
assert_eq!(columns.len(), expected as usize);
assert!(columns.iter().all(|r| r.width() > 0. && [r.x0, r.x1, r.y0, r.y1].into_iter().all(f64::is_finite)));
frames += 1;
}
}
assert!(frames > 0, "the primary text frame must exist");
}
#[test]
fn primary_columns_survive_composition_native_reopen_and_idml_round_trip() {
for columns in [41, 216] {
let mut s = Session::new();
s.execute("file.new", &json!({"columns":columns,"gutter":0,"primaryTextFrame":true})).unwrap();
assert_primary_columns(&s.doc().unwrap().doc, columns);
let bytes = s.execute("file.serialize", &json!({})).unwrap();
let mut reopened = Session::new();
reopened.execute("file.openBytes", &json!({"name":"primary-columns.designcraft","base64":bytes["base64"]})).unwrap();
assert_primary_columns(&reopened.doc().unwrap().doc, columns);
let idml = designcraft_idml::export_idml(&reopened.doc().unwrap().doc);
let imported = designcraft_idml::import_idml(&idml).unwrap();
assert_primary_columns(&imported, columns);
}
}

View File

@ -0,0 +1,68 @@
#![cfg(not(target_arch = "wasm32"))]
use std::path::Path;
use std::sync::Arc;
use designcraft_engine::Session;
use serde_json::json;
fn linked_document(root: &Path) -> (Session, std::path::PathBuf) {
let source = root.join("source/contacts.csv");
std::fs::create_dir_all(source.parent().unwrap()).unwrap();
std::fs::write(&source, "Name\nAda\n").unwrap();
let original = root.join("original/template.designcraft");
std::fs::create_dir_all(original.parent().unwrap()).unwrap();
let mut session = Session::new();
session.execute("file.new", &json!({})).unwrap();
session.execute("data.source.select", &json!({"path": source})).unwrap();
session.execute("file.saveAs", &json!({"path": original})).unwrap();
(session, original)
}
#[test]
fn failed_save_as_preserves_document_saved_state_history_and_original_file() {
let temp = tempfile::tempdir().unwrap();
let (mut session, original) = linked_document(temp.path());
session.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap();
session.execute("edit.undo", &json!({})).unwrap();
let before = session.doc().unwrap().clone();
let journal = session.journal.clone();
let original_bytes = std::fs::read(&original).unwrap();
let destination = temp.path().join("missing/elsewhere/template.designcraft");
assert!(session.execute("file.saveAs", &json!({"path": destination})).is_err());
let after = session.doc().unwrap();
assert!(Arc::ptr_eq(&before.doc, &after.doc), "failed Save As must not change data-source paths");
assert!(Arc::ptr_eq(&before.saved_doc, &after.saved_doc));
assert_eq!(before.path, after.path);
assert_eq!((before.revision, before.saved_revision, before.is_dirty()), (after.revision, after.saved_revision, after.is_dirty()));
assert_eq!(before.doc.data_merge, after.doc.data_merge);
assert_eq!(before.history.undo.len(), after.history.undo.len());
assert_eq!(before.history.redo.len(), after.history.redo.len());
assert_eq!(serde_json::to_value(&before.selection).unwrap(), serde_json::to_value(&after.selection).unwrap());
assert_eq!(journal, session.journal);
assert_eq!(original_bytes, std::fs::read(original).unwrap());
assert!(!destination.exists());
}
#[test]
fn save_a_copy_rebases_its_data_source_without_changing_the_open_document() {
let temp = tempfile::tempdir().unwrap();
let (mut session, original) = linked_document(temp.path());
let before = session.doc().unwrap().clone();
let original_bytes = std::fs::read(&original).unwrap();
let destination = temp.path().join("copies/nested/template.designcraft");
std::fs::create_dir_all(destination.parent().unwrap()).unwrap();
session.execute("file.saveACopy", &json!({"path": destination})).unwrap();
let copy = designcraft_format::load(&std::fs::read(destination).unwrap()).unwrap();
assert_eq!(copy.data_merge.sources[0].relative_path.as_deref(), Some("../../source/contacts.csv"));
let after = session.doc().unwrap();
assert!(Arc::ptr_eq(&before.doc, &after.doc));
assert!(Arc::ptr_eq(&before.saved_doc, &after.saved_doc));
assert_eq!(before.path, after.path);
assert_eq!((before.revision, before.saved_revision, before.is_dirty()), (after.revision, after.saved_revision, after.is_dirty()));
assert_eq!(original_bytes, std::fs::read(original).unwrap());
}

View File

@ -163,3 +163,42 @@ fn decoration_offsets_are_centers_for_explicit_and_automatic_rules() {
}
}
}
/// An underline is one bar per stretch of text: a bar per glyph left lighter seams between the
/// letters where the anti-aliased edges met (at fractional positions).
#[test]
fn an_underline_has_no_seams_between_letters() {
let mut d = Document::new(&NewDocument { width: 400.0, height: 120.0, facing_pages: false, ..Default::default() });
let chars = CharAttrs {
font_family: Some("Source Sans 3".into()),
size: Some(48.0),
fill: Some("[Black]".into()),
underline: Some(true),
underline_weight: Some(Some(6.0)),
..Default::default()
};
d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 390.0, 110.0), d.default_layer(), "FERRUM", ParaFormat { chars, ..Default::default() })
.unwrap();
let mut renderer = Renderer::new();
renderer.threads = 0;
let placed = [Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }];
let options = RenderOptions { paper: false, background: Some([255; 4]), greek_below_px: 0.0, ..Default::default() };
// A fractional scale puts the letters' edges between pixels.
let (w, h) = (543u32, 163u32);
let img = renderer.render(&d, &Cache::new(), &placed, w, h, Affine::scale(1.3579), &options);
let px = |x: u32, y: u32| {
let i = ((y * w + x) * 4) as usize;
[img.pixels[i], img.pixels[i + 1], img.pixels[i + 2], img.pixels[i + 3]]
};
// The underline's middle row: the row with the longest run of dark pixels ([Black] shows as
// about 52 grey), then nothing lighter than its darkest pixel along it.
let row = (0..h).max_by_key(|&y| (0..w).filter(|&x| px(x, y)[0] < 110).count()).unwrap();
let dark: Vec<u32> = (0..w).filter(|&x| px(x, row)[0] < 110).collect();
let (x0, x1) = (dark[0], *dark.last().unwrap());
assert!(x1 - x0 > 150, "the underline spans the word: {x0}..{x1}");
// The bar's own anti-aliased ends aside.
let inside = x0 + 2..=x1 - 2;
let ink = inside.clone().map(|x| px(x, row)[0]).min().unwrap();
let light: Vec<(u32, u8)> = inside.map(|x| (x, px(x, row)[0])).filter(|&(_, v)| v > ink + 12).collect();
assert!(light.is_empty(), "seams in the underline at {light:?}");
}

View File

@ -0,0 +1,277 @@
use designcraft_doc::{ParaFormat, Story, StoryId};
use designcraft_engine::Session;
use serde_json::{Value, json};
use std::sync::Arc;
fn setup() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s
}
fn frame(s: &mut Session, text: &str) -> (u64, u64) {
let r = s.execute("frame.create", &json!({"rect":[72,72,300,300],"content":"text","text":text,"caret":false})).unwrap();
(r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap())
}
fn snapshot(s: &Session) -> Value {
let d = s.doc().unwrap();
json!({"doc":d.doc,"selection":d.selection,"revision":d.revision,"dirty":d.is_dirty(),"undo":d.history.undo.len(),"redo":d.history.redo.len(),"journal":s.journal})
}
#[test]
fn rejected_thread_is_atomic_with_new_frame_and_redo() {
let mut s = setup();
let (a, _) = frame(&mut s, "hello");
frame(&mut s, "undo");
s.execute("edit.undo", &json!({})).unwrap();
let before = snapshot(&s);
assert!(s.execute("text.thread", &json!({"from":999999,"rect":[0,0,100,100]})).is_err());
assert_eq!(snapshot(&s), before);
assert!(s.execute("text.thread", &json!({"from":a,"rect":[0,0,100,100],"spread":999})).is_err());
assert_eq!(snapshot(&s), before);
}
#[test]
fn locked_frame_cannot_be_unthreaded() {
let mut s = setup();
let (a, _) = frame(&mut s, "hello");
let (b, _) = frame(&mut s, "");
s.execute("text.thread", &json!({"from":a,"to":b})).unwrap();
s.execute("object.lock", &json!({"ids":[a]})).unwrap();
let before = snapshot(&s);
let r = s.execute("text.unthread", &json!({"frame":a}));
assert!(r.is_err(), "unthread accepted locked frame: {r:?}");
assert_eq!(snapshot(&s), before);
}
#[test]
fn locked_frame_cannot_be_removed_from_thread() {
let mut s = setup();
let (a, _) = frame(&mut s, "hello");
let (b, _) = frame(&mut s, "");
s.execute("text.thread", &json!({"from":a,"to":b})).unwrap();
s.execute("object.lock", &json!({"ids":[a]})).unwrap();
let before = snapshot(&s);
let r = s.execute("text.removeFromThread", &json!({"frame":a}));
assert!(r.is_err(), "remove accepted locked frame: {r:?}");
assert_eq!(snapshot(&s), before);
}
#[test]
fn rejected_selection_preserves_pending_typing() {
let mut s = setup();
let (_, sid) = frame(&mut s, "hello");
s.execute("text.select", &json!({"story":sid,"anchor":3})).unwrap();
s.execute("type.char", &json!({"attrs":{"size":30}})).unwrap();
let before = s.execute("type.selectionAttrs", &json!({})).unwrap();
assert!(s.execute("text.select", &json!({"story":999999,"anchor":0})).is_err());
let after = s.execute("type.selectionAttrs", &json!({})).unwrap();
assert_eq!(after, before, "rejected selection reset pending typing");
}
#[test]
fn append_max_id_preserves_both_tables() {
use designcraft_doc::table::Table;
let mut a = Story::with_text(StoryId(1), &designcraft_doc::TABLE_ANCHOR.to_string(), ParaFormat::default());
let mut b = Story::with_text(StoryId(2), &designcraft_doc::TABLE_ANCHOR.to_string(), ParaFormat::default());
let mut ta = Table::new(u64::MAX, 1, 1, 0, 0, 100.);
ta.cells[0].text = Story::with_text(StoryId(0), "ORIGINAL", ParaFormat::default());
a.tables.insert(u64::MAX, Arc::new(ta));
let mut tb = Table::new(1, 1, 1, 0, 0, 100.);
tb.cells[0].text = Story::with_text(StoryId(0), "INCOMING", ParaFormat::default());
b.tables.insert(1, Arc::new(tb));
a.paras[0].table = Some(u64::MAX);
b.paras[0].table = Some(1);
a.check().unwrap();
b.check().unwrap();
a.append_story(b);
let contents: Vec<_> = a.tables.values().map(|t| t.cells[0].text.text.clone()).collect();
assert!(contents.contains(&"ORIGINAL".into()) && contents.contains(&"INCOMING".into()), "table loss: {contents:?}");
}
#[test]
fn idml_dropcap_detail_preserves_inherited_other_bit() {
use designcraft_doc::{Document, ParaAttrs, ParagraphStyle, build::NewDocument};
let mut d = Document::new(&NewDocument::default());
let parent = ParagraphStyle {
name: "Parent".into(),
based_on: None,
next_style: None,
para: ParaAttrs { drop_cap_align_left: Some(false), drop_cap_scale_descenders: Some(true), ..Default::default() },
chars: Default::default(),
shortcut: String::new(),
};
let child = ParagraphStyle {
name: "Child".into(),
based_on: Some("Parent".into()),
next_style: None,
para: ParaAttrs { drop_cap_scale_descenders: Some(false), ..Default::default() },
chars: Default::default(),
shortcut: String::new(),
};
d.styles_mut().paragraph.extend([parent, child]);
let before = d.styles.resolve_para_style("Child").0;
let bytes = designcraft_idml::export_idml(&d);
let back = designcraft_idml::import_idml(&bytes).unwrap();
let after = back.styles.resolve_para_style("Child").0;
assert_eq!(
(after.drop_cap_align_left, after.drop_cap_scale_descenders),
(before.drop_cap_align_left, before.drop_cap_scale_descenders),
"IDML export resets inherited align-left bit"
);
}
#[test]
fn threading_preserves_incoming_text_hyperlink() {
let mut s = setup();
let (a, sa) = frame(&mut s, "First");
let (b, sb) = frame(&mut s, "Second");
s.execute("text.select", &json!({"story":sb,"anchor":0,"focus":6})).unwrap();
s.execute("hyperlink.create", &json!({"url":"https://example.com"})).unwrap();
s.execute("text.thread", &json!({"from":a,"to":b})).unwrap();
let d = &s.doc().unwrap().doc;
let back = designcraft_format::load(&designcraft_format::save(d).unwrap()).unwrap();
match &back.hyperlinks[0].source {
designcraft_doc::HyperlinkSource::Text { story, start, end } => {
assert_eq!(story.0, sa, "hyperlink points to deleted incoming story");
assert_eq!((*start, *end), (6, 12));
}
_ => panic!("wrong source"),
};
}
#[test]
fn thread_cannot_destroy_body_by_joining_generated_endnotes() {
let mut s = setup();
let (body, sid) = frame(&mut s, "KEEP THIS BODY");
s.execute("text.select", &json!({"story":sid,"anchor":14})).unwrap();
let e = s.execute("endnote.insert", &json!({"text":"a note"})).unwrap();
let ef = e["frame"].as_u64().unwrap();
let before = snapshot(&s);
let r = s.execute("text.thread", &json!({"from":ef,"to":body}));
assert!(
r.is_err(),
"thread into generated endnotes accepted: {r:?}; stories: {:?}",
s.doc().unwrap().doc.stories.values().map(|s| &s.text).collect::<Vec<_>>()
);
assert_eq!(snapshot(&s), before);
}
#[test]
fn generated_endnotes_accept_empty_extensions_but_reject_reverse_body_merge() {
let mut s = setup();
let (body, sid) = frame(&mut s, "Body");
s.execute("text.select", &json!({"story":sid,"anchor":4})).unwrap();
let r = s.execute("endnote.insert", &json!({"text":"note"})).unwrap();
let ef = r["frame"].as_u64().unwrap();
let esid = s.doc().unwrap().doc.endnote_story.unwrap();
let before = snapshot(&s);
assert!(s.execute("text.thread", &json!({"from":body,"to":ef})).is_err());
assert_eq!(snapshot(&s), before);
s.execute("text.thread", &json!({"from":ef,"rect":[72,72,300,300]})).unwrap();
assert_eq!(s.doc().unwrap().doc.endnote_story, Some(esid));
s.execute("text.thread", &json!({"to":ef,"rect":[72,72,300,300]})).unwrap();
assert_eq!(s.doc().unwrap().doc.endnote_story, Some(esid));
assert_eq!(s.doc().unwrap().doc.story(esid).unwrap().frames.len(), 3);
assert!(s.doc().unwrap().doc.story(StoryId(sid)).unwrap().text.starts_with("Body"));
s.doc().unwrap().doc.check().unwrap();
}
#[test]
fn threading_moves_data_placeholders_and_primary_story_with_text() {
use designcraft_doc::{Placeholder, PlaceholderAnchor, PlaceholderRole};
let mut s = setup();
let (a, sa) = frame(&mut s, "First");
let (b, sb) = frame(&mut s, "<<name>>");
s.edit(|d, _| {
d.settings.primary_story = Some(StoryId(sb));
d.data_merge.placeholders.push(Placeholder {
id: 123,
source_id: 0,
field: "name".into(),
role: PlaceholderRole::Text,
anchor: PlaceholderAnchor::Text { story: StoryId(sb), start: 0, end: 8 },
});
Ok(())
})
.unwrap();
let before = s.doc().unwrap().doc.clone();
s.execute("text.thread", &json!({"from":a,"to":b})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.settings.primary_story, Some(StoryId(sa)));
assert_eq!(d.data_merge.placeholders.len(), 1);
assert_eq!(d.data_merge.placeholders[0].anchor, PlaceholderAnchor::Text { story: StoryId(sa), start: 6, end: 14 });
let after = d.clone();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc, before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc, after);
}
#[test]
fn append_max_ids_preserves_addressable_footnotes_and_endnotes() {
let mut a = Story::with_text(StoryId(1), "A", ParaFormat::default());
let mut b = Story::with_text(StoryId(2), "B", ParaFormat::default());
a.insert_note(1, "original footnote", ParaFormat::default());
b.insert_note(1, "incoming footnote", ParaFormat::default());
Arc::make_mut(&mut a.notes[0]).id = u64::MAX;
a.insert_endnote(1, "original endnote", ParaFormat::default());
b.insert_endnote(1, "incoming endnote", ParaFormat::default());
Arc::make_mut(&mut a.endnotes[0]).id = u64::MAX;
a.check().unwrap();
b.check().unwrap();
a.append_story(b);
a.check().unwrap();
assert_ne!(a.notes[0].id, a.notes[1].id);
assert_ne!(a.endnotes[0].id, a.endnotes[1].id);
assert_eq!(a.notes[0].text.text, "original footnote");
assert_eq!(a.endnotes[1].text.text, "incoming endnote");
}
#[test]
fn locked_layer_and_neighbour_reject_unthread_atomically() {
for lock_layer in [false, true] {
let mut s = setup();
let (a, _) = frame(&mut s, "A");
let (b, _) = frame(&mut s, "B");
s.execute("text.thread", &json!({"from":a,"to":b})).unwrap();
if lock_layer {
let lid = s.doc().unwrap().active_layer.0;
s.execute("layer.set", &json!({"id":lid,"locked":true})).unwrap();
} else {
s.execute("object.lock", &json!({"ids":[b]})).unwrap();
}
let before = snapshot(&s);
assert!(s.execute("text.unthread", &json!({"frame":a})).is_err());
assert_eq!(snapshot(&s), before);
}
}
#[test]
fn idml_partial_dropcap_bits_resolve_both_style_and_paragraph_inheritance() {
use designcraft_doc::{Document, ParaAttrs, ParagraphStyle, SpreadRef, build::NewDocument};
use designcraft_geom::Rect;
for parent_align in [false, true] {
for parent_scale in [false, true] {
for local_align in [false, true] {
let mut d = Document::new(&NewDocument::default());
d.styles_mut().paragraph.push(ParagraphStyle {
name: "Parent".into(),
based_on: None,
next_style: None,
para: ParaAttrs { drop_cap_align_left: Some(parent_align), drop_cap_scale_descenders: Some(parent_scale), ..Default::default() },
chars: Default::default(),
shortcut: String::new(),
});
let layer = d.default_layer();
let (_, sid) = d
.add_text_frame(
SpreadRef::Doc(0),
Rect::new(10., 10., 200., 200.),
layer,
"Body",
ParaFormat {
style: "Parent".into(),
para: ParaAttrs { drop_cap_align_left: Some(local_align), ..Default::default() },
..Default::default()
},
)
.unwrap();
let bytes = designcraft_idml::export_idml(&d);
let back = designcraft_idml::import_idml(&bytes).unwrap();
let st = back.stories.values().find(|s| s.text == "Body").unwrap();
let actual = back.styles.resolve_para(&st.paras[0]).0;
let expected = d.styles.resolve_para(&d.story(sid).unwrap().paras[0]).0;
assert_eq!(
(actual.drop_cap_align_left, actual.drop_cap_scale_descenders),
(expected.drop_cap_align_left, expected.drop_cap_scale_descenders)
);
}
}
}
}

View File

@ -0,0 +1,223 @@
//! Linked-story threading and text-color transaction safety.
use designcraft_doc::{ParaFormat, Story, StoryId, table::Table};
use designcraft_engine::Session;
use serde_json::{Value, json};
use std::sync::Arc;
fn setup() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s
}
fn frame(s: &mut Session, text: &str) -> (u64, StoryId) {
let f = s.execute("frame.create", &json!({"rect":[36,36,200,100],"content":"text","text":text,"caret":false})).unwrap();
(f["id"].as_u64().unwrap(), StoryId(f["story"].as_u64().unwrap()))
}
fn child(s: &mut Session, parent: StoryId) -> (u64, StoryId) {
let f = s.execute("story.placeAndLink", &json!({"story":parent.0,"rect":[36,150,200,100]})).unwrap();
(f["id"].as_u64().unwrap(), StoryId(f["story"].as_u64().unwrap()))
}
fn state(s: &Session) -> Value {
let d = s.doc().unwrap();
json!({"doc":d.doc,"selection":d.selection,"revision":d.revision,"dirty":d.is_dirty(),"undo":format!("{:?}",d.history),"journal":s.journal})
}
fn link(s: &mut Session, sid: StoryId) -> Value {
s.execute("story.links", &json!({})).unwrap().as_array().unwrap().iter().find(|l| l["story"] == sid.0).unwrap().clone()
}
#[test]
fn safety_linked_parent_follows_merge_and_updates_after_native_reopen() {
let mut s = setup();
let (first, survivor) = frame(&mut s, "PREFIX");
let (incoming, parent) = frame(&mut s, "PARENT");
let (_, linked) = child(&mut s, parent);
// A coincident old parent revision must not hide the change of parent identity/content.
let future_rev = s.doc().unwrap().doc.story(survivor).unwrap().rev + 2;
s.edit(|d, _| {
d.story_mut(linked).unwrap().link = Some((parent, future_rev));
Ok(())
})
.unwrap();
s.execute("text.thread", &json!({"from":first,"to":incoming})).unwrap();
assert_eq!(link(&mut s, linked)["parent"], survivor.0);
assert_eq!(link(&mut s, linked)["outOfDate"], true);
assert_eq!(s.doc().unwrap().doc.story(linked).unwrap().text, "PARENT");
let bytes = s.execute("file.serialize", &json!({})).unwrap();
let mut reopened = Session::new();
reopened.execute("file.openBytes", &json!({"name":"links.designcraft","base64":bytes["base64"]})).unwrap();
assert_eq!(link(&mut reopened, linked)["parent"], survivor.0);
reopened.execute("story.updateLink", &json!({"story":linked.0})).unwrap();
assert_eq!(reopened.doc().unwrap().doc.story(linked).unwrap().text, "PREFIX\nPARENT");
assert_eq!(link(&mut reopened, linked)["outOfDate"], false);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(link(&mut s, linked)["parent"], parent.0);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(link(&mut s, linked)["parent"], survivor.0);
}
#[test]
fn safety_linked_child_cannot_merge_body_text_in_either_direction() {
for reverse in [false, true] {
let mut s = setup();
let (_, parent) = frame(&mut s, "SOURCE");
let (linked, child) = child(&mut s, parent);
let (body, _) = frame(&mut s, "BODY");
let (from, to) = if reverse { (body, linked) } else { (linked, body) };
let before = state(&s);
let error = s.execute("text.thread", &json!({"from":from,"to":to})).unwrap_err();
assert!(error.to_string().contains("linked story"));
assert_eq!(state(&s), before);
s.execute("story.updateLink", &json!({"story":child.0})).unwrap();
assert!(s.doc().unwrap().doc.stories.values().any(|st| st.text == "BODY"));
}
}
#[test]
fn safety_linked_child_allows_empty_extensions_in_both_directions() {
for reverse in [false, true] {
let mut s = setup();
let (_, parent) = frame(&mut s, "SOURCE");
let (linked, child) = child(&mut s, parent);
let (empty, _) = frame(&mut s, "");
let (from, to) = if reverse { (empty, linked) } else { (linked, empty) };
s.execute("text.thread", &json!({"from":from,"to":to})).unwrap();
assert_eq!(link(&mut s, child)["parent"], parent.0);
s.execute("story.setText", &json!({"story":parent.0,"text":"UPDATED"})).unwrap();
assert_eq!(link(&mut s, child)["outOfDate"], true);
s.execute("story.updateLink", &json!({"story":child.0})).unwrap();
let st = s.doc().unwrap().doc.story(child).unwrap();
assert_eq!(st.text, "UPDATED");
assert_eq!(st.frames.len(), 2);
assert_eq!(link(&mut s, child)["outOfDate"], false);
}
}
#[test]
fn safety_linked_empty_parent_cannot_be_merged_into_its_child() {
let mut s = setup();
let (parent_frame, parent) = frame(&mut s, "");
let (linked, _) = child(&mut s, parent);
let before = state(&s);
assert!(s.execute("text.thread", &json!({"from":parent_frame,"to":linked})).is_err());
assert_eq!(state(&s), before);
}
#[test]
fn safety_text_color_without_target_is_atomic_and_carets_remain_valid() {
let mut s = setup();
let f = s.execute("frame.create", &json!({"rect":[36,36,200,100]})).unwrap();
s.execute("selection.set", &json!({"ids":[f["id"]]})).unwrap();
let before = state(&s);
assert!(s.execute("object.color", &json!({"color":"#123456","text":true})).is_err());
assert_eq!(state(&s), before);
for text in ["", "Words"] {
let (_, sid) = frame(&mut s, text);
s.execute("text.select", &json!({"story":sid.0,"anchor":0})).unwrap();
let color = s.execute("object.color", &json!({"color":"#654321","text":true})).unwrap();
s.execute("text.insert", &json!({"text":"X"})).unwrap();
assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().format_after(0).over.fill.as_deref(), color["swatch"].as_str());
}
}
fn nested_story(depth: usize, name: &str) -> Story {
let mut st = Story::with_text(StoryId(0), "leaf", ParaFormat::default());
st.chars[0].format.over.fill = Some(name.into());
for _ in 0..depth {
let mut outer = Story::with_text(StoryId(0), &designcraft_doc::TABLE_ANCHOR.to_string(), ParaFormat::default());
let mut t = Table::new(1, 1, 1, 0, 0, 100.);
t.cells[0].text = st;
outer.tables.insert(1, Arc::new(t));
outer.paras[0].table = Some(1);
st = outer;
}
st
}
#[test]
fn safety_deep_swatch_rename_rejects_atomically_with_pending_color() {
// A small boundary probe, never an attempted stack-overflow reproduction.
for depth in [33, 34] {
let mut s = setup();
let (_, sid) = frame(&mut s, "root");
s.execute("text.select", &json!({"story":sid.0,"anchor":0})).unwrap();
let old = s.execute("object.color", &json!({"color":"#123456"})).unwrap()["swatch"].as_str().unwrap().to_string();
s.edit(|d, _| {
let st = d.story_mut(sid).unwrap();
let frames = st.frames.clone();
*st = nested_story(depth, &old);
st.id = sid;
st.frames = frames;
Ok(())
})
.unwrap();
let pending = s.execute("type.selectionAttrs", &json!({})).unwrap();
let before = state(&s);
let doc = s.doc().unwrap().doc.clone();
let error = s.execute("swatch.addToSwatches", &json!({"swatch":old,"name":"Brand"})).unwrap_err();
assert!(error.to_string().contains("nesting"));
assert!(Arc::ptr_eq(&doc, &s.doc().unwrap().doc));
assert_eq!(state(&s), before);
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap(), pending);
}
}
#[test]
fn safety_swatch_rename_reaches_last_supported_depth() {
let mut s = setup();
let (_, sid) = frame(&mut s, "root");
s.execute("text.select", &json!({"story":sid.0,"anchor":0})).unwrap();
let old = s.execute("object.color", &json!({"color":"#123456"})).unwrap()["swatch"].as_str().unwrap().to_string();
s.edit(|d, _| {
let st = d.story_mut(sid).unwrap();
let frames = st.frames.clone();
*st = nested_story(32, &old);
st.id = sid;
st.frames = frames;
Ok(())
})
.unwrap();
s.execute("swatch.addToSwatches", &json!({"swatch":old,"name":"Brand"})).unwrap();
let mut st = s.doc().unwrap().doc.story(sid).unwrap();
for _ in 0..32 {
st = &st.tables[&1].cells[0].text;
}
assert_eq!(st.format_after(0).over.fill.as_deref(), Some("Brand"));
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "Brand");
}
#[test]
fn safety_empty_linked_parent_follows_survivor_only_on_explicit_update() {
for incoming_empty in [false, true] {
for alternate_layout in [false, true] {
let mut s = setup();
let (empty, parent) = frame(&mut s, "");
let linked = if alternate_layout {
s.execute("layout.createAlternate", &json!({"name":"Alternate","width":600,"height":800})).unwrap();
let links = s.execute("story.links", &json!({})).unwrap();
StoryId(links.as_array().unwrap().iter().find(|l| l["parent"] == parent.0).unwrap()["story"].as_u64().unwrap())
} else {
child(&mut s, parent).1
};
let (body, survivor) = frame(&mut s, "BODY");
let (from, to) = if incoming_empty { (body, empty) } else { (empty, body) };
s.execute("text.thread", &json!({"from":from,"to":to})).unwrap();
assert!(s.doc().unwrap().doc.story(parent).is_none());
assert_eq!(link(&mut s, linked)["parent"], survivor.0);
assert_eq!(link(&mut s, linked)["outOfDate"], true);
assert_eq!(s.doc().unwrap().doc.story(linked).unwrap().text, "");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(link(&mut s, linked)["parent"], parent.0);
assert_eq!(s.doc().unwrap().doc.story(linked).unwrap().text, "");
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(link(&mut s, linked)["parent"], survivor.0);
let bytes = s.execute("file.serialize", &json!({})).unwrap();
let mut reopened = Session::new();
reopened.execute("file.openBytes", &json!({"name":"empty-parent.designcraft","base64":bytes["base64"]})).unwrap();
assert_eq!(link(&mut reopened, linked)["outOfDate"], true);
assert_eq!(reopened.doc().unwrap().doc.story(linked).unwrap().text, "");
reopened.execute("story.updateLink", &json!({"story":linked.0})).unwrap();
assert_eq!(reopened.doc().unwrap().doc.story(linked).unwrap().text, "BODY");
assert_eq!(link(&mut reopened, linked)["outOfDate"], false);
}
}
}

View File

@ -322,7 +322,10 @@ pub fn stylesheet(doc: &Document) -> String {
let mut rule = String::new();
para_css(&pp, base, lead, &mut rule);
char_css(doc, &cp, base, &mut rule);
let _ = writeln!(css, "p.{} {{ {rule}}}", pc[ps.name.as_str()]);
let (tag, class) = export_mapping(st, &ps.name, false, &pc);
if let Some(selector) = class_selector(tag, &class) {
let _ = writeln!(css, "{selector} {{ {rule}}}");
}
}
for cs in &st.character {
if cs.name == story::NO_CHAR_STYLE {
@ -347,7 +350,10 @@ pub fn stylesheet(doc: &Document) -> String {
if let Some(sz) = c.size {
let _ = write!(rule, "font-size: {:.3}em; ", sz / base);
}
let _ = writeln!(css, "span.{} {{ {rule}}}", cc[cs.name.as_str()]);
let (tag, class) = export_mapping(st, &cs.name, true, &cc);
if let Some(selector) = class_selector(tag, &class) {
let _ = writeln!(css, "{selector} {{ {rule}}}");
}
}
css
}
@ -386,6 +392,39 @@ fn is_tag(t: &str, character: bool) -> bool {
}
}
/// Resolve Export Tagging once for both markup and its stylesheet. Keep the generated,
/// collision-free class when no custom class was supplied, and the existing tag fallback.
fn export_mapping<'a>(st: &'a Styles, name: &str, character: bool, classes: &BTreeMap<&str, String>) -> (&'a str, String) {
let et = st.export_tag(name, character);
let tag = et.map(|e| e.tag.as_str()).filter(|t| is_tag(t, character)).unwrap_or(if character { "span" } else { "p" });
let class = et.map(|e| e.class.clone()).filter(|c| !c.is_empty()).or_else(|| classes.get(name).cloned()).unwrap_or_else(|| slug(name));
// Match the characters xml_attr actually emits, before escaping either output context.
let class = class.chars().filter(|c| valid_xml_char(*c) && !('\u{E000}'..='\u{E1FF}').contains(c)).collect();
(tag, class)
}
/// Each class token must match on the same element. Escape CSS identifier punctuation and
/// leading digits without changing the class written into XHTML.
fn class_selector(tag: &str, class: &str) -> Option<String> {
let mut tokens = class.split_ascii_whitespace().peekable();
// A nonempty but whitespace-only custom class has no token to select. Never emit a bare
// element selector, which would apply this style to unrelated paragraphs or character runs.
tokens.peek()?;
let mut selector = tag.to_string();
for token in tokens {
selector.push('.');
for (i, c) in token.chars().enumerate() {
let leading_digit = c.is_ascii_digit() && (i == 0 || i == 1 && token.starts_with('-'));
if !leading_digit && token != "-" && (c.is_ascii_alphanumeric() || matches!(c, '-' | '_') || !c.is_ascii()) {
selector.push(c);
} else {
let _ = write!(selector, "\\{:x} ", c as u32);
}
}
}
Some(selector)
}
/// One story as XHTML paragraphs.
pub fn story_html(doc: &Document, sid: StoryId) -> String {
let Some(st) = doc.story(sid) else { return String::new() };
@ -394,10 +433,7 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String {
for (pi, r) in st.para_ranges().iter().enumerate() {
let pf = &st.paras[pi];
// Export Tagging: the style's element and class.
let et = doc.styles.export_tag(&pf.style, false);
let tag = et.map(|e| e.tag.as_str()).filter(|t| is_tag(t, false)).unwrap_or("p");
let class =
et.map(|e| e.class.clone()).filter(|c| !c.is_empty()).or_else(|| pc.get(pf.style.as_str()).cloned()).unwrap_or_else(|| slug(&pf.style));
let (tag, class) = export_mapping(&doc.styles, &pf.style, false, &pc);
let text_empty = st.text[r.clone()].trim().is_empty();
let rtl = doc.styles.resolve_para(pf).0.direction == designcraft_doc::TextDirection::RightToLeft;
let _ = write!(out, "<{tag} class=\"{}\"{}>", xml_attr(&class), if rtl { " dir=\"rtl\"" } else { "" });
@ -427,17 +463,11 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String {
} else {
story_markup(raw)
};
let et = (f.style != story::NO_CHAR_STYLE).then(|| doc.styles.export_tag(&f.style, true)).flatten();
let ctag = et.map(|e| e.tag.as_str()).filter(|t| is_tag(t, true)).unwrap_or("span");
let cls = if f.style != story::NO_CHAR_STYLE {
let class = et
.map(|e| e.class.clone())
.filter(|c| !c.is_empty())
.or_else(|| cc.get(f.style.as_str()).cloned())
.unwrap_or_else(|| slug(&f.style));
format!(" class=\"{}\"", xml_attr(&class))
let (ctag, cls) = if f.style != story::NO_CHAR_STYLE {
let (tag, class) = export_mapping(&doc.styles, &f.style, true, &cc);
(tag, format!(" class=\"{}\"", xml_attr(&class)))
} else {
String::new()
("span", String::new())
};
let style = override_css(doc, &f.over);
let style_attr = if style.is_empty() { String::new() } else { format!(" style=\"{}\"", xml_attr(&style)) };

View File

@ -0,0 +1,138 @@
use std::io::{Cursor, Read};
use designcraft_doc::build::NewDocument;
use designcraft_doc::geom::Rect;
use designcraft_doc::{Align, CharAttrs, CharacterStyle, Document, ExportTag, ParaAttrs, ParaFormat, ParagraphStyle, SpreadRef, story};
use designcraft_epub::{EpubOptions, HtmlOptions, export_epub, export_html};
fn fixture() -> Document {
let mut doc = Document::new(&NewDocument::default());
let styles = doc.styles_mut();
styles.paragraph.iter_mut().find(|p| p.name == story::BASIC_PARAGRAPH).unwrap().chars.size = Some(12.0);
styles.paragraph.push(ParagraphStyle {
name: "Chapter Title".into(),
based_on: None,
next_style: None,
para: ParaAttrs { align: Some(Align::Center), ..Default::default() },
chars: CharAttrs { size: Some(24.0), ..Default::default() },
shortcut: String::new(),
});
styles.character.push(CharacterStyle {
name: "Key Term".into(),
based_on: None,
chars: CharAttrs { size: Some(18.0), ..Default::default() },
shortcut: String::new(),
});
let layer = doc.default_layer();
doc.add_text_frame(
SpreadRef::Doc(0),
Rect::new(36.0, 36.0, 300.0, 100.0),
layer,
"Heading",
ParaFormat { style: "Chapter Title".into(), ..Default::default() },
)
.unwrap();
let (_, sid) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 120.0, 300.0, 200.0), layer, "Accent plain", ParaFormat::default()).unwrap();
doc.story_mut(sid).unwrap().format_chars(0..6, |f| f.style = "Key Term".into());
doc
}
fn map(doc: &mut Document, paragraph_tag: &str, paragraph_class: &str, character_tag: &str, character_class: &str) {
let tags = &mut doc.styles_mut().export_tags;
tags.insert("p:Chapter Title".into(), ExportTag { tag: paragraph_tag.into(), class: paragraph_class.into() });
tags.insert("c:Key Term".into(), ExportTag { tag: character_tag.into(), class: character_class.into() });
}
fn package(doc: &Document) -> (String, String) {
let mut zip = zip::ZipArchive::new(Cursor::new(export_epub(doc, &EpubOptions::default()).unwrap())).unwrap();
let mut read = |path: &str| {
let mut text = String::new();
zip.by_name(path).unwrap().read_to_string(&mut text).unwrap();
text
};
let xhtml = read("OEBPS/content.xhtml");
let css = read("OEBPS/style.css");
let opf = read("OEBPS/content.opf");
assert!(xhtml.contains("<link rel=\"stylesheet\" type=\"text/css\" href=\"style.css\"/>"));
assert!(opf.contains("href=\"style.css\" media-type=\"text/css\""));
(xhtml, css)
}
fn rule<'a>(css: &'a str, selector: &str) -> &'a str {
css.lines().find_map(|line| line.strip_prefix(&format!("{selector} {{ "))).unwrap_or_else(|| panic!("missing {selector} in {css}"))
}
#[test]
fn mapped_tags_and_classes_keep_their_packaged_style_declarations() {
let mut doc = fixture();
map(&mut doc, "h1", "chapter", "em", "term");
let (xhtml, css) = package(&doc);
assert!(xhtml.contains("<h1 class=\"chapter\">Heading</h1>"), "{xhtml}");
assert!(xhtml.contains("<em class=\"term\">Accent</em> plain"), "{xhtml}");
assert!(rule(&css, "h1.chapter").contains("font-size: 2.000em;"));
assert!(rule(&css, "h1.chapter").contains("text-align: center;"));
assert!(rule(&css, "em.term").contains("font-size: 1.500em;"));
assert!(!css.contains("p.chapter-title {") && !css.contains("span.key-term {"));
assert!(rule(&css, "p.basic-paragraph").contains("font-size: 1.000em;"));
// HTML uses the same mapped stylesheet inline, while EPUB links its packaged CSS.
let html = export_html(&doc, &HtmlOptions::default());
assert!(html.contains(&css));
assert!(html.contains("<h1 class=\"chapter\">Heading</h1>"));
}
#[test]
fn tag_only_class_only_and_unsupported_tags_keep_existing_fallbacks() {
for (pt, pc, ct, cc, paragraph, character) in [
("h2", "", "em", "", "h2.chapter-title", "em.key-term"),
("", "chapter", "", "term", "p.chapter", "span.term"),
("section", "", "h2", "", "p.chapter-title", "span.key-term"),
("", "", "", "", "p.chapter-title", "span.key-term"),
] {
let mut doc = fixture();
map(&mut doc, pt, pc, ct, cc);
let (xhtml, css) = package(&doc);
for (selector, text, size) in [(paragraph, "Heading", "2.000em"), (character, "Accent", "1.500em")] {
let (tag, class) = selector.split_once('.').unwrap();
assert!(xhtml.contains(&format!("<{tag} class=\"{class}\">{text}</{tag}>")), "{xhtml}");
assert!(rule(&css, selector).contains(&format!("font-size: {size};")));
}
}
let (xhtml, css) = package(&fixture());
assert!(xhtml.contains("<p class=\"chapter-title\">Heading</p>"));
assert!(rule(&css, "p.chapter-title").contains("font-size: 2.000em;"));
assert!(rule(&css, "span.key-term").contains("font-size: 1.500em;"));
}
#[test]
fn custom_class_tokens_match_without_renaming_them() {
for (class, attribute, selector) in [
("lead wide", "lead wide", ".lead.wide"),
("lead\twide\n", "lead\twide\n", ".lead.wide"),
("chapter:lead", "chapter:lead", r".chapter\3a lead"),
("2nd-edition", "2nd-edition", r".\32 nd-edition"),
("-2nd", "-2nd", r".-\32 nd"),
("-", "-", r".\2d "),
("見出し.café", "見出し.café", r".見出し\2e café"),
("edition \"wide\"", "edition &quot;wide&quot;", r".edition.\22 wide\22 "),
] {
let mut doc = fixture();
map(&mut doc, "h2", class, "em", class);
let (xhtml, css) = package(&doc);
assert!(xhtml.contains(&format!("<h2 class=\"{attribute}\">Heading</h2>")), "{xhtml}");
assert!(xhtml.contains(&format!("<em class=\"{attribute}\">Accent</em>")), "{xhtml}");
assert!(rule(&css, &format!("h2{selector}")).contains("font-size: 2.000em;"));
assert!(rule(&css, &format!("em{selector}")).contains("font-size: 1.500em;"));
}
}
#[test]
fn whitespace_only_custom_classes_do_not_apply_rules_to_every_element() {
let mut doc = fixture();
map(&mut doc, "p", " \t", "span", " \n");
let (xhtml, css) = package(&doc);
assert!(xhtml.contains("<p class=\" \t\">Heading</p>"));
assert!(xhtml.contains("<span class=\" \n\">Accent</span>"));
assert!(!css.lines().any(|line| line.starts_with("p {") || line.starts_with("span {")));
assert!(!css.contains("font-size: 2.000em;") && !css.contains("font-size: 1.500em;"));
assert!(rule(&css, "p.basic-paragraph").contains("font-size: 1.000em;"));
}

View File

@ -103,10 +103,14 @@ pub struct FontFace {
pub upem: f64,
/// Ascender in font units (positive = up).
pub ascent: f64,
/// OS/2 typographic ascender in font units (the ascent when the font has no usable one): the
/// Ascent first baseline offset.
pub typo_ascent: f64,
/// Descender in font units (positive = down).
pub descent: f64,
/// Cap height and x height in font units (estimated from the ascent when the font has no OS/2
/// values).
/// Cap height and x height in font units (without plausible OS/2 values, the cap height is the
/// top of the H, and either is estimated from the ascent): the Cap Height and x Height first
/// baseline offsets.
pub cap_height: f64,
pub x_height: f64,
pub shaper: harfrust::ShaperData,
@ -118,9 +122,11 @@ pub struct FontFace {
/// Basic Multilingual Plane coverage bitset, built on first use.
bmp: std::sync::OnceLock<Box<[u64]>>,
/// The ideographic em box, read on first use.
em: std::sync::OnceLock<(f64, f64)>,
em: std::sync::OnceLock<((f64, f64), bool)>,
/// The font menu group and native family name, read on first use.
group: std::sync::OnceLock<(FontGroup, Option<String>)>,
/// Format, class and PANOSE numbers (the font menus), read on first use.
kind: std::sync::OnceLock<crate::kind::FaceKind>,
}
/// A cheap `Copy` handle to a face. Faces are never unloaded, so the handle lives for the rest of
@ -188,6 +194,32 @@ impl FontFace {
pub fn skrifa(&self) -> Option<skrifa::FontRef<'_>> {
skrifa::FontRef::from_index(self.data(), self.index).ok()
}
/// The face's format, class and PANOSE numbers, as the font menus show and filter them.
pub fn kind(&self) -> crate::kind::FaceKind {
*self.kind.get_or_init(|| {
let mut kind = self.skrifa().map(|f| crate::kind::read_kind(&f, &self.family)).unwrap_or_default();
// Nothing in the tables or the name: look at the letters.
if kind.class == crate::kind::FontClass::Other
&& let Some(serifs) = has_serifs(self)
{
kind.class = if serifs { crate::kind::FontClass::Serif } else { crate::kind::FontClass::SansSerif };
}
kind
})
}
/// When the font file was added (created, else last modified): the font menus' Show Recently
/// Added. `None` for fonts without a file.
pub fn added(&self) -> Option<std::time::SystemTime> {
#[cfg(not(target_arch = "wasm32"))]
{
let meta = std::fs::metadata(self.source.path()?).ok()?;
meta.created().or_else(|_| meta.modified()).ok()
}
#[cfg(target_arch = "wasm32")]
{
None
}
}
pub fn hb(&self) -> Option<harfrust::FontRef<'_>> {
harfrust::FontRef::from_index(self.data(), self.index).ok()
}
@ -259,11 +291,17 @@ impl FontFace {
pub fn v_origin(&self, gid: u32) -> f64 {
crate::vertical::VMetrics::new(self).origin(GlyphId::new(gid))
}
/// The ideographic em box (top, bottom) in font units, y up (Japanese fonts: typically 880,
/// -120): `BASE` `idtp`/`ideo`, else the OS/2 typo ascender/descender, else the ascender and
/// descender, the last two centred on one em.
/// The ideographic em box (top, bottom) in font units, y up, one em tall (Japanese fonts:
/// typically 880, -120): `BASE` `idtp`/`ideo`; else, for a CJK font, from the OS/2 typo
/// ascender when the typo ascender and descender span one em; else centred on half the cap
/// height (OS/2, else the `H` glyph's top); else 0.88 em above the baseline.
pub fn em_box(&self) -> (f64, f64) {
*self.em.get_or_init(|| crate::vertical::em_box(self))
self.em.get_or_init(|| crate::vertical::em_box(self)).0
}
/// [`Self::em_box`] when the font's tables give it, `None` for the 0.88 em fallback.
pub fn declared_em_box(&self) -> Option<(f64, f64)> {
let (b, declared) = *self.em.get_or_init(|| crate::vertical::em_box(self));
declared.then_some(b)
}
/// The font menu group the font is for and, for a CJK group, its family name in that language
/// (see [`crate::FamilyInfo`]); read from the font once.
@ -435,6 +473,29 @@ const KOREAN_FALLBACKS: &[&str] = &[
"Source Han Sans K",
];
/// Fonts made for Malayalam (macOS, Windows, Noto, Linux). A font that only maps the characters
/// (Arial Unicode MS, first by path on macOS) sets them without conjuncts or vowel signs.
const MALAYALAM_FALLBACKS: &[&str] = &[
"Malayalam Sangam MN",
"Malayalam MN",
"Nirmala UI",
"Kartika",
"Noto Sans Malayalam",
"Noto Serif Malayalam",
"Manjari",
"Rachana",
"Meera",
"Lohit Malayalam",
];
/// The fallback chain of a script that only fonts made for it set well, whatever the language.
fn script_chain(c: char) -> Option<&'static [&'static str]> {
match c as u32 {
0x0D00..=0x0D7F => Some(MALAYALAM_FALLBACKS),
_ => None,
}
}
/// 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] {
@ -677,6 +738,7 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s
let settings: Vec<(skrifa::Tag, f32)> = coords.iter().map(|(t, v)| (skrifa::Tag::new(t), *v)).collect();
let location = if coords.is_empty() { Location::default() } else { f.axes().location(settings.iter().copied()) };
let m = f.metrics(Size::unscaled(), &location);
let upem = m.units_per_em.max(1) as f64;
let a = f.attributes();
let hb = harfrust::FontRef::from_index(data, index).ok()?;
let shaper = harfrust::ShaperData::new(&hb);
@ -694,11 +756,19 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s
style,
weight,
italic,
upem: m.units_per_em.max(1) as f64,
upem,
ascent: m.ascent as f64,
typo_ascent: typo_ascent(&f, &location, upem).unwrap_or(m.ascent as f64),
descent: -(m.descent as f64),
cap_height: m.cap_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.72),
x_height: m.x_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.5),
// Fonts with an OS/2 table older than version 2 don't declare these: measure them from
// flat-topped letters before falling back to a share of the ascent, which can be far off
// (drop caps are sized from the cap height).
cap_height: plausible(m.cap_height.map(f64::from), upem)
.or_else(|| measured_top(&f, (&location).into(), &['H', 'I', 'E', 'T'], upem))
.unwrap_or(m.ascent as f64 * 0.72),
x_height: plausible(m.x_height.map(f64::from), upem)
.or_else(|| measured_top(&f, (&location).into(), &['x', 'z', 'v'], upem))
.unwrap_or(m.ascent as f64 * 0.5),
shaper,
coords,
location,
@ -709,9 +779,58 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s
bmp: std::sync::OnceLock::new(),
em: std::sync::OnceLock::new(),
group: std::sync::OnceLock::new(),
kind: std::sync::OnceLock::new(),
})
}
/// A font's height above the baseline (font units) if it is plausible: above 0 and at most 4 em.
pub(crate) fn plausible(v: Option<f64>, upem: f64) -> Option<f64> {
v.filter(|v| *v > 0.0 && *v <= 4.0 * upem)
}
/// The top of the first of `chars` the font has a non-empty outline for, in font units: the
/// highest point of its unhinted outline at `location`, if [`plausible`]. For flat-topped letters
/// that is the cap height (`H`) or x-height (`x`).
pub(crate) fn measured_top(f: &skrifa::FontRef<'_>, location: LocationRef<'_>, chars: &[char], upem: f64) -> Option<f64> {
struct Top(f32);
impl OutlinePen for Top {
fn move_to(&mut self, _x: f32, y: f32) {
self.0 = self.0.max(y);
}
fn line_to(&mut self, _x: f32, y: f32) {
self.0 = self.0.max(y);
}
fn quad_to(&mut self, _cx: f32, _cy: f32, _x: f32, y: f32) {
self.0 = self.0.max(y);
}
fn curve_to(&mut self, _cx0: f32, _cy0: f32, _cx1: f32, _cy1: f32, _x: f32, y: f32) {
self.0 = self.0.max(y);
}
fn close(&mut self) {}
}
let cmap = f.charmap();
let glyphs = f.outline_glyphs();
chars.iter().find_map(|&c| {
let g = glyphs.get(cmap.map(c)?)?;
let mut pen = Top(f32::NEG_INFINITY);
g.draw(DrawSettings::unhinted(Size::unscaled(), location), &mut pen).ok()?;
plausible(Some(f64::from(pen.0)), upem)
})
}
/// The OS/2 typographic ascender in font units at `location`, if the font has an OS/2 table and
/// the value is [`plausible`].
fn typo_ascent(f: &skrifa::FontRef<'_>, location: &Location, upem: f64) -> Option<f64> {
use skrifa::raw::TableProvider;
let mut v = f.os2().ok()?.s_typo_ascender() as f64;
if !location.coords().is_empty()
&& let Ok(mvar) = f.mvar()
{
v += mvar.metric_delta(skrifa::raw::tables::mvar::tags::HASC, location.coords()).map_or(0.0, |d| d.to_f64());
}
plausible(Some(v), upem)
}
/// `family` without the font-format suffix layout apps append when a family is installed in
/// several formats (`Minion Pro (OTF)` → `Minion Pro`), if it has one.
fn without_format_suffix(family: &str) -> Option<&str> {
@ -834,6 +953,16 @@ impl FontDb {
self.scoped(0).styles(family)
}
/// The shared fonts' weight and italic of a style of `family` (see [`ScopedFonts::traits_of`]).
pub fn traits_of(&self, family: &str, style: &str) -> (f32, bool) {
self.scoped(0).traits_of(family, style)
}
/// Style linking among the shared fonts (see [`ScopedFonts::linked_style`]).
pub fn linked_style(&self, family: &str, current: &str, bold: Option<bool>, italic: Option<bool>) -> Option<String> {
self.scoped(0).linked_style(family, current, bold, italic)
}
/// Add a user font (TTF/OTF/TTC bytes). Returns the number of faces added (0 if unparseable or
/// every face was already present).
pub fn add_font(&self, bytes: Vec<u8>) -> usize {
@ -1227,14 +1356,59 @@ impl ScopedFonts<'_> {
/// Style names available for `family` (Regular first, then by weight).
pub fn styles(&self, family: &str) -> Vec<String> {
let v = self.exact_styles(family);
self.style_traits(family).into_iter().map(|t| t.2).collect()
}
/// Weight and italic of a style of `family`: from the font when it is known, else from the
/// style's name.
pub fn traits_of(&self, family: &str, style: &str) -> (f32, bool) {
let base = base_style(style);
self.style_traits(family)
.into_iter()
.find(|t| t.2.eq_ignore_ascii_case(base))
.map_or((style_weight(base), style_italic(base)), |(italic, weight, _)| (weight, italic))
}
/// Style linking: the style of `family` that sets bold (`bold`) and italic (`italic`) as asked
/// and keeps the other from `current` (Bold from Italic is Bold Italic). `None` when the
/// family has no such style.
pub fn linked_style(&self, family: &str, current: &str, bold: Option<bool>, italic: Option<bool>) -> Option<String> {
let (weight, cur_italic) = self.traits_of(family, current);
let want_italic = italic.unwrap_or(cur_italic);
let want_weight = match bold {
Some(true) => 700.0,
Some(false) => 400.0,
None => weight,
};
// Width words (Condensed, Extended…) of the current style are kept when the family allows.
const WIDTHS: &[&str] = &["condensed", "compressed", "narrow", "extended", "expanded", "wide"];
let widths = |s: &str| {
let n = norm(s);
WIDTHS.iter().filter(|w| n.contains(*w)).count()
};
let cur_widths = widths(current);
self.style_traits(family)
.into_iter()
.filter(|(i, w, _)| *i == want_italic && bold.is_none_or(|b| b == (*w >= 600.0)))
.min_by(|a, b| {
let key = |t: &(bool, f32, String)| ((t.1 - want_weight).abs(), usize::from(widths(&t.2) != cur_widths), t.2.len());
let (ka, kb) = (key(a), key(b));
ka.0.total_cmp(&kb.0).then(ka.1.cmp(&kb.1)).then(ka.2.cmp(&kb.2))
})
.map(|t| t.2)
}
/// `(italic, weight, name)` of each style of `family` (the document's, loaded, or installed),
/// Regular first, then by weight.
fn style_traits(&self, family: &str) -> Vec<(bool, f32, String)> {
let v = self.exact_style_traits(family);
match without_format_suffix(family) {
Some(base) if v.is_empty() => self.exact_styles(base),
Some(base) if v.is_empty() => self.exact_style_traits(base),
_ => v,
}
}
fn exact_styles(&self, family: &str) -> Vec<String> {
fn exact_style_traits(&self, family: &str) -> Vec<(bool, f32, String)> {
let mut v: Vec<(bool, f32, String)> = self
.own()
.iter()
@ -1250,7 +1424,7 @@ impl ScopedFonts<'_> {
}
v.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1)).then(a.2.cmp(&b.2)));
v.dedup_by(|a, b| a.2 == b.2);
v.into_iter().map(|t| t.2).collect()
v
}
/// Resolve a family + style to a face, falling back to the closest style of the family, then to
@ -1277,6 +1451,43 @@ impl ScopedFonts<'_> {
None
}
/// `family`'s kind (format, class, PANOSE) and when its file was added, read from its Regular
/// face (else its first), for the font menus' filters, which look at every family. A face
/// already loaded is asked; an installed family that isn't loaded is read from its file, which
/// is then let go rather than kept loaded. None for an unknown family or an unreadable file.
pub fn family_kind(&self, family: &str) -> Option<(crate::kind::FaceKind, Option<std::time::SystemTime>)> {
let styles = self.styles(family);
let style = styles.iter().find(|s| *s == "Regular").or(styles.first())?.clone();
if self.own().iter().chain(self.db.read_faces().iter()).any(|f| f.family.eq_ignore_ascii_case(family)) {
let f = self.find(family, &style)?;
return Some((f.kind(), f.added()));
}
#[cfg(not(target_arch = "wasm32"))]
{
let path = {
let cat = self.db.read_catalog();
let of_family = |c: &&CatalogEntry| c.family.eq_ignore_ascii_case(family);
let entry = cat.iter().filter(of_family).find(|c| norm(&c.style) == norm(&style)).or_else(|| cat.iter().find(of_family));
entry.map(|c| c.path.clone())?
};
if std::fs::metadata(&path).ok()?.len() > MAX_DOCUMENT_FONT_BYTES {
return None;
}
let data = Arc::new(std::fs::read(&path).ok()?);
let found = enumerate_faces(&data);
let pick = found
.iter()
.find(|(_, f, st, _)| f.eq_ignore_ascii_case(family) && norm(st) == norm(&style))
.or_else(|| found.iter().find(|(_, f, _, _)| f.eq_ignore_ascii_case(family)))?
.clone();
let (index, fam, st, coords) = pick;
let face = make_face(FontBytes::Owned(data.clone()), FontSource::Installed(path), index, fam, st, coords)?;
Some((face.kind(), face.added()))
}
#[cfg(target_arch = "wasm32")]
None
}
/// Is `family` available (the document's, loaded, or installed on the system), as named or
/// without a format suffix (see [`ScopedFonts::face`])?
pub fn has_family(&self, family: &str) -> bool {
@ -1375,12 +1586,12 @@ impl ScopedFonts<'_> {
}
/// A face other than `exclude` that covers `c` in `language` (a BCP 47 tag, `None` when
/// unknown). CJK characters try their language's chain first (see [`cjk_chain`]); then the
/// first face (fallback family first, then load order, the document's fonts last) that covers
/// `c`. On native, system fonts are cataloged and loaded lazily the first time no loaded face
/// covers a character.
/// unknown). CJK characters try their language's chain first (see [`cjk_chain`]), Malayalam
/// the fonts made for it (see [`script_chain`]); then the first face (fallback family first,
/// then load order, the document's fonts last) that covers `c`. On native, system fonts are
/// cataloged and loaded lazily the first time no loaded face covers a character.
pub fn fallback_for(&self, c: char, exclude: u32, language: Option<&str>) -> Option<Arc<FontFace>> {
if let Some(chain) = cjk_chain(c, language)
if let Some(chain) = cjk_chain(c, language).or_else(|| script_chain(c))
&& let Some(f) = chain.iter().find_map(|family| self.chain_face(family, c, exclude))
{
return Some(f);
@ -1432,6 +1643,53 @@ fn over_total() -> String {
format!("over the {} MB the folder's fonts may take in all", MAX_DOCUMENT_FONTS_TOTAL >> 20)
}
/// Does the face's "l" (else "I") have serifs: much wider at its foot than mid-stem? `None` when
/// it has neither letter.
fn has_serifs(face: &FontFace) -> Option<bool> {
let gid = ['l', 'I'].into_iter().map(|c| face.glyph_for(c)).find(|g| *g != 0)?;
let path = font_outline(face, gid);
let bbox = kurbo::Shape::bounding_box(&path);
// An empty or degenerate outline (NaN included) says nothing.
if bbox.height().is_nan() || bbox.height() <= 1.0 {
return None;
}
let mut edges = Vec::new();
let mut last = None;
let mut start = None;
kurbo::flatten(&path, 1.0, |el| match el {
kurbo::PathEl::MoveTo(p) => {
last = Some(p);
start = Some(p);
}
kurbo::PathEl::LineTo(p) => {
if let Some(a) = last {
edges.push((a, p));
}
last = Some(p);
}
kurbo::PathEl::ClosePath => {
if let (Some(a), Some(b)) = (last, start) {
edges.push((a, b));
}
last = start;
}
_ => {}
});
// The outline is y-down: the foot is near bbox.y1 (the baseline).
let width_at = |y: f64| {
let xs: Vec<f64> = edges
.iter()
.filter(|(a, b)| (a.y <= y && y < b.y) || (b.y <= y && y < a.y))
.map(|(a, b)| a.x + (y - a.y) * (b.x - a.x) / (b.y - a.y))
.collect();
let (lo, hi) = xs.iter().fold((f64::MAX, f64::MIN), |(lo, hi), x| (lo.min(*x), hi.max(*x)));
(hi > lo).then_some(hi - lo)
};
let foot = width_at(bbox.y1 - bbox.height() * 0.02)?;
let stem = width_at(bbox.y0 + bbox.height() * 0.5)?;
Some(foot > stem * 1.6)
}
/// Glyph `gid`'s outline as the font draws it, in font units, y-down (flipped).
fn font_outline(face: &FontFace, gid: u32) -> BezPath {
let mut pen = FlipPen(BezPath::new());
@ -1481,3 +1739,54 @@ mod tests_docfonts;
#[cfg(test)]
#[path = "tests_vertical.rs"]
mod tests_vertical;
#[cfg(test)]
mod tests_heights {
use super::*;
use crate::testing::{font_mapping, with_table_u16};
fn face_of(bytes: Vec<u8>) -> FontFace {
make_face(FontBytes::Owned(Arc::new(bytes)), FontSource::Memory, 0, "Test".into(), "Regular".into(), Vec::new()).unwrap()
}
/// `font` with OS/2 sxHeight and sCapHeight set to 0 (undeclared).
fn undeclared(font: Vec<u8>) -> Vec<u8> {
with_table_u16(with_table_u16(font, b"OS/2", 86, 0).unwrap(), b"OS/2", 88, 0).unwrap()
}
/// The top of Source Sans 3's glyph for `c` from its bounding box (font units).
fn bbox_top(c: char) -> f64 {
let f = skrifa::FontRef::new(BUNDLED[0]).unwrap();
f.glyph_metrics(Size::unscaled(), LocationRef::default()).bounds(f.charmap().map(c).unwrap()).unwrap().y_max as f64
}
/// A font that doesn't declare its cap height and x-height gets them measured from its
/// flat-topped letters, also when it lacks the first choices `H` and `x`; a font that
/// declares them keeps them.
#[test]
fn undeclared_heights_are_measured_from_outlines() {
let glyphs = [('H', 'H'), ('x', 'x'), ('E', 'E'), ('z', 'z')];
for (glyphs, cap, x) in [(&glyphs[..], 'H', 'x'), (&glyphs[2..], 'E', 'z')] {
let face = face_of(undeclared(font_mapping("Heights", glyphs).unwrap()));
let (cap, x) = (bbox_top(cap), bbox_top(x));
assert!(cap > x && x > 0.0);
assert!((face.cap_height - cap).abs() <= 1.0, "{glyphs:?}: cap height {} vs {cap}", face.cap_height);
assert!((face.x_height - x).abs() <= 1.0, "{glyphs:?}: x-height {} vs {x}", face.x_height);
}
let declared = face_of(font_mapping("Heights", &glyphs).unwrap());
assert_eq!((declared.cap_height, declared.x_height), (660.0, 486.0), "Source Sans 3 Regular's OS/2 values");
// Declared values win over the outlines: `H` drawn as `x` doesn't lower the cap height.
let face = face_of(font_mapping("Heights", &[('H', 'x'), ('x', 'H')]).unwrap());
assert_eq!((face.cap_height, face.x_height), (declared.cap_height, declared.x_height));
}
/// Without any of the letters, or with only empty outlines, the heights fall back to a share
/// of the ascent.
#[test]
fn heights_without_measurable_letters_fall_back_to_the_ascent() {
for glyphs in [&[('a', 'a')][..], &[('H', ' '), ('x', ' ')]] {
let face = face_of(undeclared(font_mapping("Heights", glyphs).unwrap()));
assert_eq!((face.cap_height, face.x_height), (face.ascent * 0.72, face.ascent * 0.5), "{glyphs:?}");
}
}
}

191
crates/fonts/src/kind.rs Normal file
View File

@ -0,0 +1,191 @@
//! What the font menus say about a face: its file format (the icon after the sample), its
//! classification (the Filters menu) and its PANOSE numbers (Show Similar Fonts).
use skrifa::raw::TableProvider;
use skrifa::raw::types::Tag;
/// A face's font format, as the font menus show it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum FontFormat {
/// TrueType outlines (`glyf`).
TrueType,
/// PostScript (CFF) outlines.
OpenType,
/// A variable font (`fvar`).
Variable,
/// Colour glyphs (COLR, SVG, bitmaps): emoji and colour fonts.
Color,
}
/// The classes the font menus filter by.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum FontClass {
Serif,
SlabSerif,
SansSerif,
Script,
Monospaced,
Decorative,
Symbol,
/// Nothing in the font says.
Other,
}
impl FontClass {
/// The classes, in the Filters menu's order.
pub const ALL: [FontClass; 8] = [
FontClass::Serif,
FontClass::SlabSerif,
FontClass::SansSerif,
FontClass::Script,
FontClass::Monospaced,
FontClass::Decorative,
FontClass::Symbol,
FontClass::Other,
];
/// The name the Filters menu shows (English; the UI translates it).
pub fn label(self) -> &'static str {
match self {
FontClass::Serif => "Serif",
FontClass::SlabSerif => "Slab Serif",
FontClass::SansSerif => "Sans Serif",
FontClass::Script => "Script",
FontClass::Monospaced => "Monospaced",
FontClass::Decorative => "Decorative",
FontClass::Symbol => "Symbol",
FontClass::Other => "Other",
}
}
}
/// A face's format, class and PANOSE numbers.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct FaceKind {
pub format: FontFormat,
pub class: FontClass,
/// The OS/2 table's PANOSE classification (all zero when the font has none).
pub panose: [u8; 10],
}
impl Default for FaceKind {
fn default() -> Self {
FaceKind { format: FontFormat::TrueType, class: FontClass::Other, panose: [0; 10] }
}
}
/// Read a face's kind from its tables; `family` helps classify fonts whose tables don't say.
pub fn read_kind(f: &skrifa::FontRef<'_>, family: &str) -> FaceKind {
let has = |tag: &[u8; 4]| f.table_data(Tag::new(tag)).is_some();
let format = if [b"COLR", b"SVG ", b"CBDT", b"sbix"].iter().any(|t| has(t)) {
FontFormat::Color
} else if has(b"fvar") {
FontFormat::Variable
} else if has(b"CFF ") || has(b"CFF2") {
FontFormat::OpenType
} else {
FontFormat::TrueType
};
let os2 = f.os2().ok();
let panose: [u8; 10] = os2.as_ref().and_then(|t| t.panose_10().try_into().ok()).unwrap_or([0; 10]);
let ibm = os2.as_ref().map_or(0, |t| (t.s_family_class() >> 8) as u8);
let fixed = f.post().ok().is_some_and(|p| p.is_fixed_pitch() != 0);
FaceKind { format, class: classify(&panose, ibm, fixed, family), panose }
}
/// The class from PANOSE (family kind, serif style, proportion), else the IBM family class in OS/2,
/// else the family name.
pub fn classify(panose: &[u8; 10], ibm_class: u8, fixed_pitch: bool, family: &str) -> FontClass {
let name = family.to_lowercase();
let named = |words: &[&str]| words.iter().any(|w| name.contains(w));
// PANOSE proportion 9 is monospaced (Latin text).
if fixed_pitch || (panose[0] == 2 && panose[3] == 9) || named(&["mono", "courier", " code"]) {
return FontClass::Monospaced;
}
match panose[0] {
3 => return FontClass::Script,
4 => return FontClass::Decorative,
5 => return FontClass::Symbol,
2 => match panose[1] {
// Square serifs are slabs; cove, bone, triangle… serifs are serifs.
6 => return FontClass::SlabSerif,
2..=10 => return FontClass::Serif,
11..=15 => return FontClass::SansSerif,
_ => {}
},
_ => {}
}
match ibm_class {
1 | 2 | 3 | 7 => FontClass::Serif,
4 | 5 => FontClass::SlabSerif,
8 => FontClass::SansSerif,
9 => FontClass::Decorative,
10 => FontClass::Script,
12 => FontClass::Symbol,
_ if named(&["slab"]) => FontClass::SlabSerif,
_ if named(&["sans", "grotesk", "grotesque", "gothic", "helvetica", "arial"]) => FontClass::SansSerif,
_ if named(&["script", "hand", "brush"]) => FontClass::Script,
_ if named(&["serif"]) => FontClass::Serif,
_ => FontClass::Other,
}
}
/// How unlike two faces look by their PANOSE numbers (0 = alike), counting only the digits both
/// fonts set; `None` when they share none.
pub fn panose_distance(a: &[u8; 10], b: &[u8; 10]) -> Option<u32> {
let pairs: Vec<(u8, u8)> = a.iter().zip(b).skip(1).filter(|(x, y)| **x > 1 && **y > 1).map(|(x, y)| (*x, *y)).collect();
if pairs.is_empty() {
return None;
}
Some(pairs.iter().map(|(x, y)| u32::from(x.abs_diff(*y))).sum::<u32>() * 9 / pairs.len() as u32)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classes_come_from_panose_then_the_ibm_class_then_the_name() {
let latin = |serif: u8, proportion: u8| [2, serif, 5, proportion, 5, 0, 0, 0, 0, 0];
assert_eq!(classify(&latin(2, 3), 0, false, "Garamond"), FontClass::Serif);
assert_eq!(classify(&latin(6, 3), 0, false, "Rockwell"), FontClass::SlabSerif);
assert_eq!(classify(&latin(11, 3), 0, false, "Inter"), FontClass::SansSerif);
assert_eq!(classify(&latin(11, 9), 0, false, "Inter"), FontClass::Monospaced, "proportion 9");
assert_eq!(classify(&[3, 0, 0, 0, 0, 0, 0, 0, 0, 0], 0, false, "Brush"), FontClass::Script);
assert_eq!(classify(&[0; 10], 8, false, "Untitled"), FontClass::SansSerif, "IBM class 8");
assert_eq!(classify(&[0; 10], 0, true, "Untitled"), FontClass::Monospaced, "post.isFixedPitch");
assert_eq!(classify(&[0; 10], 0, false, "JetBrains Mono"), FontClass::Monospaced);
assert_eq!(classify(&[0; 10], 0, false, "Roboto Slab"), FontClass::SlabSerif);
assert_eq!(classify(&[0; 10], 0, false, "Plain"), FontClass::Other);
}
#[test]
fn bundled_fonts_are_classified() {
let db = crate::FontDb::global();
for (family, class) in [
("Source Serif 4", FontClass::Serif),
("Source Sans 3", FontClass::SansSerif),
("Inter", FontClass::SansSerif),
("JetBrains Mono", FontClass::Monospaced),
] {
let kind = db.face(family, "Regular").kind();
assert_eq!(kind.class, class, "{family}");
assert_eq!(kind.format, FontFormat::TrueType, "{family}");
}
let serif = db.face("Source Serif 4", "Regular").kind().panose;
let sans = db.face("Source Sans 3", "Regular").kind().panose;
let serif_bold = db.face("Source Serif 4", "Bold").kind().panose;
if let (Some(near), Some(far)) = (panose_distance(&serif, &serif_bold), panose_distance(&serif, &sans)) {
assert!(near < far, "the bold serif is nearer the serif than the sans is ({near} vs {far})");
}
}
#[test]
fn panose_distance_counts_shared_digits() {
let a = [2, 2, 5, 3, 5, 4, 5, 2, 2, 4];
assert_eq!(panose_distance(&a, &a), Some(0));
let b = [2, 11, 5, 3, 5, 4, 5, 2, 2, 4];
assert!(panose_distance(&a, &b).is_some_and(|d| d > 0));
assert_eq!(panose_distance(&a, &[0; 10]), None, "no PANOSE: nothing to compare");
}
}

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