mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-31 13:46:52 -06:00
Merge storytold/main into PR #1232
This commit is contained in:
Generated
+22
-21
@@ -1306,7 +1306,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft"
|
||||
version = "0.14.0"
|
||||
version = "0.15.2"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"craft-update",
|
||||
@@ -1330,7 +1330,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-cli"
|
||||
version = "0.14.0"
|
||||
version = "0.15.2"
|
||||
dependencies = [
|
||||
"designcraft-color",
|
||||
"designcraft-compose",
|
||||
@@ -1346,7 +1346,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-color"
|
||||
version = "0.14.0"
|
||||
version = "0.15.2"
|
||||
dependencies = [
|
||||
"craft-cms",
|
||||
"kurbo",
|
||||
@@ -1358,7 +1358,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-compose"
|
||||
version = "0.14.0"
|
||||
version = "0.15.2"
|
||||
dependencies = [
|
||||
"designcraft-color",
|
||||
"designcraft-doc",
|
||||
@@ -1378,7 +1378,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-doc"
|
||||
version = "0.14.0"
|
||||
version = "0.15.2"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"designcraft-color",
|
||||
@@ -1391,7 +1391,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-engine"
|
||||
version = "0.14.0"
|
||||
version = "0.15.2"
|
||||
dependencies = [
|
||||
"caseless",
|
||||
"craft-atomic",
|
||||
@@ -1431,7 +1431,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-epub"
|
||||
version = "0.14.0"
|
||||
version = "0.15.2"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"designcraft-color",
|
||||
@@ -1441,7 +1441,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-fonts"
|
||||
version = "0.14.0"
|
||||
version = "0.15.2"
|
||||
dependencies = [
|
||||
"core-text",
|
||||
"harfrust 0.13.3",
|
||||
@@ -1454,7 +1454,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-format"
|
||||
version = "0.14.0"
|
||||
version = "0.15.2"
|
||||
dependencies = [
|
||||
"designcraft-doc",
|
||||
"designcraft-images",
|
||||
@@ -1466,7 +1466,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-geom"
|
||||
version = "0.14.0"
|
||||
version = "0.15.2"
|
||||
dependencies = [
|
||||
"flo_curves",
|
||||
"kurbo",
|
||||
@@ -1479,7 +1479,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-idml"
|
||||
version = "0.14.0"
|
||||
version = "0.15.2"
|
||||
dependencies = [
|
||||
"designcraft-color",
|
||||
"designcraft-compose",
|
||||
@@ -1494,7 +1494,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-images"
|
||||
version = "0.14.0"
|
||||
version = "0.15.2"
|
||||
dependencies = [
|
||||
"craft-cms",
|
||||
"designcraft-color",
|
||||
@@ -1511,7 +1511,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-mcp"
|
||||
version = "0.14.0"
|
||||
version = "0.15.2"
|
||||
dependencies = [
|
||||
"designcraft-engine",
|
||||
"designcraft-geom",
|
||||
@@ -1524,7 +1524,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-pdf"
|
||||
version = "0.14.0"
|
||||
version = "0.15.2"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-cms",
|
||||
@@ -1536,6 +1536,7 @@ dependencies = [
|
||||
"designcraft-fonts",
|
||||
"designcraft-geom",
|
||||
"designcraft-images",
|
||||
"flate2",
|
||||
"hayro-cmap",
|
||||
"hayro-interpret",
|
||||
"hayro-syntax",
|
||||
@@ -1549,7 +1550,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-pptx"
|
||||
version = "0.14.0"
|
||||
version = "0.15.2"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"designcraft-color",
|
||||
@@ -1567,7 +1568,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-render"
|
||||
version = "0.14.0"
|
||||
version = "0.15.2"
|
||||
dependencies = [
|
||||
"craft-pdf",
|
||||
"designcraft-color",
|
||||
@@ -1586,7 +1587,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-textimport"
|
||||
version = "0.14.0"
|
||||
version = "0.15.2"
|
||||
dependencies = [
|
||||
"designcraft-doc",
|
||||
"quick-xml 0.41.0",
|
||||
@@ -1595,7 +1596,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-tools"
|
||||
version = "0.14.0"
|
||||
version = "0.15.2"
|
||||
dependencies = [
|
||||
"designcraft-color",
|
||||
"designcraft-compose",
|
||||
@@ -1607,7 +1608,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-ui-egui"
|
||||
version = "0.14.0"
|
||||
version = "0.15.2"
|
||||
dependencies = [
|
||||
"craft-dialogs",
|
||||
"craft-keymap",
|
||||
@@ -1639,7 +1640,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-web"
|
||||
version = "0.14.0"
|
||||
version = "0.15.2"
|
||||
dependencies = [
|
||||
"designcraft-engine",
|
||||
"designcraft-format",
|
||||
@@ -7603,7 +7604,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.14.0"
|
||||
version = "0.15.2"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.14.0"
|
||||
version = "0.15.2"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
@@ -68,6 +68,34 @@ list (author, source, license) is in ASSETS.md.
|
||||
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.
|
||||
- French hyphenation patterns, embedded through the hypher crate (feature "french"):
|
||||
hyph-fr.tex 2.13 from hyph-utf8, https://github.com/hyphenation/tex-hyphen.
|
||||
Copyright (C) 1994-2002 Daniel Flipo, Bernard Gaulle, 2016 Arthur Reutenauer. MIT License:
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software
|
||||
and associated documentation files (the "Software"), to deal in the Software without
|
||||
restriction, including without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following conditions: The above copyright notice
|
||||
and this permission notice shall be included in all copies or substantial portions of the
|
||||
Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
||||
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE
|
||||
AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
- Italian hyphenation patterns, embedded through the hypher crate (feature "italian"):
|
||||
hyph-it.tex 4.9 from hyph-utf8, https://github.com/hyphenation/tex-hyphen.
|
||||
Copyright (C) 2008-2011 Claudio Beccari. Available under the LPPL (1.3 or later) or the MIT
|
||||
License; taken here under the MIT License: Permission is hereby granted, free of charge, to any person obtaining a copy of this software
|
||||
and associated documentation files (the "Software"), to deal in the Software without
|
||||
restriction, including without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following conditions: The above copyright notice
|
||||
and this permission notice shall be included in all copies or substantial portions of the
|
||||
Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
||||
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE
|
||||
AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
- Norwegian hyphenation patterns, embedded through the hypher crate (feature "norwegian"):
|
||||
hyph-no.tex from hyph-utf8, https://github.com/hyphenation/tex-hyphen.
|
||||
Copyright (C) Rune Kleveland and Ole Michael Selberg. Copying and distribution of this file,
|
||||
|
||||
+66
-1
@@ -46,7 +46,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
|
||||
default layer queries find a placed PDF anywhere in mixed selections. Regression tests cover
|
||||
IDML export after rejection and both selection orders.
|
||||
|
||||
## Status (2026-10-01)
|
||||
## Status (2026-10-21)
|
||||
|
||||
**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.
|
||||
@@ -382,9 +382,17 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-30** · The Color Picker takes an eyedropper: dragged from the picker onto the page, it takes the colour the canvas shows (proofed under Proof Colors). No readiness percentage change.
|
||||
- **2026-10-30** · The Color Picker is laid out as the reference app's: a field and slider for the component chosen, HSB, Lab, RGB and CMYK values, the old colour, and Add CMYK Swatch; Enter and Esc close it once. No readiness percentage change.
|
||||
- **2026-10-18** · Table of Contents gains Create PDF Bookmarks: included headings become nested page destinations, regenerated in their existing outline position on update without replacing or moving surrounding manual bookmarks. The setting survives native and IDML files. No readiness percentage change.
|
||||
|
||||
Newest first.
|
||||
|
||||
- **2026-10-30** · Without facing pages, a page of a multi-page spread shows the parent page in its place, as in InDesign: the second page of a two-page spread shows the parent's second page. Every single-sided page showed the parent's first page, so an InDesign book set up without facing pages but laid out in two-page spreads repeated the left parent page's items (a black heading bar) on its right pages and lost the right parent page's. No readiness percentage change.
|
||||
- **2026-10-30** · File > Package opens a dialog, as the reference app's: a Summary of fonts, links and images and inks, pages for each, a Printing Instructions step, and a PDF made with the options of a preset (#1514, #1515).
|
||||
- **2026-10-30** · IDML import reads TOC separators and entry styles written with the `$ID/` prefix or as `Properties` children, and the TOC separator notation (`^t`, `^y`, `^m`, `^>`) is shared by the dialog and IDML, with a special-character menu on the separator field (#1249, #1255).
|
||||
- **2026-10-29** · Inserting text with many returns no longer takes time that grows with their square (part of #1588): each return copied its paragraph's format with its own insert, moving every format after it. Splitting 20,000 returns into paragraphs took 15.8 s; placing a text file of them now takes 0.2 s, and one of a million returns 5.5 s. No readiness percentage change.
|
||||
- **2026-10-28** · A placed image whose pixels would decode past 1 GiB of RGBA (256 Mpx) is refused from its header, before decoding (#1586). A 288 KB 23,000 px gray PNG used to hang the first draw for 9 s and take the app to 4.5 GB; it now draws as an empty frame, and PDF export skips it with a warning. A 300 ppi A0 scan still fits. No readiness percentage change.
|
||||
- **2026-10-28** · Discretionary hyphens (U+00AD) break in the single-line composer with automatic hyphenation on or off, matching the paragraph composer. Unbroken soft hyphens remain invisible and zero-width, and words containing them keep only their discretionary break points (#1955). No readiness percentage change.
|
||||
- **2026-10-28** · Autoflow no longer sizes the rest of a story by a frame that a Start Paragraph option (next column, frame, page, or odd or even page) ends early (#1209). With a title before a chapter that starts on the next odd page, it estimated a page per few characters, added up to 2000 pages (the autoflow limit) and removed them again: placing 2000 words took 36 s in the app, now 0.4 s, with the same pages. It looks past tracked-deleted paragraphs for the option, as composing does, and asks for every frame that start options and breaks force in one round: 200 one-line chapters on their own pages take 9 ms (58 ms before). A break and the start option it meets count once, and breaks that tracked changes delete or a hidden condition hides don't count. No readiness percentage change.
|
||||
- **2026-10-28** · IDML export writes the document's default `StoryPreference` in `Resources/Preferences.xml`, as InDesign does. Only each story carried one, and IDML readers that take the spread reading order from the document's (Okapi's IDML filter) failed on DesignCraft's files. No readiness percentage change.
|
||||
@@ -423,6 +431,7 @@ Newest first.
|
||||
- **2026-10-24** · A guide pulled out of a ruler keeps the resize cursor across it until it is dropped; the tool's cursor took over as soon as the pointer left the ruler, so the drag looked as if it had stopped. No readiness percentage change.
|
||||
- **2026-10-24** · Anchored text frames, such as marginal notes, compose where their host story places them: they align to the baseline grid at that position, and page-number markers and spine-relative alignment inside them read the host's page. They composed against a grid measured from their stored position and on no page, so a note set to Only Align First Line to Grid could sit a fraction of a line off the body text's grid line. No readiness percentage change.
|
||||
- **2026-10-24** · Tables: a gradient swatch in a cell fill draws as a gradient on the canvas and in PDF, in one gradient space for the whole table, so it runs across cells without restarting. Cells showed the gradient's first stop as a solid colour. No readiness percentage change.
|
||||
- **2026-10-22** · Paragraph Style Options › Paragraph Shading and Paragraph Border are laid out as InDesign's: headed with the page's name; Tint is a spinner with InDesign's presets and has Overprint beside it (shading, border and gap; a new attribute each, kept through IDML; PDF export warns it doesn't overprint yet); Corner Size and Shape is InDesign's two-by-two grid of corner icons, sizes and shape menus showing each shape's glyph, with the link in the middle; offsets and weights have spinners. Found in the Learn Skills Daily course, chapter 41. No readiness percentage change.
|
||||
- **2026-10-24** · New documents start with Hyphenate Capitalized Words, Hyphenate Last Word and Hyphenate Across Column off in [Basic Paragraph], as in InDesign. Opened and imported files keep the values they carry. No readiness percentage change.
|
||||
- **2026-10-24** · The baseline grid's Relative To setting applies: with Top Margin, the grid starts at each page's own top margin in text composition, on the canvas and for snapping. It started at the page top, so grid-aligned text sat on the wrong lines and a page could fit one line fewer. No readiness percentage change.
|
||||
- **2026-10-24** · Custom-positioned anchored objects honour Relative to Spine (`anchored.options {spineRelative}`, IDML `SpineRelative`): the settings describe a left-hand page and mirror on a right-hand page, so marginal notes stay in the outside margin on both pages of a spread. On right-hand pages they took the left-hand placement and landed in the inside margin, across the spine or over the body text. No readiness percentage change.
|
||||
@@ -433,15 +442,24 @@ Newest first.
|
||||
- **2026-10-18** · German text hyphenates using the MIT-licensed 2006 reform patterns (`hyph-de-1996.tex`) through `hypher`. Explicit 2006 names and plain/2006 locale codes reach both composers; traditional and unknown spelling variants stay unhyphenated. Tests cover this distinction, umlauts, ß, compounds, reformed ck/st breaks and paragraph limits. Six of InDesign's ~40 hyphenation languages. No readiness percentage change.
|
||||
- **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change.
|
||||
- **2026-10-23** · Translations: a missing string shows in English and fails no test; `cargo xtask i18n-report`, run by `cargo xtask ci`, prints the missing strings per language. Tests keep duplicate-key and placeholder checks and the language mechanics (lookup, context captions, plural captions, right-to-left). No readiness percentage change.
|
||||
- **2026-10-23** · Show Hidden Characters marks table cells as InDesign does: every cell shows its own spaces, tabs and paragraph returns and an end-of-story # after its text (an empty cell shows just the #), and the table paragraph's ¶ sits after the table's bottom right corner. Cells showed no marks. No readiness percentage change.
|
||||
- **2026-10-23** · Tables: with the Type tool in a cell, Shift+Tab goes back a cell (it went on to the next one) and Esc toggles between the text and the cell, as in InDesign: the insertion point selects its cell, and selected cells select the text of the first one (`table.toggleSelection`). Esc left the table for the frame. No readiness percentage change.
|
||||
- **2026-10-23** · Direct Selection shows a path's direction handles as InDesign: both handles of each selected anchor and, on its neighbours, the handle shaping the segment to it; no handles with no anchor selected, and handles that don't show can't be grabbed. The tool draws the path and its points without the bounding box. Found in the Learn Skills Daily course, chapter 43. No readiness percentage change.
|
||||
- **2026-10-23** · Tables: Convert Text to Table… and Convert Table to Text… open InDesign's dialogs (they converted at once, with tabs and paragraphs only). Column and Row Separator take Tab, Comma, Paragraph or any characters typed; Number of Columns, available when both separators are the same, fills rows of that many cells (a list of paragraphs into two columns); Convert Text to Table also applies a Table Style. `table.convertFromText` takes `rowSeparator`, `columns` and `tableStyle`, and `table.convertToText` its separators. Convert Text to Table is available only with text selected outside a table. No readiness percentage change.
|
||||
- **2026-10-23** · Tables: the Table menu follows InDesign's order (Cell Options before Insert, Delete and Select; Merge, Unmerge and Split before Convert Cell Type and Convert Rows; Distribute Rows Evenly before Distribute Columns Evenly), and its first item is Insert Table… with InDesign's Cmd+Alt+Shift+T, which opens the dialog (the shortcut ran `table.insert` with its defaults, behind the dialog). The Table panel stays in Window › Type & Tables (Shift+F9). The Insert Table dialog is laid out as InDesign's: Body Rows and Columns, then Header and Footer Rows, as numbers with spinners and right-aligned labels, and a Table Style applied to the new table. No readiness percentage change.
|
||||
- **2026-10-22** · Hyphenation Settings, as in InDesign: the Paragraph panel menu's Hyphenation… (`app.hyphenationDialog`) sets the selected paragraphs' Hyphenate, Words with at Least, After First, Before Last, Hyphen Limit, Hyphenation Zone, the Better Spacing ↔ Fewer Hyphens slider (InDesign's 0–10 steps), Hyphenate Capitalized Words, Last Word and Across Column, with Preview, as one undo step; Paragraph Style Options › Hyphenation has the same form. The panel menu lists its items in InDesign's order. Replicated from a hyphenation tutorial: the same text in Minion Pro breaks into the same lines as InDesign's with hyphenation on, off, and at Fewer Hyphens. No readiness percentage change.
|
||||
- **2026-10-22** · Hyphenate Across Column (Hyphenation Settings; IDML HyphenateAcrossColumns) is applied: with it off, a paragraph whose line ending in an automatic hyphen would end a column or frame is re-composed without that hyphenation point, so the word moves on whole, as in InDesign. Opened and imported files keep their own setting, so their layouts don't change. No readiness percentage change.
|
||||
- **2026-10-22** · The Paragraph panel is laid out as InDesign's: the nine alignment buttons in groups; indent, spacing and drop cap fields by their icons in two columns, with Space Between Paragraphs Using Same Style (Ignore or a space); Shading and Border with the Paragraph Borders and Shading button and colour; Hyphenate with Do Not Align / Align to Baseline Grid. Its panel menu follows InDesign's order: Paragraph and Single-line Composer, Only Align First Line to Grid, Balance Ragged Lines, Optical Margin Alignment, Justification…, Keep Options…, Span Columns…, Hyphenation…, Drop Caps and Nested Styles…, GREP Styles… (these three now open for the selected paragraphs), Paragraph Rules…, Paragraph Borders and Shading…, Bullets and Numbering…, Restart/Continue Numbering, Define Lists… and the paragraph direction. Convert Bullets and Numbering to Text is listed but not available yet. No readiness percentage change.
|
||||
- **2026-10-22** · Preferences › Units & Increments with no document open, as in InDesign: the ruler units (horizontal and vertical), the ruler origin and the cursor-key and kerning increments set there are what new documents start with, and New Document opens in those units. They are kept with the application preferences (`prefs.set {defaultUnits}`); a document keeps its own once made. Reported by a reader who works in inches and had to change every file. No readiness percentage change.
|
||||
- **2026-10-22** · French and Italian text is hyphenated, with hyph-utf8's patterns through hypher (hyph-fr 2.13 and hyph-it 4.9, MIT): for the Language set in the Character panel (French, French: Canadian, French: Swiss, Italian, or fr / it locale codes). A word with an elision hyphenates after its apostrophe (l'im-pri-me-rie, dell'im-pa-gi-na-zio-ne), in French and Italian only; other languages leave apostrophe words unbroken. Gap 13 shrinks. No readiness percentage change.
|
||||
- **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change.
|
||||
- **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change.
|
||||
- **2026-10-23** · Type › Text Variables › Convert Variable to Text (`variables.convertToText`): the variable instances in the selected text, in a story, a table cell or a footnote, become plain text of the value each shows on its own page (a running header on page 3 becomes page 3's heading). `variables.convertAllToText {name}` does the same for every instance of one variable in the document, as the Text Variables dialog's Convert to Text button. The variable stays defined; one undo step. No readiness percentage change.
|
||||
- **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change.
|
||||
- **2026-10-22** · Paragraph rules: Rule Above's offset is measured from the first line's baseline to the rule's bottom edge, as in InDesign. Rules sat one ascent too high and crossed the previous paragraph's text (reported from an imported INDD document). No readiness percentage change.
|
||||
- **2026-10-21** · Ruler guides are selected like objects (#1282): a click with the Selection tools selects a guide (also where it crosses an object), Shift-click adds or removes one, a new guide from the rulers is selected, a marquee that touches no object selects the guides it crosses, and Edit › Select All Guides (⌥⌘G, `guide.selectAll`) selects every unlocked guide on the spread. Selected guides draw thicker in their layer's color, drag together (all of them shown moving), nudge with the arrow keys and go with Delete, each as one undo step (`guide.select`, `guide.deselect`, `guide.moveBy`, `guide.delete {}`). With one guide selected the Control bar's X (vertical guide) or Y (horizontal guide) field and Properties › Transform show where it is, as the rulers measure, and typing a value moves it there. No readiness percentage change.
|
||||
- **2026-10-21** · Properties for points selected with Direct Selection, as InDesign's: the title is "Anchor Point(s)", and the panel shows Transform, Appearance, Convert Points (Plain, Corner, Smooth, Symmetrical) and the Delete Point(s) quick action (`path.deletePoints`, one undo step). Found in Zero2Graphic's *How to Page Layout design in Adobe InDesign 2022 CC*. No readiness percentage change.
|
||||
- **2026-10-20** · Direct Selection selects anchor points: a marquee selects the points inside it on every path it reaches, a click selects one point and Shift+click adds or removes one, selected points show solid, and dragging one (Shift: at 45° steps) or pressing the arrow keys moves them all in one step (`selection.set {anchors}`, `path.moveAnchors {points}`). No readiness percentage change.
|
||||
- **2026-10-19** · A PDF export can repeat its bytes: it recorded the second it was made in, so exporting one document twice gave different files though the pages matched. `SOURCE_DATE_EPOCH` stands in for the clock when the export is given no date, as the reproducible-builds specification defines it; a date passed in still wins, and a malformed value is ignored rather than refusing the export. The timestamp was the only thing that differed, so the files are now identical byte for byte. No readiness percentage change.
|
||||
- **2026-10-17** · PDF/X-4 export warns about fonts drawing visible text without an embedded
|
||||
font program, including placed PDF forms; `validate-pdf` reports the font and page (#909).
|
||||
@@ -455,14 +473,58 @@ Newest first.
|
||||
- **2026-10-16** · Lock Guides and Lock Column Guides are commands in View ▸ Grids & Guides (`view.lockGuides`, ⌥⌘;, and `view.lockColumnGuides`), so the control channel and MCP reach them. Lock Column Guides is an undo step, is saved with the document, on in new ones, and read from and written to IDML's `ColumnGuideLocked`, which export always wrote as locked (#1096). No readiness percentage change.
|
||||
- **2026-10-16** · IDML: a page keeps column guides that were dragged off centre (InDesign's Custom columns). They were divided evenly on import, so the gutter sprang back to the middle; the imported positions now place the columns, with the canvas guides, snapping and text placement following them, and they are written back on export. Layout › Margins and Columns re-divides them evenly when the count or gutter changes, as InDesign's does. No readiness percentage change.
|
||||
- **2026-10-16** · IDML: custom column positions are checked before they are used (#1096). A set must give two edges per column, ascending from the left margin to the right one; any other (a wrong count, unsorted or non-numeric values, a list longer than 216 columns allow, or positions left behind by a margin or page-size change) is the even split, in the canvas, on import and on export, instead of columns drawn outside the margins. No readiness percentage change.
|
||||
- **2026-10-21** · Paragraph Border and Paragraph Shading link their four sides as InDesign's: the stroke weights, offsets and corners have the "Make all settings the same" link, on when the four are the same, so a side typed in sets all four; with the link off a side is set alone (a border with only a thick left side). Found in the Learn Skills Daily course, chapter 40. No readiness percentage change.
|
||||
- **2026-10-21** · Paragraph rules have InDesign's Type (solid, thick/thin stripes, dashed, dotted, straight hash, wavy, or a stroke style of the document), Gap Color and Gap Tint, and Keep In Frame (rule above); the Paragraph Rules page is laid out as InDesign's (Weight and Type, Color and Tint, Gap Color and Gap Tint, Width and Offset, Left and Right Indent). IDML reads and writes them (`RuleAboveType`, `RuleBelowGapColor`, `KeepRuleAboveInFrame`…). Found in the Learn Skills Daily course, chapter 20. No readiness percentage change.
|
||||
- **2026-10-21** · New Character Style with text selected starts from what the text has of its own over its paragraph style, as InDesign's ("[None] + Bold + color"), and Apply Style to Selection gives the text the new style, with no override left. A new style's dialog is titled New Paragraph Style / New Character Style. `type.selectionAttrs` reports the text's own character attributes (`ownChars`). Found in the Learn Skills Daily course, chapter 22. No readiness percentage change.
|
||||
- **2026-10-21** · New Paragraph Style with text selected starts from it, as InDesign's (Based On [No Paragraph Style], every setting of the text that differs) and Apply Style to Selection gives the paragraph the new style; General has Next Style and Reset To Base; Basic Character Formats is in two columns (Size | Leading, Kerning | Tracking, Case | Position). `style.paragraph.edit` honours `nextStyle` (it was ignored) and takes `resetToBase`. Found in the Learn Skills Daily course, chapter 20. No readiness percentage change.
|
||||
- **2026-10-21** · Find/Change has InDesign's Change and Change/Find: Change changes the text Find Next found and keeps it selected, Change/Find also finds the next match, and the search keeps its domain (a selection or story) across them. The Search list reads Document, Story and Selection. `find.change` takes `found`. Found in the Learn Skills Daily course, chapter 22. No readiness percentage change.
|
||||
- **2026-10-21** · Find/Change has InDesign's Find Format: Specify Attributes opens Find Format Settings (the same Style Options and character sections as Change Format Settings), and only text with the format is found. With nothing in Find what, each stretch of text with the format is found, so Change All can restyle every word in a character style. `find.find`, `find.next` and `find.change` take `findFormat {charStyle?, paraStyle?, chars?}`. Found in the Learn Skills Daily course, chapter 22. No readiness percentage change.
|
||||
- **2026-10-21** · Find/Change has InDesign's Change Format. Specify Attributes opens Change Format Settings: Style Options (the character and paragraph style to apply, [Any Style] for none), then Basic and Advanced Character Formats, Character Color, OpenType Features, Underline and Strikethrough Options. The box shows the format ("Character Style: eggs"), and Change All gives it to the changed text. With a Change Format and nothing in Change to, the found text stays and is only formatted, as InDesign's (an ⓘ beside Change to says so). `find.change` takes `charStyle` and `paraStyle`. Found in the Learn Skills Daily course, chapter 22. No readiness percentage change.
|
||||
- **2026-10-21** · Preferences › Interface follows InDesign's: Appearance has a Color Theme swatch per interface theme (a click shows it at once, Cancel goes back) and Panels sets the Tools panel to a single or double column; UI Size moves to a UI Scaling section of its own. Interface held only UI Size, and the theme could be changed only from the Window menu. Found in the Learn Skills Daily course, chapter 26. No readiness percentage change.
|
||||
- **2026-10-21** · Preferences › File Handling shows Number of Recent Items to Display as a whole number ("20"; it read "20.0"). Found in the Learn Skills Daily course, chapter 26. No readiness percentage change.
|
||||
- **2026-10-21** · Insert Pages… opens InDesign's dialog: Pages, Insert (After Page, Before Page, At Start of Document, At End of Document) with the page by name, and Parent ([None] or a parent), set to the page in view and its parent. It was a form asking for a page index and a parent id, and pages could not go before the first page; `layout.pages.insert` takes `before` (0: at the start). Found in the Learn Skills Daily course, chapter 25. No readiness percentage change.
|
||||
- **2026-10-21** · Spelling (Check Spelling and Dynamic Spelling) no longer flags everyday words the bundled hyphenation word list lacks: "we", "us", "if", titles such as "Mr", and words such as email, online, website, database, desktop and download, with their plurals and other forms. Contractions typed with a typographer's apostrophe (don’t, we’re), which Use Typographer's Quotes produces by default, count like their straight-apostrophe forms. Dynamic Spelling used to underline most words of an ordinary sentence. Found in the Learn Skills Daily course, chapter 26. No readiness percentage change.
|
||||
- **2026-10-21** · While a parent is shown, the status bar's page box and Properties › Page name it ("A-Parent") instead of page 1, as InDesign's; their lists have the parents after the pages, and choosing one shows that parent (a page leaves the parents). Found in the Learn Skills Daily course, chapter 25. No readiness percentage change.
|
||||
- **2026-10-21** · Effects dialog: the Angle field of the shadows, Bevel and Emboss, Satin and Gradient Feather has InDesign's dial beside it, its radius pointing where the light comes from; a click or drag on it sets the angle. Found in the Learn Skills Daily course, chapter 14. No readiness percentage change.
|
||||
- **2026-10-21** · A double-click on a Fill or Stroke chip of Properties › Appearance opens the Color Picker with its colour, as in InDesign (a click still opens the swatches); for selected text, its own chips open it for the text. Found in the Learn Skills Daily course, chapter 23. No readiness percentage change.
|
||||
- **2026-10-20** · Properties › Appearance: with several text frames selected, the Fill and Stroke popups offer Apply To Frame or Text, as with one frame, so their text can be coloured at once, as in InDesign. No readiness percentage change.
|
||||
- **2026-10-20** · Properties › Appearance: the arrow beside Opacity opens a slider under the field, as in InDesign, instead of the Effects panel; a drag shows the opacity live and is one undo step (the Opacity link still opens Effects). No readiness percentage change.
|
||||
- **2026-10-20** · Properties › Frame Fitting has InDesign's Auto-Fit checkbox and Options button (Frame Fitting Options); the app's checkboxes are now named and ticked-or-not for screen readers. No readiness percentage change.
|
||||
- **2026-10-20** · Properties › Text Wrap shows the four wrap offsets under the wrap buttons, with the link that makes them the same, as InDesign's Properties panel does (greyed with no wrap). No readiness percentage change.
|
||||
- **2026-10-21** · Properties › Bullets and Numbering › Options opens the Bullets and Numbering dialog for the selected paragraphs, as InDesign's, instead of the Paragraph panel. Found in the Learn Skills Daily course, chapter 21. No readiness percentage change.
|
||||
- **2026-10-20** · Properties › Appearance: the corner shape list shows each shape's glyph beside its name, drawn as InDesign draws them (Fancy has its own glyph now), and the stroke type list shows each type's line; both dropdowns are named for screen readers. No readiness percentage change.
|
||||
- **2026-10-21** · Properties › Align: the Align To button shows what the objects align to, one glyph each for the selection, the key object, the margins, the page and the spread, as InDesign's. Found in the Learn Skills Daily course, chapter 12. No readiness percentage change.
|
||||
- **2026-10-20** · Object › Corner Options opens a Corner Options dialog laid out as InDesign's: each corner's size and shape, the link button that makes all four the same, and Preview. The Corner link in Properties › Appearance opens it too (it opened a Rectangle size dialog). No readiness percentage change.
|
||||
- **2026-10-20** · The Object menu is in InDesign's order and groups; Paths has Join, Open Path and Close Path (Convert Shape lists only shapes, as InDesign's does) and a Convert Point submenu (Plain, Corner, Smooth, Symmetrical) is added. No readiness percentage change.
|
||||
- **2026-10-20** · Layout › Create Guides puts its guides on both pages of the spread in view, as InDesign does; "This page" in Apply to narrows them to one page. No readiness percentage change.
|
||||
- **2026-10-21** · Object › Fitting › Clear Frame Fitting Options (`object.clearFittingOptions`): the frames' auto-fit, crop amounts and reference point go back to their defaults, and the picture stays where it is. The Fitting submenu is grouped as InDesign's. Found in the Learn Skills Daily course, chapter 11. No readiness percentage change.
|
||||
- **2026-10-21** · Swatches panel rows show InDesign's columns, drawn in code: a crossed pencil on the swatches that can't be edited ([None], [Registration], [Black]), the colour type (process or spot) and the colour mode (CMYK, RGB, Gray, or [None]'s and [Registration]'s own glyphs), in place of a lock and "CMYK" text. Tint reads 100 with nothing selected. No readiness percentage change.
|
||||
- **2026-10-21** · With a gradient fill or stroke, the Color panel shows the stop selected in the Gradient panel (the stop before a selected midpoint) with its values, and its C/M/Y/K or R/G/B sliders and spectrum colour that stop, keeping the others, as in InDesign. It showed "[None] — choose a colour on the ramp", and a spectrum click replaced the whole gradient with a flat colour. Found in the Learn Skills Daily course, chapter 24. No readiness percentage change.
|
||||
- **2026-10-20** · A new document's Swatches panel lists InDesign's defaults: [None], [Registration], [Paper], [Black] and six process colours (C=100, M=100, Y=100, C=15 M=100 Y=100, C=75 M=5 Y=100, C=100 M=90 Y=10); the extra M=100 Y=100, C=100 Y=100 and C=100 M=100 swatches are gone. No readiness percentage change.
|
||||
- **2026-10-21** · The readout beside the cursor while drawing or measuring ("W: 3.13 cm") is InDesign's light grey box with white text, measured from the tutorials' frames; High Contrast keeps it black. It was a dark translucent box. No readiness percentage change.
|
||||
- **2026-10-20** · Drawing a frame or shape shows its width and height in the document's ruler units, to two decimals and on two lines ("W: 6.08 cm", "H: 6.08 cm"), as InDesign does; it was always whole points. No readiness percentage change.
|
||||
- **2026-10-20** · Type tool: a drag that starts in an empty frame draws a new text frame over it, as in InDesign; a click still turns the frame into a text frame. No readiness percentage change.
|
||||
- **2026-10-20** · Measures in centimetres show four decimals, as InDesign's fields do (a 1p gutter reads 0.4233 cm, not 0.423 cm). No readiness percentage change.
|
||||
- **2026-10-21** · Text Frame Options is laid out as InDesign's, with Preview: General, Column Rules, Baseline Options, Auto-Size; framed Columns (Number and Width, Gutter and Maximum, Balance Columns), Inset Spacing (Top/Bottom, the link, Left/Right) and Vertical Justification (Align, Paragraph Spacing Limit) with spinner fields; Fixed Number shows each column's width as the fields change. Preview shows the change on the page until OK (one undo step) or Cancel. Justify honours the Paragraph Spacing Limit (`paragraphSpacingLimit` on `object.textFrameOptions`): the space goes between paragraphs up to the limit, the rest between lines. Found in the Learn Skills Daily course, chapter 18. No readiness percentage change.
|
||||
- **2026-10-21** · Swatches panel: a swatch is renamed in place, as in InDesign: click it, then click its name again (a slow double-click), type, and Enter (Escape keeps the name). Everything that uses it follows; a double-click still opens Swatch Options, and [None], [Registration], [Paper] and [Black] keep their names. Found in the Learn Skills Daily course, chapter 23. No readiness percentage change.
|
||||
- **2026-10-20** · The loaded place cursor shows a thumbnail of the graphic, as InDesign's does; dragging with it draws the frame at the graphic's proportions from where the drag started, with its scale beside the pointer, and places exactly that frame. No readiness percentage change.
|
||||
- **2026-10-21** · Drop Shadow, Inner Shadow and Outer Glow have a blend mode, as InDesign's (Effects dialog › Blending › Mode; `mode` on `object.dropShadow`, `object.innerShadow`, `object.outerGlow`; IDML `BlendMode`). New shadows start Multiply and new outer glows Screen in white ([Paper]); effects saved before keep Normal, so documents look as they did. Found in the Learn Skills Daily course, chapter 14. No readiness percentage change.
|
||||
- **2026-10-14** · The Pages panel labels spreads as InDesign does ("2–3", "2, 1" across a section start, "8, A"), marks each section's first page with a triangle, and scrolls to the page in view. No readiness percentage change.
|
||||
- **2026-10-18** · Double-clicking a section triangle in the Pages panel opens that page’s numbering options, including from parent-page editing. No readiness percentage change.
|
||||
- **2026-10-18** · TOC separators preserve right-indent tabs through the dialog and IDML, including localized `$ID/^y` values. A separator picker exposes Tab Character, Right Indent Tab, Em Space and En Space. Existing paragraph tab leaders now work with the tutorial's TOC workflow. No readiness percentage change.
|
||||
- **2026-10-21** · The Package dialog's Include PDF (Print) has a PDF Preset (the Export PDF presets, the last one used first), as InDesign's Select PDF Preset; `file.package` takes `pdf: {…file.exportPdf options}`, refusing bad options before writing anything. The packaged PDF always had the default settings. Found in the Learn Skills Daily course, chapter 27. No readiness percentage change.
|
||||
- **2026-10-21** · File › Package… opens InDesign's Package dialog: Summary counts the fonts, links and images and inks the package carries, with a warning where some are missing or modified, and Fonts, Links and Images and Colors and Inks list them (Show Problems Only). Create Printing Instructions asks for the contact details and instructions that go into the package's Instructions.txt, and Include IDML / Include PDF (Print) choose the copies it carries. Package… went straight to the folder picker with fixed options. Found in the Learn Skills Daily course, chapter 27. No readiness percentage change.
|
||||
- **2026-10-21** · Properties › Character and Paragraph: ••• (More Options) expands the section in place, as InDesign's: Character adds vertical and horizontal scale, baseline shift, skew, language and OpenType, and All Caps, Small Caps, Superscript, Subscript, Underline and Strikethrough; Paragraph adds the left, right, first line and last line indents, space before and after, the drop cap's lines and characters, Align to Baseline Grid and Hyphenate. It used to open the Character or Paragraph panel; the language field moved under ••• as in InDesign. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change.
|
||||
- **2026-10-21** · The text Control panel has InDesign's two modes: A (Character Formatting Controls: font and style, size and leading, All Caps/Superscript/Underline and Small Caps/Subscript/Strikethrough, kerning and tracking, vertical and horizontal scale, baseline shift and skew, character style and language) and ¶ (Paragraph Formatting Controls: the nine alignments, left/first line and right/last line indents, space before and after, the drop cap's lines and characters, paragraph style, Hyphenate and Align to Baseline Grid). It used to show a fixed mix of both. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change.
|
||||
- **2026-10-20** · With the Type tool, the Control panel's font size, leading and tracking fields have a spinner and a list of common values, as in InDesign (auto leading in parentheses). No readiness percentage change.
|
||||
- **2026-10-21** · The ¶ (Paragraph Formatting Controls) mode of the text Control panel has InDesign's Bulleted List and Numbered List buttons (with the default hanging indent) and Shading and Border: a checkbox, a button opening Paragraph Borders and Shading on that tab, and the colour. Found in the Learn Skills Daily course, chapter 21. No readiness percentage change.
|
||||
- **2026-10-21** · The Bulleted and Numbered List buttons (Properties, and Type › Bulleted & Numbered Lists › Apply Bullets / Apply Numbers) work as InDesign's: a paragraph with no indents gets the default hanging indent (0.25 in, −0.25 in), so the text lines up after the bullet instead of at the next default tab stop, and pressing the button again removes the list and that indent (`list.bullets`, `list.numbers`). Found in the Learn Skills Daily course, chapter 21. No readiness percentage change.
|
||||
- **2026-10-18** · Table of Contents offers More Options / Fewer Options, preserving page-number, separator, sorting and level settings while their controls are hidden. No readiness percentage change.
|
||||
- **2026-10-18** · Table of Contents reserves the title field's width so Contents is readable next to its style picker; the grid previously squeezed it to a few characters. No readiness percentage change.
|
||||
- **2026-10-17** · Inline and anchored objects paint under their frame's text, on screen and in PDF, as InDesign draws them: a picture whose frame reaches into the next line (a negative Y offset) no longer hides the top of that line. No readiness percentage change.
|
||||
- **2026-10-17** · Running headers: *first/last on page* read a page in text order (story order, then column by column), not by height — a multi-column page's right header showed the lowest entry of column 1 instead of the page's last entry. No readiness percentage change.
|
||||
- **2026-10-17** · Parent pages: while parents are edited, the document pages' hidden characters, spelling squiggles and other overlays no longer pile onto the right parent page. No readiness percentage change.
|
||||
- **2026-10-16** · A Direct Selection click on an image whose frame is already selected selects the image, so Delete removes only the image and leaves the empty frame, as in InDesign. No readiness percentage change.
|
||||
- **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change.
|
||||
- **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change.
|
||||
- **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change.
|
||||
@@ -475,6 +537,7 @@ Newest first.
|
||||
- **2026-10-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change.
|
||||
- **2026-10-16** · Greek text hyphenates. The patterns were never compiled in, so Greek set unhyphenated however narrow the column; `"Greek"`, `"Greek: Greece"`, `el` and `el-GR` all resolve now and ανθρωπος breaks αν-θρω-πος. They are the monotonic patterns (hyph-el-monoton, Dimitrios Filippou, under MIT), which is what polytonic text hyphenates by too. Five of InDesign's ~40 hyphenation languages. No readiness percentage change.
|
||||
- **2026-10-16** · A swatch applied to a placed greyscale or bitmap image colours it, as in InDesign: IDML's `<Image FillColor>` is read and written, greyscale pixels become a tint of the swatch on opaque white and a bitmap's white lets the frame show through, on screen and in PDF (spot swatches print on their plate). Colour images keep their colours. Applying the swatch from the Swatches panel comes next. No readiness percentage change.
|
||||
- **2026-10-15** · Anchored objects wrap their own story as their anchor lines are set, as in InDesign: the layout no longer depends on where an earlier composition put them (a long story whose pictures moved between frames from pass to pass never settled). No readiness percentage change.
|
||||
- **2026-10-15** · Color Theme: the toolbar tool previews up to five colours from a placed raster and adds them as one undoable swatch group. Local deterministic extraction; transparent padding is ignored. No readiness percentage change.
|
||||
- **2026-10-15** · Applying text styles removes matching local formatting so capture sources
|
||||
inherit later edits; real overrides and named character styles remain.
|
||||
@@ -534,6 +597,8 @@ Newest first.
|
||||
- **2026-10-15** · Flex Layout (InDesign 2026): Object › Flex Layout (Add to, Convert to, Remove) and a Flex Layout panel laid out as InDesign's, also in Properties. A container lays out its objects by direction, wrap, justification, the alignment grid, Fixed/Auto width and height, padding and spacing, after every edit; Add detects aligned objects (direction, Auto size, Space Between at their spacing); resizing a container reflows its objects instead of scaling them; dragging an object in one reorders it. Montilla Design's tutorial replays step for step. No readiness percentage change.
|
||||
- **2026-10-15** · IDML: the CJK leading model InDesign writes on [No paragraph style] of every document (Aki Below) only applies when the document uses a Japanese composer, as in InDesign; a Western document's paragraphs after a size change no longer sit lower than in InDesign (Montilla Design's Flex Layout file: its captions came out overset). No readiness percentage change.
|
||||
- **2026-10-15** · IDML: linked images in a subfolder beside the package load when the package is opened on another machine (a link to `/images/logo.png` finds `images/logo.png` next to the .idml); they showed as missing. No readiness percentage change.
|
||||
- **2026-10-14** · Numbering & Section Options opened on a page that doesn't start a section is New Section, as in InDesign: Start Section on, 1 offered, and the start numbers stay editable (typing one chooses it). No readiness percentage change.
|
||||
- **2026-10-14** · The Direct Selection tool pressed on a corner or anchor of an object that isn't selected selects it and pulls just that point, as in InDesign (it moved the whole object). No readiness percentage change.
|
||||
- **2026-10-14** · Overset text is marked on every frame that ends an overset story, selected or not and inside groups too (the red mark at the out port, with frame edges shown); a selected group shows the in/out ports of the text frames inside it. InDesign shows it only on the selected frame, which hides overset labels in grouped objects until Preflight. No readiness percentage change.
|
||||
- **2026-10-14** · Load Swatches adds the colors of an `.ase` file that repeat a name or have none: a repeated name gets a number (`Red 2`), and a color without a name is named by its value (`C=0 M=0 Y=0 K=55`). Before, a repeated name kept one of its colors, and only the first unnamed color was added, with an empty name. No readiness percentage change.
|
||||
- **2026-10-14** · Objects snap to the bleed guide's four sides while moving, resizing or drawing; snapping and the canvas share one bleed rectangle (`Spread::bleed_rect`). No readiness percentage change.
|
||||
|
||||
@@ -230,10 +230,12 @@ fn build(
|
||||
path: &str,
|
||||
recent: &mut Option<Submenu>,
|
||||
) {
|
||||
for e in entries {
|
||||
for (i, e) in entries.iter().enumerate() {
|
||||
match e {
|
||||
Item::Sep => {
|
||||
let _ = parent.append(&PredefinedMenuItem::separator());
|
||||
if menus::has_shown_item(app, &entries[i + 1..]) {
|
||||
let _ = parent.append(&PredefinedMenuItem::separator());
|
||||
}
|
||||
}
|
||||
Item::Sub(label, children) => {
|
||||
let sub = Submenu::new(menu_title(app, label), true);
|
||||
|
||||
+27
-12
@@ -145,7 +145,9 @@ pub fn apply_tint(c: Color, tint: f32) -> Color {
|
||||
}
|
||||
}
|
||||
|
||||
/// A new document's swatches, in panel order.
|
||||
/// A new document's swatches, in panel order: InDesign's four specials, then its six process
|
||||
/// colours (cyan, magenta, yellow, red, green and blue as C=15 M=100 Y=100, C=75 M=5 Y=100 and
|
||||
/// C=100 M=90 Y=10).
|
||||
pub fn default_swatches() -> Vec<Swatch> {
|
||||
let special = |name: &str, value: SwatchValue| Swatch { name: name.into(), value, locked: true, named: true, hidden: false };
|
||||
let mut v = vec![
|
||||
@@ -154,17 +156,9 @@ pub fn default_swatches() -> Vec<Swatch> {
|
||||
special(PAPER, SwatchValue::Paper { color: Color::WHITE }),
|
||||
special(BLACK, SwatchValue::Color { color: Color::cmyk(0.0, 0.0, 0.0, 1.0), color_type: ColorType::Process }),
|
||||
];
|
||||
for (c, m, y, k) in [
|
||||
(1.0, 0.0, 0.0, 0.0),
|
||||
(0.0, 1.0, 0.0, 0.0),
|
||||
(0.0, 0.0, 1.0, 0.0),
|
||||
(0.0, 1.0, 1.0, 0.0),
|
||||
(1.0, 0.0, 1.0, 0.0),
|
||||
(1.0, 1.0, 0.0, 0.0),
|
||||
(0.15, 1.0, 1.0, 0.0),
|
||||
(0.75, 0.05, 1.0, 0.0),
|
||||
(1.0, 0.9, 0.1, 0.0),
|
||||
] {
|
||||
for (c, m, y, k) in
|
||||
[(1.0, 0.0, 0.0, 0.0), (0.0, 1.0, 0.0, 0.0), (0.0, 0.0, 1.0, 0.0), (0.15, 1.0, 1.0, 0.0), (0.75, 0.05, 1.0, 0.0), (1.0, 0.9, 0.1, 0.0)]
|
||||
{
|
||||
v.push(Swatch::cmyk(c, m, y, k));
|
||||
}
|
||||
v
|
||||
@@ -188,6 +182,27 @@ mod tests {
|
||||
assert!(s.iter().any(|s| s.name == "C=100 M=0 Y=0 K=0"));
|
||||
}
|
||||
|
||||
/// The Swatches panel of a new InDesign document (Zero2Graphic's page layout tutorial, 3:22).
|
||||
#[test]
|
||||
fn defaults_are_indesigns_ten() {
|
||||
let names: Vec<String> = default_swatches().into_iter().map(|s| s.name).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
"[None]",
|
||||
"[Registration]",
|
||||
"[Paper]",
|
||||
"[Black]",
|
||||
"C=100 M=0 Y=0 K=0",
|
||||
"C=0 M=100 Y=0 K=0",
|
||||
"C=0 M=0 Y=100 K=0",
|
||||
"C=15 M=100 Y=100 K=0",
|
||||
"C=75 M=5 Y=100 K=0",
|
||||
"C=100 M=90 Y=10 K=0",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_tints_and_specials() {
|
||||
let mut s = default_swatches();
|
||||
|
||||
@@ -21,7 +21,7 @@ log = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
fancy-regex = { workspace = true }
|
||||
miniz_oxide = "0.8"
|
||||
hypher = { version = "0.1.8", default-features = false, features = ["alloc", "german", "greek", "portuguese", "spanish", "norwegian"] }
|
||||
hypher = { version = "0.1.8", default-features = false, features = ["alloc", "french", "german", "greek", "italian", "portuguese", "spanish", "norwegian"] }
|
||||
|
||||
[dev-dependencies]
|
||||
designcraft-fonts = { workspace = true, features = ["testing"] }
|
||||
|
||||
@@ -385,6 +385,16 @@ impl Grep {
|
||||
self.re.find_iter(text).take(limit).map(|m| m.map(|m| m.range()).map_err(run_err)).collect()
|
||||
}
|
||||
|
||||
/// The first match starting at or after byte `start`, with its replacement: `template`
|
||||
/// expanded as [`Self::replacements`] does.
|
||||
pub fn replacement_at(&self, text: &str, start: usize, template: &str) -> Result<Option<(Range<usize>, String)>, GrepError> {
|
||||
if start > text.len() || !text.is_char_boundary(start) {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(caps) = self.re.captures_from_pos(text, start).map_err(run_err)? else { return Ok(None) };
|
||||
Ok(caps.get(0).map(|m| (m.range(), fancy_regex::Expander::default().expansion(template, &caps))))
|
||||
}
|
||||
|
||||
/// Non-empty matches in `text`, at most `limit` of them: an empty match styles nothing, so it
|
||||
/// doesn't count toward a GREP style's cap.
|
||||
pub fn find_nonempty(&self, text: &str, limit: usize) -> Result<Vec<Range<usize>>, GrepError> {
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
//! `hyph-no.tex` from hyph-utf8 (Rune Kleveland, Ole Michael Selberg), permissive (see NOTICE);
|
||||
//! Greek is `hyph-el-monoton.tex` 5.0 from hyph-utf8 (Dimitrios Filippou), LPPL or MIT, taken
|
||||
//! under MIT. Those are the **monotonic** patterns, which is what `hypher` maps `el` to, so
|
||||
//! polytonic text hyphenates by them as well.
|
||||
//! polytonic text hyphenates by them as well. French is `hyph-fr.tex` 2.13 from hyph-utf8 (Daniel
|
||||
//! Flipo, Bernard Gaulle, Arthur Reutenauer), MIT; Italian is `hyph-it.tex` 4.9 from hyph-utf8
|
||||
//! (Claudio Beccari), LPPL or MIT, taken under MIT. A word with an elision (`l'imprimerie`)
|
||||
//! hyphenates after its apostrophe.
|
||||
//! German uses `hyph-de-1996.tex` (2024-02-28, Deutschsprachige Trennmustermannschaft, MIT):
|
||||
//! despite the filename, these are the reformed 2006 spelling patterns. Only the two 2006 reform
|
||||
//! names and German locale codes without a reform year or with 2006 use this set. Traditional,
|
||||
@@ -137,8 +140,14 @@ fn core_breaks(core: &[char], lang: Lang) -> (Vec<usize>, Source) {
|
||||
return (vec![], Source::None);
|
||||
}
|
||||
if let Lang::Patterns(l) = lang {
|
||||
if core.contains(&'\'') {
|
||||
return (vec![], Source::None);
|
||||
if let Some(ap) = core.iter().rposition(|&c| c == '\'') {
|
||||
// An elision (French "l'", Italian "dell'"): the word after the apostrophe hyphenates,
|
||||
// with no break up to it. Elsewhere (German "Rock'n'Roll") an apostrophe word gets none.
|
||||
if !matches!(l, hypher::Lang::French | hypher::Lang::Italian) {
|
||||
return (vec![], Source::None);
|
||||
}
|
||||
let (pts, src) = if ap + 1 < n { core_breaks(&core[ap + 1..], lang) } else { (vec![], Source::None) };
|
||||
return (pts.into_iter().map(|p| p + ap + 1).collect(), src);
|
||||
}
|
||||
let s: String = core.iter().collect();
|
||||
let mut out = Vec::new();
|
||||
@@ -229,6 +238,7 @@ pub fn hyphen_points_in(word: &str, lim: &Limits, lang: Lang) -> Vec<usize> {
|
||||
}
|
||||
let min_before = lim.after_first.max(1);
|
||||
let min_after = lim.before_last.max(1);
|
||||
let elides = matches!(lang, Lang::Patterns(hypher::Lang::French | hypher::Lang::Italian));
|
||||
// Run bounds of each point's letter run (trimmed of apostrophes).
|
||||
let run_of = |p: usize| {
|
||||
let mut a = p;
|
||||
@@ -245,6 +255,10 @@ pub fn hyphen_points_in(word: &str, lim: &Limits, lang: Lang) -> Vec<usize> {
|
||||
while b > a && is_apostrophe(chars[b - 1]) {
|
||||
b -= 1;
|
||||
}
|
||||
// After an elision the word is the part after its last apostrophe: the limits count that.
|
||||
if elides && let Some(ap) = chars[a..b].iter().rposition(|&c| is_apostrophe(c)) {
|
||||
a += ap + 1;
|
||||
}
|
||||
(a, b)
|
||||
};
|
||||
pts.into_iter()
|
||||
@@ -397,6 +411,15 @@ mod tests {
|
||||
assert!(hyphen_points("typography", &lim).is_empty());
|
||||
}
|
||||
|
||||
/// Only French and Italian elide at an apostrophe; other languages leave such words unbroken.
|
||||
#[test]
|
||||
fn apostrophe_words_hyphenate_only_in_french_and_italian() {
|
||||
let de = Lang::for_language("German: 2006 Reform").unwrap();
|
||||
assert!(hyphen_points_in("Rock'n'Roll", &Limits::default(), de).is_empty());
|
||||
let fr = Lang::for_language("French").unwrap();
|
||||
assert!(!hyphen_points_in("l'information", &Limits::default(), fr).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn german_2006_uses_reformed_patterns() {
|
||||
let de = Lang::for_language("German: 2006 Reform").unwrap();
|
||||
@@ -478,7 +501,7 @@ mod tests {
|
||||
assert_eq!(h("desesperación"), "de-ses-pe-ra-ción");
|
||||
assert_eq!(h("teatro"), "tea-tro", "a vowel run stays whole");
|
||||
assert_eq!(h("calle"), "ca-lle", "ll stays whole");
|
||||
assert!(Lang::for_language("French").is_none(), "only languages built in");
|
||||
assert!(Lang::for_language("Swedish").is_none(), "only languages built in");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -520,6 +543,51 @@ mod tests {
|
||||
assert_eq!(h("beveger"), "be-ve-ger");
|
||||
}
|
||||
|
||||
/// The paragraph limits count the letters of the word after an elision, not the elided prefix.
|
||||
#[test]
|
||||
fn elision_limits_apply_to_the_word_after_it() {
|
||||
let fr = Lang::for_language("French").unwrap();
|
||||
let lim = Limits { min_word: 5, after_first: 3, before_last: 2, capitalized: true };
|
||||
assert_eq!(hyphen_points_in("l'imprimerie", &lim, fr), vec![7, 9]);
|
||||
let lim = Limits { min_word: 11, ..Limits::default() };
|
||||
assert!(hyphen_points_in("l'imprimerie", &lim, fr).is_empty());
|
||||
}
|
||||
|
||||
/// A word with an elision ("l'", "dell'") hyphenates as the word after it.
|
||||
fn marked(w: &str, l: Lang) -> String {
|
||||
let pts = hyphen_points_in(w, &Limits::default(), l);
|
||||
w.chars().enumerate().map(|(i, c)| if pts.contains(&i) { format!("-{c}") } else { c.to_string() }).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn french_uses_the_hyph_fr_patterns() {
|
||||
let fr = Lang::for_language("French").unwrap();
|
||||
for name in ["fr", "fr_FR", "French: Canadian", "French: Swiss"] {
|
||||
assert_eq!(Lang::for_language(name), Some(fr), "{name}");
|
||||
}
|
||||
assert_eq!(marked("hyphénation", fr), "hy-phé-na-tion");
|
||||
assert_eq!(marked("typographie", fr), "ty-po-gra-phie");
|
||||
assert_eq!(marked("Développement", fr), "Dé-ve-lop-pe-ment");
|
||||
assert_eq!(marked("anticonstitutionnellement", fr), "an-ti-cons-ti-tu-tion-nel-le-ment");
|
||||
assert_eq!(marked("paragraphe", fr), "pa-ra-graphe", "no break before a final mute syllable of two letters");
|
||||
assert_eq!(marked("l'imprimerie", fr), "l'im-pri-me-rie", "after an elision");
|
||||
assert_eq!(marked("l’imprimerie", fr), "l’im-pri-me-rie", "a typographer's apostrophe too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn italian_uses_the_hyph_it_patterns() {
|
||||
let it = Lang::for_language("Italian").unwrap();
|
||||
for name in ["it", "it_IT"] {
|
||||
assert_eq!(Lang::for_language(name), Some(it), "{name}");
|
||||
}
|
||||
assert_ne!(it, Lang::for_language("French").unwrap());
|
||||
assert_eq!(marked("sillabazione", it), "sil-la-ba-zio-ne");
|
||||
assert_eq!(marked("tipografia", it), "ti-po-gra-fia");
|
||||
assert_eq!(marked("Repubblica", it), "Re-pub-bli-ca");
|
||||
assert_eq!(marked("impaginazione", it), "im-pa-gi-na-zio-ne");
|
||||
assert_eq!(marked("dell'impaginazione", it), "dell'im-pa-gi-na-zio-ne", "after an elision");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heuristic_fallback() {
|
||||
let c: Vec<char> = "happen".chars().collect();
|
||||
|
||||
@@ -268,6 +268,8 @@ pub struct Deco {
|
||||
pub path: Option<designcraft_geom::BezPath>,
|
||||
/// Shown on screen only (not printed or exported).
|
||||
pub nonprinting: bool,
|
||||
/// Set to overprint (paragraph shading or border); PDF export doesn't overprint yet.
|
||||
pub overprint: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -2569,7 +2571,12 @@ fn finish_paragraph(input: ParagraphFinishInput<'_>) -> ParagraphFinish {
|
||||
.map(|l| if r.column_width { (l.x0, l.x1) } else { (l.x0, l.end_x) })
|
||||
.unwrap_or((0.0, 0.0));
|
||||
let (color, tint) = rule_color(r, above);
|
||||
ft.decos.push(Deco { rect: Rect::new(col.0 + r.left_indent, y - r.weight, col.1 - r.right_indent, y), color, tint, ..Deco::default() });
|
||||
// Keep In Frame: not above the frame, as InDesign's (its top, not the text area's
|
||||
// below the top inset).
|
||||
let top = frames.get(fi).map_or(f64::NEG_INFINITY, |f| f.area.y0 - if f.vertical { 0.0 } else { f.opts.inset[0] });
|
||||
let y = if r.keep_in_frame && y - r.weight < top { top + r.weight } else { y };
|
||||
let rect = Rect::new(col.0 + r.left_indent, y - r.weight, col.1 - r.right_indent, y);
|
||||
ft.decos.extend(rule_decos(doc, r, rect, (color, tint), above));
|
||||
}
|
||||
}
|
||||
if pp.rule_below.on
|
||||
@@ -2580,7 +2587,7 @@ fn finish_paragraph(input: ParagraphFinishInput<'_>) -> ParagraphFinish {
|
||||
let y = l.baseline + r.offset;
|
||||
let (x0, x1) = if r.column_width { (l.x0, l.x1) } else { (l.x0, l.end_x) };
|
||||
let (color, tint) = rule_color(r, below);
|
||||
ft.decos.push(Deco { rect: Rect::new(x0 + r.left_indent, y, x1 - r.right_indent, y + r.weight), color, tint, ..Deco::default() });
|
||||
ft.decos.extend(rule_decos(doc, r, Rect::new(x0 + r.left_indent, y, x1 - r.right_indent, y + r.weight), (color, tint), below));
|
||||
}
|
||||
// The end of a split block: text after it continues below its deepest sub-column, which
|
||||
// are balanced first. A block that ends the story fills its sub-columns in turn.
|
||||
@@ -5326,6 +5333,55 @@ fn hyphenation_points(
|
||||
out
|
||||
}
|
||||
|
||||
/// A paragraph rule drawn in `rect` (its length and weight) with its Type: the gap colour under
|
||||
/// it, then the line, as dashes, dots, stripes, waves or hash marks. A pattern that can't be laid
|
||||
/// out along the rule draws solid.
|
||||
/// `(color, tint)` is the rule's (Text Color already resolved); `text`, the colour of the text
|
||||
/// the rule goes with, for a gap in Text Color.
|
||||
fn rule_decos(doc: &Document, r: &designcraft_doc::Rule, rect: Rect, (color, tint): (String, f32), text: &(String, f32)) -> Vec<Deco> {
|
||||
use designcraft_doc::StrokeType;
|
||||
let kind = doc.stroke_kind(&r.kind);
|
||||
let (w, len) = (rect.height(), rect.width());
|
||||
let mut out = Vec::new();
|
||||
if !matches!(kind, StrokeType::Solid) && r.gap_color != designcraft_color::swatch::NONE && w > 0.0 && len > 0.0 {
|
||||
let gap = if r.gap_color == designcraft_doc::TEXT_COLOR { text.0.clone() } else { r.gap_color.clone() };
|
||||
out.push(Deco { rect, color: gap, tint: r.gap_tint, ..Deco::default() });
|
||||
}
|
||||
let y = rect.center().y;
|
||||
let mut line = designcraft_geom::BezPath::new();
|
||||
line.move_to((rect.x0, y));
|
||||
line.line_to((rect.x1, y));
|
||||
let path = match &kind {
|
||||
StrokeType::Dashed { pattern } if StrokeType::expandable_dashes(pattern, len) => {
|
||||
let mut p = designcraft_geom::BezPath::new();
|
||||
let (mut x, mut k, mut on) = (rect.x0, 0, true);
|
||||
while x < rect.x1 {
|
||||
let step = pattern[k % pattern.len()];
|
||||
if on && step > 0.0 {
|
||||
let r = Rect::new(x, rect.y0, (x + step).min(rect.x1), rect.y1);
|
||||
p.extend(designcraft_geom::Shape::path_elements(&r, 0.1));
|
||||
}
|
||||
x += step;
|
||||
k += 1;
|
||||
on = !on;
|
||||
}
|
||||
Some(p)
|
||||
}
|
||||
StrokeType::Dotted if StrokeType::expandable_dashes(&[w, w], len) => {
|
||||
let mut p = designcraft_geom::BezPath::new();
|
||||
let mut x = rect.x0 + w / 2.0;
|
||||
while x <= rect.x1 - w / 2.0 + 1e-9 {
|
||||
p.extend(designcraft_geom::Shape::path_elements(&designcraft_geom::kurbo::Circle::new((x, y), w / 2.0), 0.1));
|
||||
x += 2.0 * w;
|
||||
}
|
||||
Some(p)
|
||||
}
|
||||
k => k.outline(&line, w, 0.05),
|
||||
};
|
||||
out.push(Deco { rect, color, tint, path, ..Deco::default() });
|
||||
out
|
||||
}
|
||||
|
||||
/// `spanned`: a paragraph spans the frame's columns, so the lines move together.
|
||||
fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool, notes: &designcraft_doc::FootnoteOptions) {
|
||||
if ft.lines.is_empty() || f.opts.vertical_justification == VerticalJustification::Top {
|
||||
@@ -5352,6 +5408,7 @@ fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool, notes: &de
|
||||
continue;
|
||||
}
|
||||
let _ = a;
|
||||
let before: Vec<f64> = idx.iter().map(|&i| ft.lines[i].baseline).collect();
|
||||
match f.opts.vertical_justification {
|
||||
VerticalJustification::Center => idx.iter().for_each(|&i| ft.lines[i].baseline += space / 2.0),
|
||||
VerticalJustification::Bottom => idx.iter().for_each(|&i| ft.lines[i].baseline += space),
|
||||
@@ -5370,14 +5427,39 @@ fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool, notes: &de
|
||||
.collect();
|
||||
let n = rows.last().copied().unwrap_or(0);
|
||||
if n > 0 {
|
||||
let per = space / n as f64;
|
||||
// Paragraph Spacing Limit: the space goes between paragraphs, up to the limit
|
||||
// each; what is left spreads over every line (all of it with no limit).
|
||||
let mut starts = vec![false; n + 1];
|
||||
for (&i, &k) in idx.iter().zip(&rows) {
|
||||
ft.lines[i].baseline += per * k as f64;
|
||||
if k > 0 && ft.lines[i].first_in_para {
|
||||
starts[k] = true;
|
||||
}
|
||||
}
|
||||
let gaps = starts.iter().filter(|s| **s).count();
|
||||
let limit = f.opts.vj_paragraph_spacing_limit.max(0.0);
|
||||
let para = if gaps > 0 && limit > 0.0 { (space / gaps as f64).min(limit) } else { 0.0 };
|
||||
let per = (space - para * gaps as f64) / n as f64;
|
||||
let mut shift = vec![0.0; n + 1];
|
||||
for k in 1..=n {
|
||||
shift[k] = shift[k - 1] + per + if starts[k] { para } else { 0.0 };
|
||||
}
|
||||
for (&i, &k) in idx.iter().zip(&rows) {
|
||||
ft.lines[i].baseline += shift[k];
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// A drop cap moves with its line.
|
||||
for (&i, b) in idx.iter().zip(before) {
|
||||
let line = &mut ft.lines[i];
|
||||
let dy = line.baseline - b;
|
||||
if let Some(dc) = &mut line.drop_cap {
|
||||
dc.baseline += dy;
|
||||
dc.rect.y0 += dy;
|
||||
dc.rect.y1 += dy;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ struct Shading {
|
||||
bottom: BoxBottom,
|
||||
clip: bool,
|
||||
nonprinting: bool,
|
||||
overprint: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -51,6 +52,8 @@ struct Border {
|
||||
display_if_splits: bool,
|
||||
gap_color: String,
|
||||
gap_tint: f32,
|
||||
overprint: bool,
|
||||
gap_overprint: bool,
|
||||
}
|
||||
|
||||
fn finite(v: f64) -> f64 {
|
||||
@@ -72,6 +75,7 @@ impl ParaBox {
|
||||
bottom: pp.shading_bottom,
|
||||
clip: pp.shading_clip,
|
||||
nonprinting: pp.shading_nonprinting,
|
||||
overprint: pp.shading_overprint,
|
||||
});
|
||||
let border = pp.border_on.then(|| Border {
|
||||
color: pp.border_color.clone(),
|
||||
@@ -88,6 +92,8 @@ impl ParaBox {
|
||||
display_if_splits: pp.border_display_if_splits,
|
||||
gap_color: pp.border_gap_color.clone(),
|
||||
gap_tint: pp.border_gap_tint,
|
||||
overprint: pp.border_overprint,
|
||||
gap_overprint: pp.border_gap_overprint,
|
||||
});
|
||||
Some(ParaBox { shading, border, merge: pp.border_merge })
|
||||
}
|
||||
@@ -136,7 +142,7 @@ fn shading_deco(s: &Shading, part: &Part<'_>, frames: &[FrameSpec], first_part:
|
||||
// Corners stay square where the box is cut by a split.
|
||||
let corners = cut_corners(s.corners, !first_part, !last_part);
|
||||
let path = (!corners.iter().all(Corner::is_none)).then(|| box_outline(rect, &corners, [true; 4]));
|
||||
return Some(Deco { rect, color: s.color.clone(), tint: s.tint, path, nonprinting: s.nonprinting });
|
||||
return Some(Deco { rect, color: s.color.clone(), tint: s.tint, path, nonprinting: s.nonprinting, overprint: s.overprint });
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -149,15 +155,14 @@ fn border_decos(bd: &Border, part: &Part<'_>, first_part: bool, last_part: bool,
|
||||
let open_top = !first_part && !bd.display_if_splits;
|
||||
let open_bottom = !last_part && !bd.display_if_splits;
|
||||
for (path, gap) in border_paths(bd, o, open_top, open_bottom) {
|
||||
let (color, tint) = if gap { (bd.gap_color.clone(), bd.gap_tint) } else { (bd.color.clone(), bd.tint) };
|
||||
decos.push((part.fi, false, Deco { rect: path.bounding_box(), color, tint, path: Some(path), nonprinting: false }));
|
||||
let (color, tint, overprint) =
|
||||
if gap { (bd.gap_color.clone(), bd.gap_tint, bd.gap_overprint) } else { (bd.color.clone(), bd.tint, bd.overprint) };
|
||||
decos.push((part.fi, false, Deco { rect: path.bounding_box(), color, tint, path: Some(path), nonprinting: false, overprint }));
|
||||
}
|
||||
if is_plain(bd, open_top, open_bottom) {
|
||||
decos.extend(
|
||||
plain_edges(bd, o, open_top, open_bottom)
|
||||
.into_iter()
|
||||
.map(|rect| (part.fi, false, Deco { rect, color: bd.color.clone(), tint: bd.tint, path: None, nonprinting: false })),
|
||||
);
|
||||
decos.extend(plain_edges(bd, o, open_top, open_bottom).into_iter().map(|rect| {
|
||||
(part.fi, false, Deco { rect, color: bd.color.clone(), tint: bd.tint, path: None, nonprinting: false, overprint: bd.overprint })
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+168
-5
@@ -3082,11 +3082,18 @@ fn only_english_text_gets_english_hyphenation() {
|
||||
let hyphenated = |d: &Document, sid| all_lines(&compose_story(d, sid, &ComposeOptions::default())).iter().filter(|l| l.hyphenated).count();
|
||||
let (d, sid, _) = doc_with(text, narrow, ParaAttrs::default());
|
||||
assert!(hyphenated(&d, sid) > 0, "English hyphenates");
|
||||
let mut d2 = d.clone();
|
||||
let st = d2.story_mut(sid).unwrap();
|
||||
let n = st.len();
|
||||
st.format_chars(0..n, |f| f.over.language = Some("French".into()));
|
||||
assert_eq!(hyphenated(&d2, sid), 0, "French isn't hyphenated with English rules");
|
||||
// A language without bundled patterns isn't hyphenated with English rules.
|
||||
let in_language = |lang: &str| {
|
||||
let mut d2 = d.clone();
|
||||
let st = d2.story_mut(sid).unwrap();
|
||||
let n = st.len();
|
||||
st.format_chars(0..n, |f| f.over.language = Some(lang.into()));
|
||||
hyphenated(&d2, sid)
|
||||
};
|
||||
assert_eq!(in_language("Swedish"), 0, "Swedish isn't hyphenated with English rules");
|
||||
// French and Italian hyphenate with their own patterns.
|
||||
assert!(in_language("French") > 0, "French hyphenates (hyph-fr)");
|
||||
assert!(in_language("Italian") > 0, "Italian hyphenates (hyph-it)");
|
||||
}
|
||||
|
||||
// A text frame's bottom inset constrains its last baseline. The full font descent
|
||||
@@ -3738,6 +3745,21 @@ fn text_x(l: &Line, from: usize) -> f64 {
|
||||
l.glyphs.iter().find(|g| g.len > 0 && g.byte >= from).map(|g| g.x).unwrap()
|
||||
}
|
||||
|
||||
/// Vertical justification moves the drop cap's box with its line.
|
||||
#[test]
|
||||
fn drop_cap_box_follows_vertical_justification() {
|
||||
let (mut d, sid, fid) = drop_doc_in(&[LOREM; 2].join(" "), Rect::new(0.0, 0.0, 300.0, 400.0), drop_cap(3, 1));
|
||||
let top = compose_story(&d, sid, &ComposeOptions::default());
|
||||
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.vertical_justification = designcraft_doc::VerticalJustification::Bottom;
|
||||
let bottom = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let (a, b) = (&top.frame(fid).unwrap().lines[0], &bottom.frame(fid).unwrap().lines[0]);
|
||||
let dy = b.baseline - a.baseline;
|
||||
assert!(dy > 1.0);
|
||||
let (da, db) = (a.drop_cap.unwrap(), b.drop_cap.unwrap());
|
||||
assert!((db.baseline - da.baseline - dy).abs() < 1e-6);
|
||||
assert!((db.rect.y0 - da.rect.y0 - dy).abs() < 1e-6 && (db.rect.y1 - da.rect.y1 - dy).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_cap_spans_lines_and_indents_them() {
|
||||
let text = [LOREM; 2].join(" ");
|
||||
@@ -4135,6 +4157,56 @@ fn drop_cap_scales_with_its_point_size() {
|
||||
assert!(b2 - fitted * 1.5 < b0 - base[0].ascent, "a 15 pt cap rises above the first line");
|
||||
}
|
||||
|
||||
/// Paragraph Rules › Type: a wavy, dashed or dotted rule is drawn as its shape (filled paths in
|
||||
/// the rule's box), a striped one as its bands; Gap Color goes under it. Keep In Frame keeps a rule
|
||||
/// above the first line inside the frame.
|
||||
#[test]
|
||||
fn paragraph_rules_have_a_type_a_gap_colour_and_keep_in_frame() {
|
||||
use designcraft_doc::{Rule, StrokeType};
|
||||
let rule = |kind: StrokeType| Rule { on: true, weight: 4.0, kind, ..Default::default() };
|
||||
let decos = |r: Rule| {
|
||||
let para = ParaAttrs { rule_below: Some(r), ..Default::default() };
|
||||
let (d, sid, fid) = doc_with("Recipe Categories", Rect::new(0.0, 0.0, 300.0, 200.0), para);
|
||||
compose_story(&d, sid, &ComposeOptions::default()).frame(fid).unwrap().decos.clone()
|
||||
};
|
||||
let solid = decos(rule(StrokeType::Solid));
|
||||
assert!(solid.len() == 1 && solid[0].path.is_none());
|
||||
let wavy = decos(rule(StrokeType::Wavy));
|
||||
let p = wavy[0].path.as_ref().expect("a wavy rule is a shape");
|
||||
assert!(designcraft_geom::Shape::area(p).abs() > 0.0 && (wavy[0].rect.height() - 4.0).abs() < 1e-9);
|
||||
let subpaths = |p: &designcraft_geom::BezPath| p.elements().iter().filter(|e| matches!(e, designcraft_geom::PathEl::MoveTo(_))).count();
|
||||
let dashed = decos(rule(StrokeType::Dashed { pattern: vec![12.0, 4.0] }));
|
||||
assert!(subpaths(dashed[0].path.as_ref().unwrap()) > 5, "one box per dash");
|
||||
let dotted = decos(rule(StrokeType::Dotted));
|
||||
assert!(subpaths(dotted[0].path.as_ref().unwrap()) > 10, "one dot per 2 weights");
|
||||
let gap = decos(Rule { gap_color: "[Black]".into(), gap_tint: 0.3, ..rule(StrokeType::Dotted) });
|
||||
assert_eq!((gap.len(), gap[0].color.as_str(), gap[0].tint, gap[0].path.is_none()), (2, "[Black]", 0.3, true), "the gap colour under the dots");
|
||||
assert_eq!(decos(Rule { gap_color: "[Black]".into(), ..rule(StrokeType::Solid) }).len(), 1, "a solid rule has no gaps");
|
||||
// A gap in Text Color takes the text's colour, whatever swatch the rule itself has.
|
||||
let gap = decos(Rule { color: "[Paper]".into(), gap_color: designcraft_doc::TEXT_COLOR.into(), ..rule(StrokeType::Dotted) });
|
||||
assert_eq!((gap[0].color.as_str(), gap[1].color.as_str()), ("[Black]", "[Paper]"));
|
||||
// Keep In Frame: a rule above raised out of the frame comes back to its top.
|
||||
let above = |keep: bool| {
|
||||
let r = Rule { on: true, weight: 2.0, offset: 30.0, keep_in_frame: keep, ..Default::default() };
|
||||
let para = ParaAttrs { rule_above: Some(r), ..Default::default() };
|
||||
let (d, sid, fid) = doc_with("Title", Rect::new(0.0, 50.0, 300.0, 200.0), para);
|
||||
compose_story(&d, sid, &ComposeOptions::default()).frame(fid).unwrap().decos[0].rect
|
||||
};
|
||||
assert!(above(false).y0 < 50.0, "{:?}", above(false));
|
||||
assert!((above(true).y0 - 50.0).abs() < 1e-6, "{:?}", above(true));
|
||||
// With a top inset the rule may still rise into it: raised out of the frame, it comes back
|
||||
// to the frame's edge, not to the text area's below the inset.
|
||||
let r = Rule { on: true, weight: 2.0, offset: 60.0, keep_in_frame: true, ..Default::default() };
|
||||
let para = ParaAttrs { rule_above: Some(r), ..Default::default() };
|
||||
let (mut d, sid, fid) = doc_with("Title", Rect::new(0.0, 50.0, 300.0, 200.0), para);
|
||||
if let Some(tf) = d.item_mut(fid).and_then(|it| it.text_frame_mut()) {
|
||||
tf.options.inset = [20.0, 0.0, 0.0, 0.0];
|
||||
}
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let rule = cs.frame(fid).unwrap().decos[0].rect;
|
||||
assert!((rule.y0 - 50.0).abs() < 1e-6, "kept at the frame's top, in the inset: {rule:?}");
|
||||
}
|
||||
|
||||
/// A rule in Text Color takes the colour of the paragraph's text: the first character's for the
|
||||
/// rule above, the last character's for the rule below. A swatch colour is used as is.
|
||||
#[test]
|
||||
@@ -7431,6 +7503,60 @@ fn anchored_object_wraps_its_own_storys_later_lines() {
|
||||
assert!((o.origin.y - top).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// Vertical Justification › Justify with a Paragraph Spacing Limit: the space goes between the
|
||||
/// paragraphs (up to the limit each), the lines of a paragraph keep their leading; past the limit
|
||||
/// the rest spreads over every line. Without a limit it spreads over every line.
|
||||
#[test]
|
||||
fn justify_paragraph_spacing_limit() {
|
||||
let text = "One line.
|
||||
Two
|
||||
Three";
|
||||
let baselines = |limit: f64| {
|
||||
let (mut d, sid, fid) = doc_with(text, Rect::new(0.0, 0.0, 300.0, 400.0), ParaAttrs::default());
|
||||
let o = &mut d.item_mut(fid).unwrap().text_frame_mut().unwrap().options;
|
||||
o.vertical_justification = designcraft_doc::VerticalJustification::Justify;
|
||||
o.vj_paragraph_spacing_limit = limit;
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
all_lines(&cs).iter().map(|l| l.baseline).collect::<Vec<_>>()
|
||||
};
|
||||
let (none, big, small) = (baselines(0.0), baselines(1000.0), baselines(20.0));
|
||||
assert_eq!(none.len(), 3);
|
||||
let gaps = |b: &[f64]| (b[1] - b[0], b[2] - b[1]);
|
||||
// No limit: equal steps. A big limit: all of it between paragraphs (here every line is one).
|
||||
assert!((gaps(&none).0 - gaps(&none).1).abs() < 1e-6, "{none:?}");
|
||||
assert!((gaps(&big).0 - gaps(&big).1).abs() < 1e-6, "{big:?}");
|
||||
// A small limit: every paragraph gap takes the whole 20 pt, and the rest spreads over lines,
|
||||
// so each gap is wider than the top-aligned one by at least 20 pt and narrower than with a big limit.
|
||||
let top = {
|
||||
let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 300.0, 400.0), ParaAttrs::default());
|
||||
all_lines(&compose_story(&d, sid, &ComposeOptions::default())).iter().map(|l| l.baseline).collect::<Vec<_>>()
|
||||
};
|
||||
let (natural, small_gaps, big_gaps) = (gaps(&top), gaps(&small), gaps(&big));
|
||||
assert!(small_gaps.0 >= natural.0 + 20.0 - 1e-6 && small_gaps.1 >= natural.1 + 20.0 - 1e-6, "{top:?} {small:?}");
|
||||
assert!(small_gaps.0 <= big_gaps.0 + 1e-6, "{small:?} {big:?}");
|
||||
assert!((none[2] - big[2]).abs() < 1e-6 && (none[2] - small[2]).abs() < 1e-6, "the last line stays at the bottom");
|
||||
// A paragraph of two lines keeps its leading when the limit absorbs the space.
|
||||
let (mut d, sid, fid) = doc_with(
|
||||
&[LOREM, "End."].join(
|
||||
"
|
||||
",
|
||||
),
|
||||
Rect::new(0.0, 0.0, 300.0, 600.0),
|
||||
ParaAttrs::default(),
|
||||
);
|
||||
let o = &mut d.item_mut(fid).unwrap().text_frame_mut().unwrap().options;
|
||||
o.vertical_justification = designcraft_doc::VerticalJustification::Justify;
|
||||
o.vj_paragraph_spacing_limit = 10_000.0;
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let lines = all_lines(&cs);
|
||||
let first: Vec<f64> = lines.iter().filter(|l| l.para == 0).map(|l| l.baseline).collect();
|
||||
assert!(first.len() > 2, "LOREM runs over several lines");
|
||||
let lead = first[1] - first[0];
|
||||
assert!(first.windows(2).all(|w| (w[1] - w[0] - lead).abs() < 1e-6), "the paragraph's lines keep their leading: {first:?}");
|
||||
let last = lines.iter().find(|l| l.para == 1).unwrap().baseline;
|
||||
assert!(last - first[first.len() - 1] > lead + 50.0, "the space went between the paragraphs");
|
||||
}
|
||||
|
||||
/// Each object's wrap pushes the anchors after it down, so where each object lands depends on the
|
||||
/// wraps of all those before it; every object still ends up wrapping the lines it hangs over.
|
||||
#[test]
|
||||
@@ -8065,6 +8191,43 @@ fn greek_text_gets_greek_hyphenation() {
|
||||
assert_eq!(hyphen::hyphen_points_in("ανθρωπος", &hyphen::Limits::default(), el), vec![2, 5]);
|
||||
}
|
||||
|
||||
/// An object's wrap pushes the text so that a later object's anchor goes on into the next frame
|
||||
/// (Learn Skills Daily's long-document exercise: pictures anchored in headings through a 79-page
|
||||
/// story). Each object is placed when its anchor line is set and wraps only the lines after it,
|
||||
/// in the frame its anchor lands in: the layout doesn't depend on where an earlier composition put
|
||||
/// the objects, and composing again gives the same lines.
|
||||
#[test]
|
||||
fn an_object_wraps_the_frame_its_anchor_lands_in_after_an_earlier_object_pushed_it_on() {
|
||||
let text = [LOREM; 6].join(" ");
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let lid = d.default_layer();
|
||||
let (a, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 300.0, 300.0), lid, &text, ParaFormat::default()).unwrap();
|
||||
let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(320.0, 0.0, 620.0, 300.0), lid, "", ParaFormat::default()).unwrap();
|
||||
d.thread(a, b).unwrap();
|
||||
let plain = compose_story(&d, sid, &ComposeOptions::default());
|
||||
// The second object's anchor sits near the end of the first frame without wrap.
|
||||
let late = plain.frames[0].lines.iter().rev().nth(2).unwrap().range.start + 2;
|
||||
anchor_box(&mut d, sid, late, 0.0, 0.0, true, WrapMode::BoundingBox);
|
||||
// The first, early in the first frame: 60 pt over its whole width pushes the later anchor on.
|
||||
let early = plain.frames[0].lines[1].range.start + 2;
|
||||
anchor_box(&mut d, sid, early, 0.0, 0.0, true, WrapMode::JumpObject);
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let late = late + OBJECT_LEN;
|
||||
let anchor_frame = cs.frames.iter().position(|f| f.range.contains(&late) || f.range.end == late).unwrap();
|
||||
assert_eq!(anchor_frame, 1, "the later anchor went on into the second frame");
|
||||
// The first frame's lines under the first object's band keep the full measure (the second
|
||||
// object is in the other frame); in the second frame, the lines under its object move right.
|
||||
let left = cs.frames[0].lines[0].x0;
|
||||
assert!(cs.frames[0].lines.iter().all(|l| (l.x0 - left).abs() < 1e-6));
|
||||
let f1 = &cs.frames[1];
|
||||
let (li, line) = f1.lines.iter().enumerate().find(|(_, l)| l.range.contains(&late)).unwrap();
|
||||
let under: Vec<_> = f1.lines[li + 1..].iter().filter(|l| l.baseline - l.ascent < line.baseline + 60.0).collect();
|
||||
assert!(!under.is_empty() && under.iter().all(|l| l.x0 >= f1.lines[0].x0 + 100.0 - 1e-6));
|
||||
assert_eq!(f1.objects.len(), 1);
|
||||
// Deterministic: the same lines again.
|
||||
assert_eq!(line_layout(&compose_story(&d, sid, &ComposeOptions::default())), line_layout(&cs));
|
||||
}
|
||||
|
||||
/// Rule Above's offset runs from the first line's baseline up to the rule's bottom edge, as in
|
||||
/// InDesign: a rule set clear of the cap height sits between the rows, not across the row above
|
||||
/// (an INDD project list whose rules crossed the previous paragraph's text).
|
||||
|
||||
@@ -86,7 +86,7 @@ attr_set! {
|
||||
kenten_font: String = String::new(),
|
||||
kenten_font_style: String = String::new(),
|
||||
/// Mark size in points; `None` is half the text size.
|
||||
kenten_size: Option<f64> = None,
|
||||
kenten_size: Option<f64> = None => "deserialize_explicit_value",
|
||||
/// Horizontal / vertical scale of the mark, 1.0 = 100%.
|
||||
kenten_x_scale: f64 = 1.0,
|
||||
kenten_y_scale: f64 = 1.0,
|
||||
@@ -99,10 +99,10 @@ attr_set! {
|
||||
/// Mark fill and stroke swatches (empty = the text's), tints and stroke weight (`None` =
|
||||
/// the text's).
|
||||
kenten_fill: String = String::new(),
|
||||
kenten_fill_tint: Option<f32> = None,
|
||||
kenten_fill_tint: Option<f32> = None => "deserialize_explicit_value",
|
||||
kenten_stroke: String = String::new(),
|
||||
kenten_stroke_tint: Option<f32> = None,
|
||||
kenten_stroke_weight: Option<f64> = None,
|
||||
kenten_stroke_tint: Option<f32> = None => "deserialize_explicit_value",
|
||||
kenten_stroke_weight: Option<f64> = None => "deserialize_explicit_value",
|
||||
kenten_overprint_fill: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto,
|
||||
kenten_overprint_stroke: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto,
|
||||
// Shatai (斜体): the character is compressed across the direction at `shatai_angle`.
|
||||
@@ -126,7 +126,7 @@ attr_set! {
|
||||
ruby_font: String = String::new(),
|
||||
ruby_font_style: String = String::new(),
|
||||
/// Points; `None` is half the parent size.
|
||||
ruby_font_size: Option<f64> = None,
|
||||
ruby_font_size: Option<f64> = None => "deserialize_explicit_value",
|
||||
/// 1.0 = 100%.
|
||||
ruby_x_scale: f64 = 1.0,
|
||||
ruby_y_scale: f64 = 1.0,
|
||||
@@ -147,10 +147,10 @@ attr_set! {
|
||||
ruby_scaling_min: f64 = 0.66,
|
||||
/// Ruby colour: empty is the text's colour; `None` tint and weight follow the text.
|
||||
ruby_fill: String = String::new(),
|
||||
ruby_fill_tint: Option<f32> = None,
|
||||
ruby_fill_tint: Option<f32> = None => "deserialize_explicit_value",
|
||||
ruby_stroke: String = String::new(),
|
||||
ruby_stroke_tint: Option<f32> = None,
|
||||
ruby_stroke_weight: Option<f64> = None,
|
||||
ruby_stroke_tint: Option<f32> = None => "deserialize_explicit_value",
|
||||
ruby_stroke_weight: Option<f64> = None => "deserialize_explicit_value",
|
||||
ruby_overprint_fill: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto,
|
||||
ruby_overprint_stroke: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto,
|
||||
/// Digits (World-Ready): how 0–9 are drawn.
|
||||
|
||||
@@ -141,6 +141,9 @@ attr_set! {
|
||||
shading_on: bool = false,
|
||||
shading_color: String = "[Black]".into(),
|
||||
shading_tint: f32 = 0.2,
|
||||
/// Overprint (InDesign's Paragraph Shading › Overprint): stored and kept through IDML;
|
||||
/// PDF export doesn't overprint yet.
|
||||
shading_overprint: bool = false,
|
||||
shading_offsets: [f64; 4] = [0.0; 4],
|
||||
/// Paragraph border: stroke weights (top, left, bottom, right), colour, and offsets past the
|
||||
/// text (top, left, bottom, right).
|
||||
@@ -148,6 +151,8 @@ attr_set! {
|
||||
border_weights: [f64; 4] = [1.0; 4],
|
||||
border_color: String = "[Black]".into(),
|
||||
border_tint: f32 = 1.0,
|
||||
/// Overprint the border's stroke (stored and kept through IDML, as `ParagraphBorderOverprint`).
|
||||
border_overprint: bool = false,
|
||||
border_offsets: [f64; 4] = [0.0; 4],
|
||||
/// The border's stroke type (a document stroke style by name, or a built-in type), cap and
|
||||
/// join.
|
||||
@@ -167,6 +172,8 @@ attr_set! {
|
||||
/// The colour between the dashes, dots or stripes of a border that isn't solid.
|
||||
border_gap_color: String = "[None]".into(),
|
||||
border_gap_tint: f32 = 1.0,
|
||||
/// Overprint the border's gap colour (stored and kept through IDML).
|
||||
border_gap_overprint: bool = false,
|
||||
/// Shading corners: top left, top right, bottom right, bottom left.
|
||||
shading_corners: [designcraft_geom::corners::Corner; 4] = [designcraft_geom::corners::Corner::default(); 4],
|
||||
shading_width: BoxWidth = BoxWidth::Column,
|
||||
@@ -188,7 +195,7 @@ attr_set! {
|
||||
bullet_align: ListAlign = ListAlign::Left,
|
||||
number_align: ListAlign = ListAlign::Left,
|
||||
/// A tab stop for the text after the bullet or number, from the column's left edge.
|
||||
list_tab: Option<f64> = None,
|
||||
list_tab: Option<f64> = None => "deserialize_explicit_value",
|
||||
/// Restart Numbers at This Level After: a paragraph at a higher level (0 = any; otherwise
|
||||
/// that level) starts this level's numbering again.
|
||||
restart_numbers: bool = true,
|
||||
@@ -222,8 +229,11 @@ impl ParaAttrs {
|
||||
let serde_json::Value::Object(patch) = value else { return self.set_json(key, value) };
|
||||
let mut merged = serde_json::to_value(rule).map_err(|e| format!("{key}: {e}"))?;
|
||||
let serde_json::Value::Object(fields) = &mut merged else { return Err(format!("{key}: not an object")) };
|
||||
// Every field, those at their defaults (left out of the JSON) included.
|
||||
const RULE_FIELDS: [&str; 12] =
|
||||
["on", "weight", "color", "tint", "columnWidth", "offset", "leftIndent", "rightIndent", "type", "gapColor", "gapTint", "keepInFrame"];
|
||||
for (k, v) in patch {
|
||||
if !fields.contains_key(k) {
|
||||
if !RULE_FIELDS.contains(&k.as_str()) {
|
||||
return Err(format!("{key}: unknown rule field `{k}`"));
|
||||
}
|
||||
fields.insert(k.clone(), v.clone());
|
||||
|
||||
@@ -17,11 +17,46 @@ pub struct Rule {
|
||||
pub offset: f64,
|
||||
pub left_indent: f64,
|
||||
pub right_indent: f64,
|
||||
/// Type: the rule's stroke type (solid, dashed, wavy…, or a stroke style of the document).
|
||||
#[serde(rename = "type", skip_serializing_if = "rule_solid")]
|
||||
pub kind: crate::item::StrokeType,
|
||||
/// Gap Color (a swatch; [None]: none) and Gap Tint: under the gaps of a type that has them.
|
||||
#[serde(skip_serializing_if = "rule_no_gap")]
|
||||
pub gap_color: String,
|
||||
#[serde(skip_serializing_if = "rule_full_tint")]
|
||||
pub gap_tint: f32,
|
||||
/// Keep In Frame: a rule above the first line of a frame stays inside the frame.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub keep_in_frame: bool,
|
||||
}
|
||||
|
||||
// A rule's settings at their defaults are left out of the document (and of the attributes).
|
||||
fn rule_solid(k: &crate::item::StrokeType) -> bool {
|
||||
*k == crate::item::StrokeType::Solid
|
||||
}
|
||||
fn rule_no_gap(c: &str) -> bool {
|
||||
c == designcraft_color::swatch::NONE
|
||||
}
|
||||
fn rule_full_tint(t: &f32) -> bool {
|
||||
*t == 1.0
|
||||
}
|
||||
|
||||
impl Default for Rule {
|
||||
fn default() -> Self {
|
||||
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 }
|
||||
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,
|
||||
kind: crate::item::StrokeType::Solid,
|
||||
gap_color: designcraft_color::swatch::NONE.into(),
|
||||
gap_tint: 1.0,
|
||||
keep_in_frame: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-3
@@ -661,11 +661,18 @@ impl Document {
|
||||
}
|
||||
|
||||
/// Parent page (spread index, page index) applied to `page`: the page on its side of its
|
||||
/// parent spread.
|
||||
/// parent spread, or in its place for single-sided pages ([`Spread::parent_page_at`]).
|
||||
pub fn parent_page_of(&self, page: &Page) -> Option<(usize, usize)> {
|
||||
let pi = self.parent_index(page.parent?)?;
|
||||
// A parent spread can come without pages from a file.
|
||||
Some((pi, self.parents.get(pi)?.parent_page_for_side(page.side)?))
|
||||
let parent = self.parents.get(pi)?;
|
||||
// Only a single-sided page's position matters: look for it only then.
|
||||
let index = if page.side == PageSide::Single && parent.pages.len() >= 2 {
|
||||
self.spreads.iter().find_map(|s| s.pages.iter().position(|p| p.id == page.id)).unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
Some((pi, parent.parent_page_at(page.side, index)?))
|
||||
}
|
||||
|
||||
/// The parent page applied to `page`.
|
||||
@@ -777,7 +784,7 @@ impl Document {
|
||||
c
|
||||
}
|
||||
};
|
||||
chain.into_iter().filter_map(|i| Some((i, self.parents.get(i)?.parent_page_for_side(pg.side)?))).collect()
|
||||
chain.into_iter().filter_map(|i| Some((i, self.parents.get(i)?.parent_page_at(pg.side, page)?))).collect()
|
||||
}
|
||||
|
||||
/// Whether basing parent `pi` on `base` would make a `based_on` cycle (or names no parent).
|
||||
|
||||
@@ -385,6 +385,23 @@ mod tests {
|
||||
use super::*;
|
||||
use designcraft_geom::shapes;
|
||||
|
||||
/// Shadows and glows saved before they had a blend mode read Normal, as they were drawn;
|
||||
/// new ones start as InDesign's: Multiply for shadows, Screen for the outer glow.
|
||||
#[test]
|
||||
fn effect_blend_modes_default_as_indesign_and_keep_old_documents() {
|
||||
let old: DropShadow =
|
||||
serde_json::from_str(r#"{"on": true, "color": "[Black]", "opacity": 0.75, "angle": 120, "distance": 7, "size": 5, "spread": 0}"#)
|
||||
.unwrap();
|
||||
assert_eq!(old.mode, BlendMode::Normal);
|
||||
let old: OuterGlow = serde_json::from_str(r#"{"on": true, "color": "[Black]", "opacity": 0.75, "size": 7, "spread": 0}"#).unwrap();
|
||||
assert_eq!(old.mode, BlendMode::Normal);
|
||||
assert_eq!(DropShadow::default().mode, BlendMode::Multiply);
|
||||
assert_eq!(InnerShadow::default().mode, BlendMode::Multiply);
|
||||
assert_eq!(OuterGlow::default().mode, BlendMode::Screen);
|
||||
let saved = serde_json::to_value(DropShadow { mode: BlendMode::Screen, ..Default::default() }).unwrap();
|
||||
assert_eq!(serde_json::from_value::<DropShadow>(saved).unwrap().mode, BlendMode::Screen, "round trip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphics_without_a_clipping_path_load_and_save_as_before() {
|
||||
let g = Graphic {
|
||||
|
||||
@@ -13,12 +13,22 @@ pub struct DropShadow {
|
||||
pub distance: f64,
|
||||
pub size: f64,
|
||||
pub spread: f64,
|
||||
/// How the shadow blends with what's behind it (InDesign: Multiply). A document saved before
|
||||
/// effects had modes reads Normal, as they were drawn then.
|
||||
#[serde(default = "normal_blend")]
|
||||
pub mode: BlendMode,
|
||||
}
|
||||
|
||||
/// The blend of an effect saved before effects had one.
|
||||
fn normal_blend() -> BlendMode {
|
||||
BlendMode::Normal
|
||||
}
|
||||
|
||||
impl Default for DropShadow {
|
||||
fn default() -> Self {
|
||||
DropShadow {
|
||||
on: false,
|
||||
mode: BlendMode::Multiply,
|
||||
color: designcraft_color::swatch::BLACK.into(),
|
||||
opacity: 0.75,
|
||||
angle: 120.0,
|
||||
@@ -45,12 +55,16 @@ pub struct InnerShadow {
|
||||
pub size: f64,
|
||||
/// Percentage (0–100) of `size` that hardens the edge instead of blurring it.
|
||||
pub choke: f64,
|
||||
/// How the shadow blends with the object (InDesign: Multiply; Normal when saved before).
|
||||
#[serde(default = "normal_blend")]
|
||||
pub mode: BlendMode,
|
||||
}
|
||||
|
||||
impl Default for InnerShadow {
|
||||
fn default() -> Self {
|
||||
InnerShadow {
|
||||
on: false,
|
||||
mode: BlendMode::Multiply,
|
||||
color: designcraft_color::swatch::BLACK.into(),
|
||||
opacity: 0.75,
|
||||
angle: 120.0,
|
||||
@@ -72,11 +86,15 @@ pub struct OuterGlow {
|
||||
pub size: f64,
|
||||
/// Percentage (0–100) of `size` that grows the silhouette instead of blurring it.
|
||||
pub spread: f64,
|
||||
/// How the glow blends with what's behind it (InDesign: Screen; Normal when saved before).
|
||||
#[serde(default = "normal_blend")]
|
||||
pub mode: BlendMode,
|
||||
}
|
||||
|
||||
impl Default for OuterGlow {
|
||||
fn default() -> Self {
|
||||
OuterGlow { on: false, color: designcraft_color::swatch::BLACK.into(), opacity: 0.75, size: 7.0, spread: 0.0 }
|
||||
// InDesign starts a glow screened in white.
|
||||
OuterGlow { on: false, color: designcraft_color::swatch::PAPER.into(), opacity: 0.75, size: 7.0, spread: 0.0, mode: BlendMode::Screen }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -831,6 +831,9 @@ pub struct Toc {
|
||||
/// Paragraph style of the title; `None` is the generated "TOC Title" style.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub title_style: Option<String>,
|
||||
/// Generate a nested PDF outline from the included paragraphs when the TOC is updated.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub create_bookmarks: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -841,6 +844,16 @@ pub struct Bookmark {
|
||||
pub page: usize,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub children: Vec<Bookmark>,
|
||||
/// Owned by the document's generated TOC, so updating it preserves manual bookmarks.
|
||||
///
|
||||
/// Stored only in native files. Bookmarks (and so this flag) are not written to or read
|
||||
/// from IDML yet, so after an IDML round trip the next TOC update builds the outline fresh.
|
||||
/// If IDML bookmark import is added, imported bookmarks arrive with this flag false and the
|
||||
/// next update would keep them as manual and add a second, duplicate outline. That import
|
||||
/// must set this flag on the bookmarks the TOC generated (for example by matching them to
|
||||
/// the TOC entries) when the TOC has `create_bookmarks` on.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub generated_by_toc: bool,
|
||||
}
|
||||
|
||||
impl Document {
|
||||
|
||||
@@ -452,6 +452,16 @@ impl Spread {
|
||||
PageSide::Single => Some(0),
|
||||
}
|
||||
}
|
||||
/// The parent page shown behind page `index` of a document spread, on `side`: by side for
|
||||
/// facing pages ([`Self::parent_page_for_side`]); by position for single-sided pages in a
|
||||
/// spread of several, as InDesign does, so the second page of a two-page spread shows the
|
||||
/// parent's second page (the last one, for spreads longer than the parent).
|
||||
pub fn parent_page_at(&self, side: PageSide, index: usize) -> Option<usize> {
|
||||
match side {
|
||||
PageSide::Single if self.pages.len() >= 2 => Some(index.min(self.pages.len() - 1)),
|
||||
_ => self.parent_page_for_side(side),
|
||||
}
|
||||
}
|
||||
/// Index of the page under spread x (nearest page for the pasteboard).
|
||||
pub fn page_at_x(&self, x: f64) -> Option<usize> {
|
||||
if self.pages.is_empty() {
|
||||
|
||||
@@ -39,6 +39,35 @@ pub const FORCED_LINE_BREAK: char = '\u{2028}';
|
||||
pub const ODD_PAGE_BREAK: char = '\u{E011}';
|
||||
pub const EVEN_PAGE_BREAK: char = '\u{E012}';
|
||||
|
||||
/// Encode a table-of-contents separator using InDesign's special-character notation.
|
||||
pub fn toc_separator_notation(text: &str) -> String {
|
||||
text.replace('^', "^^").replace('\t', "^t").replace(RIGHT_INDENT_TAB, "^y").replace('\u{2003}', "^m").replace('\u{2002}', "^>")
|
||||
}
|
||||
|
||||
/// Decode TOC notation, preserving unknown codes and an escaped literal caret.
|
||||
pub fn toc_separator_text(notation: &str) -> String {
|
||||
let mut text = String::new();
|
||||
let mut chars = notation.chars();
|
||||
while let Some(ch) = chars.next() {
|
||||
if ch != '^' {
|
||||
text.push(ch);
|
||||
continue;
|
||||
}
|
||||
match chars.next() {
|
||||
Some('t') => text.push('\t'),
|
||||
Some('y') => text.push(RIGHT_INDENT_TAB),
|
||||
Some('m') => text.push('\u{2003}'),
|
||||
Some('>') => text.push('\u{2002}'),
|
||||
Some('^') | None => text.push('^'),
|
||||
Some(other) => {
|
||||
text.push('^');
|
||||
text.push(other);
|
||||
}
|
||||
}
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// A column, frame or page break character. It ends its paragraph: the story stores it as the
|
||||
/// paragraph's last character, followed by the `\n` separator (also at the end of the story), and
|
||||
/// the next paragraph starts in the next column, frame or page. A break inside a paragraph (from
|
||||
@@ -286,6 +315,24 @@ impl Story {
|
||||
v
|
||||
}
|
||||
|
||||
/// The paragraphs `r` touches (at least the one at `r.start`), as (index, byte range without
|
||||
/// its return), found from `r` itself rather than by listing every paragraph of the story.
|
||||
pub fn paras_touching(&self, r: std::ops::Range<usize>) -> Vec<(usize, std::ops::Range<usize>)> {
|
||||
let start = floor_char_boundary(&self.text, r.start.min(self.text.len()));
|
||||
let mut pi = self.para_at(start);
|
||||
let mut a = self.text[..start].rfind('\n').map_or(0, |i| i + 1);
|
||||
let mut out = Vec::new();
|
||||
loop {
|
||||
let b = self.text[a..].find('\n').map_or(self.text.len(), |i| a + i);
|
||||
out.push((pi, a..b));
|
||||
// The next paragraph starts after this one's return: is it still inside `r`?
|
||||
if b >= self.text.len() || b + 1 >= r.end {
|
||||
return out;
|
||||
}
|
||||
(a, pi) = (b + 1, pi + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Index of the paragraph containing byte `pos`.
|
||||
pub fn para_at(&self, pos: usize) -> usize {
|
||||
self.text[..floor_char_boundary(&self.text, pos)].bytes().filter(|b| *b == b'\n').count()
|
||||
@@ -639,6 +686,27 @@ mod tests {
|
||||
Story::with_text(StoryId(1), t, ParaFormat::default())
|
||||
}
|
||||
|
||||
/// `paras_touching` gives the paragraphs a range reaches into (returns included), as listing
|
||||
/// every paragraph would, for every range of a story with empty and multi-byte paragraphs.
|
||||
#[test]
|
||||
fn paras_touching_matches_the_full_listing() {
|
||||
let s = st("ab
|
||||
|
||||
çé
|
||||
last");
|
||||
let all = s.para_ranges();
|
||||
for a in 0..=s.len() {
|
||||
for b in a..=s.len() {
|
||||
if !s.text.is_char_boundary(a) || !s.text.is_char_boundary(b) {
|
||||
continue;
|
||||
}
|
||||
let want: Vec<(usize, std::ops::Range<usize>)> =
|
||||
all.iter().cloned().enumerate().filter(|(i, p)| *i == s.para_at(a) || (a < b && p.start < b && a <= p.end)).collect();
|
||||
assert_eq!(s.paras_touching(a..b), want, "{a}..{b}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optical_margin_is_optional_in_saved_stories() {
|
||||
let plain = serde_json::to_value(st("Text")).unwrap();
|
||||
|
||||
@@ -156,6 +156,19 @@ mod tests {
|
||||
assert_eq!(right.selected_owners(CellRange::new(1, 0, 1, 1)), vec![(0, 1), (1, 0)], "owner order, not encounter order");
|
||||
}
|
||||
|
||||
/// A short last row's padding cells take the converted text's paragraph and character format,
|
||||
/// like the cells with text.
|
||||
#[test]
|
||||
fn from_strings_formats_the_padding_cells_too() {
|
||||
let data = vec![vec!["a".to_string(), "b".to_string()], vec!["c".to_string()]];
|
||||
let para = ParaFormat { style: "Body".into(), ..Default::default() };
|
||||
let fmt = CharFormat { style: "Strong".into(), ..Default::default() };
|
||||
let t = Table::from_strings(1, &data, 200.0, ¶, &fmt);
|
||||
let pad = &t.cell(1, 1).unwrap().text;
|
||||
assert_eq!((pad.paras[0].style.as_str(), pad.chars[0].format.style.as_str()), ("Body", "Strong"));
|
||||
assert_eq!(pad.text, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_table_gets_own_paragraph() {
|
||||
let mut s = story("before after");
|
||||
|
||||
@@ -55,12 +55,15 @@ impl Table {
|
||||
pub fn from_strings(id: u64, data: &[Vec<String>], width: f64, para: &ParaFormat, fmt: &CharFormat) -> Table {
|
||||
let cols = data.iter().map(Vec::len).max().unwrap_or(1).max(1);
|
||||
let mut t = Table::new(id, data.len().max(1), cols, 0, 0, width);
|
||||
for (r, row) in data.iter().enumerate() {
|
||||
for (c, s) in row.iter().enumerate() {
|
||||
// Every cell takes the text's format, the padding of a short row too.
|
||||
for r in 0..t.nrows() {
|
||||
for c in 0..t.ncols() {
|
||||
let Some(cell) = t.cell_mut(r, c) else { continue };
|
||||
cell.text = Story::with_text(StoryId(0), "", ParaFormat { table: None, ..para.clone() });
|
||||
cell.text.chars[0].format = fmt.clone();
|
||||
cell.text.insert(0, s);
|
||||
if let Some(s) = data.get(r).and_then(|row| row.get(c)) {
|
||||
cell.text.insert(0, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
t
|
||||
@@ -180,14 +183,19 @@ impl Table {
|
||||
|
||||
/// Cell texts, rows separated by `\n`, cells by `\t` (Convert Table to Text, search, agents).
|
||||
pub fn plain_text(&self) -> String {
|
||||
self.text_with("\t", "\n")
|
||||
}
|
||||
|
||||
/// The cells' text, columns separated by `col` and rows by `row` (Convert Table to Text).
|
||||
pub fn text_with(&self, col: &str, row: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for r in 0..self.nrows() {
|
||||
if r > 0 {
|
||||
out.push('\n');
|
||||
out.push_str(row);
|
||||
}
|
||||
for c in 0..self.ncols() {
|
||||
if c > 0 {
|
||||
out.push('\t');
|
||||
out.push_str(col);
|
||||
}
|
||||
if let Some(cell) = self.cell(r, c) {
|
||||
out.push_str(&cell.text.text.replace('\n', " "));
|
||||
|
||||
+28
-1
@@ -84,7 +84,7 @@ pub fn expand(definition: &str, v: &XrefValues) -> String {
|
||||
let mut rest = definition;
|
||||
while let Some(i) = rest.find('<') {
|
||||
out.push_str(&rest[..i]);
|
||||
let Some(j) = rest[i..].find('>') else {
|
||||
let Some(j) = tag_end(&rest[i..]) else {
|
||||
out.push_str(&rest[i..]);
|
||||
return out;
|
||||
};
|
||||
@@ -120,6 +120,20 @@ pub fn expand(definition: &str, v: &XrefValues) -> String {
|
||||
typographic_quotes(&out)
|
||||
}
|
||||
|
||||
/// Byte index of the `>` that ends the tag starting at `s`, skipping quoted attribute values.
|
||||
/// An unterminated quote falls back to the first `>`, so only that malformed tag is affected.
|
||||
fn tag_end(s: &str) -> Option<usize> {
|
||||
let mut in_quote = false;
|
||||
for (k, c) in s.char_indices() {
|
||||
match c {
|
||||
'"' => in_quote = !in_quote,
|
||||
'>' if !in_quote => return Some(k),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
s.find('>')
|
||||
}
|
||||
|
||||
/// Straight double quotes in format text become typographer's quotes.
|
||||
fn typographic_quotes(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
@@ -388,6 +402,19 @@ pub fn clean(s: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn gt_inside_quoted_attribute_does_not_end_tag() {
|
||||
let v = XrefValues { para: "Intro > the start".into(), ..Default::default() };
|
||||
assert_eq!(expand("<partialPara delim=\">\" includeDelim=\"false\" />|", &v), "Intro |");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unterminated_quote_only_affects_its_own_tag() {
|
||||
let v = XrefValues { para: "Intro > the start".into(), ..Default::default() };
|
||||
let out = expand("<x a=\"b> <fullPara />", &v);
|
||||
assert!(out.contains("Intro > the start"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_expand() {
|
||||
let v =
|
||||
|
||||
@@ -66,6 +66,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection,
|
||||
join_paths
|
||||
),
|
||||
cmd!(
|
||||
"path.deletePoints",
|
||||
"Delete Points",
|
||||
[],
|
||||
None,
|
||||
"{points?: [[id, subpath, index]]} — the Direct Selection tool's selected points by default, in one undo step (Properties › Quick Actions › Delete Point(s)); a subpath left with one point goes, and an object with none is removed → {deleted, removed: [id]}",
|
||||
has_doc,
|
||||
delete_points
|
||||
),
|
||||
cmd!(
|
||||
"path.convertPoints",
|
||||
"Convert Point",
|
||||
@@ -162,6 +171,66 @@ fn join_paths(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Delete Point(s): removes the given points, or the ones selected with Direct Selection.
|
||||
fn delete_points(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "path.deletePoints";
|
||||
let points: Vec<(ItemId, usize, usize)> = match p.get("points") {
|
||||
Some(v) => serde_json::from_value::<Vec<(u64, usize, usize)>>(v.clone())
|
||||
.map_err(|e| bad(ID, format!("points: [[id, subpath, index], …] ({e})")))?
|
||||
.into_iter()
|
||||
.map(|(id, si, ai)| (ItemId(id), si, ai))
|
||||
.collect(),
|
||||
None => s.doc()?.selection.anchors.clone(),
|
||||
};
|
||||
if points.is_empty() {
|
||||
return Err(bad(ID, "no points selected"));
|
||||
}
|
||||
// Each subpath's points, worked from the last subpath and the last point back, so the indices
|
||||
// still to delete keep their place.
|
||||
let mut groups: Vec<((ItemId, usize), Vec<usize>)> = Vec::new();
|
||||
for (id, si, ai) in points {
|
||||
match groups.iter_mut().find(|(k, _)| *k == (id, si)) {
|
||||
Some((_, v)) => v.push(ai),
|
||||
None => groups.push(((id, si), vec![ai])),
|
||||
}
|
||||
}
|
||||
groups.sort_by_key(|((id, si), _)| std::cmp::Reverse((id.0, *si)));
|
||||
s.edit(|d, sel| {
|
||||
let (mut deleted, mut removed) = (0usize, Vec::new());
|
||||
for ((id, si), mut ais) in groups {
|
||||
if d.item(id).is_none() {
|
||||
continue;
|
||||
}
|
||||
path_editable(d, id, ID)?;
|
||||
let Some(it) = d.item_mut(id) else { continue };
|
||||
let Some(sp) = it.path.subpaths.get_mut(si) else { continue };
|
||||
ais.sort_unstable();
|
||||
ais.dedup();
|
||||
for ai in ais.into_iter().rev() {
|
||||
if ai < sp.anchors.len() {
|
||||
sp.anchors.remove(ai);
|
||||
deleted += 1;
|
||||
}
|
||||
}
|
||||
// A subpath needs two points (three to stay closed).
|
||||
let (left, closed) = (sp.anchors.len(), sp.closed);
|
||||
if left < 2 {
|
||||
it.path.subpaths.remove(si);
|
||||
} else if closed && left < 3 {
|
||||
sp.closed = false;
|
||||
}
|
||||
as_path(&mut it.shape);
|
||||
if it.path.subpaths.is_empty() {
|
||||
d.remove_item(id)?;
|
||||
sel.items.retain(|i| *i != id);
|
||||
removed.push(id.0);
|
||||
}
|
||||
}
|
||||
sel.anchors.clear();
|
||||
Ok(json!({"deleted": deleted, "removed": removed}))
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert Point: the selected points (or every point of the selected paths) to a point type.
|
||||
fn convert_points(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let kind = super::str_param(p, "kind").unwrap_or("plain").to_string();
|
||||
@@ -232,7 +301,7 @@ fn erase_or_smooth(s: &mut Session, p: &Value, smooth: bool) -> Result<Value> {
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(|v| Some(Point::new(v.get(0)?.as_f64()?, v.get(1)?.as_f64()?))).collect())
|
||||
.unwrap_or_default();
|
||||
s.edit(|d, _| {
|
||||
s.edit(|d, sel| {
|
||||
path_editable(d, id, cmd)?;
|
||||
let inv = to_inner(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
let scale = inv.determinant().abs().sqrt().max(1e-9);
|
||||
@@ -257,6 +326,8 @@ fn erase_or_smooth(s: &mut Session, p: &Value, smooth: bool) -> Result<Value> {
|
||||
return Err(bad(cmd, "the drag didn't touch the path"));
|
||||
};
|
||||
let sp = it.path.subpaths[si].clone();
|
||||
// The subpath list changes under the stored point indices: they would name other points.
|
||||
sel.anchors.retain(|a| a.0 != id);
|
||||
if smooth {
|
||||
it.path.subpaths[si] = designcraft_geom::edit_path::smooth(&sp, a, b, tol * scale);
|
||||
} else {
|
||||
@@ -299,7 +370,7 @@ fn add_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = super::id_param(p, "id").ok_or_else(|| bad("path.addAnchor", "missing id"))?;
|
||||
let at = point_param(p, "at").ok_or_else(|| bad("path.addAnchor", "missing at"))?;
|
||||
let tol = p.get("tolerance").and_then(Value::as_f64).unwrap_or(4.0);
|
||||
s.edit(|d, _| {
|
||||
s.edit(|d, sel| {
|
||||
path_editable(d, id, "path.addAnchor")?;
|
||||
let inv = to_inner(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
@@ -313,6 +384,10 @@ fn add_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let t = t.clamp(1e-4, 1.0 - 1e-4);
|
||||
let ai = it.path.subpaths[si].insert_anchor(seg, t);
|
||||
as_path(&mut it.shape);
|
||||
// Selected points after the new one move up an index, so they stay the same points.
|
||||
for a in sel.anchors.iter_mut().filter(|a| a.0 == id && a.1 == si && a.2 >= ai) {
|
||||
a.2 += 1;
|
||||
}
|
||||
Ok(json!({"subpath": si, "anchor": ai}))
|
||||
})
|
||||
}
|
||||
@@ -328,15 +403,27 @@ fn delete_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
sp.anchors.remove(ai);
|
||||
// A subpath needs two points (three to stay closed).
|
||||
if sp.anchors.len() < 2 {
|
||||
let removed_subpath = sp.anchors.len() < 2;
|
||||
if removed_subpath {
|
||||
it.path.subpaths.remove(si);
|
||||
} else if sp.closed && sp.anchors.len() < 3 {
|
||||
sp.closed = false;
|
||||
}
|
||||
as_path(&mut it.shape);
|
||||
// The deleted point leaves the selection; the selected points after it (or the subpaths
|
||||
// after a removed one) move down an index.
|
||||
sel.anchors.retain(|a| !(a.0 == id && a.1 == si && (a.2 == ai || removed_subpath)));
|
||||
for a in sel.anchors.iter_mut().filter(|a| a.0 == id) {
|
||||
if removed_subpath && a.1 > si {
|
||||
a.1 -= 1;
|
||||
} else if !removed_subpath && a.1 == si && a.2 > ai {
|
||||
a.2 -= 1;
|
||||
}
|
||||
}
|
||||
if it.path.subpaths.is_empty() {
|
||||
d.remove_item(id)?;
|
||||
sel.items.retain(|i| *i != id);
|
||||
sel.anchors.retain(|a| a.0 != id);
|
||||
return Ok(json!({"deleted": true}));
|
||||
}
|
||||
Ok(json!({"deleted": false}))
|
||||
@@ -426,6 +513,27 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Join Path closes one open path, and joins two open paths at their nearest ends.
|
||||
/// `path.moveAnchors` points may each carry their own movement (Direct Selection sends each
|
||||
/// point's in its own spread); without one a point takes the shared `dx`, `dy`.
|
||||
#[test]
|
||||
fn move_anchors_takes_a_movement_per_point() {
|
||||
let mut s = crate::Session::new();
|
||||
s.execute("file.new", &serde_json::json!({})).unwrap();
|
||||
let id = s.execute("frame.create", &serde_json::json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap();
|
||||
let at = |s: &crate::Session, i: usize| {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let it = d.item(designcraft_doc::ItemId(id)).unwrap();
|
||||
it.xf * it.path.subpaths[0].anchors[i].p
|
||||
};
|
||||
let (a0, a1) = (at(&s, 0), at(&s, 1));
|
||||
s.execute("path.moveAnchors", &serde_json::json!({"points": [[id, 0, 0, 5, 0], [id, 0, 1]], "dx": 0, "dy": 7})).unwrap();
|
||||
assert_eq!(at(&s, 0) - a0, designcraft_geom::Vec2::new(5.0, 0.0), "its own");
|
||||
assert_eq!(at(&s, 1) - a1, designcraft_geom::Vec2::new(0.0, 7.0), "the shared one");
|
||||
for bad in [serde_json::json!([[id, 0, 0, "x", 1]]), serde_json::json!([[id, 0, 0, 5]])] {
|
||||
assert!(s.execute("path.moveAnchors", &serde_json::json!({"points": bad})).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_path_closes_one_path_and_joins_two() {
|
||||
let mut s = Session::new();
|
||||
@@ -454,6 +562,63 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Convert Point turns the selected points (or a whole path's) plain, corner, smooth or symmetrical.
|
||||
/// Delete Point(s) removes the selected points of several paths in one step; a triangle left
|
||||
/// with two points opens, and Undo brings every point back.
|
||||
#[test]
|
||||
fn delete_points_removes_the_selected_points_of_several_paths() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let a = s.execute("frame.create", &json!({"rect": [72, 72, 172, 172]})).unwrap()["id"].as_u64().unwrap();
|
||||
let b = s.execute("frame.create", &json!({"rect": [200, 72, 300, 172], "shape": "polygon", "sides": 3})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("selection.set", &json!({"anchors": [[a, 0, 0], [a, 0, 2], [b, 0, 1]]})).unwrap();
|
||||
let r = s.execute("path.deletePoints", &json!({})).unwrap();
|
||||
assert_eq!(r["deleted"], 3);
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let pa = &d.item(ItemId(a)).unwrap().path.subpaths[0];
|
||||
assert_eq!(pa.anchors.len(), 2, "two of the square's four points are left");
|
||||
assert!(!pa.closed, "two points can't stay closed");
|
||||
assert_eq!(d.item(ItemId(b)).unwrap().path.subpaths[0].anchors.len(), 2);
|
||||
assert!(s.doc().unwrap().selection.anchors.is_empty());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.item(ItemId(a)).unwrap().path.subpaths[0].anchors.len(), 4, "one undo step");
|
||||
assert_eq!(d.item(ItemId(b)).unwrap().path.subpaths[0].anchors.len(), 3);
|
||||
// A line's two points: the line goes.
|
||||
let l = s.execute("path.create", &json!({"anchors": [{"p": [72, 300]}, {"p": [172, 300]}]})).unwrap()["id"].as_u64().unwrap();
|
||||
let r = s.execute("path.deletePoints", &json!({"points": [[l, 0, 0]]})).unwrap();
|
||||
assert_eq!(r["removed"], json!([l]));
|
||||
assert!(s.doc().unwrap().doc.item(ItemId(l)).is_none());
|
||||
assert!(s.execute("path.deletePoints", &json!({"points": []})).is_err());
|
||||
// Named points need no selected object.
|
||||
s.execute("selection.set", &json!({"ids": []})).unwrap();
|
||||
assert!(s.doc().unwrap().selection.items.is_empty());
|
||||
let r = s.execute("path.deletePoints", &json!({"points": [[b, 0, 0]]})).unwrap();
|
||||
assert_eq!(r["deleted"], 1);
|
||||
// Edit › Clear with points selected deletes the points, not their paths.
|
||||
s.execute("selection.set", &json!({"anchors": [[a, 0, 0]]})).unwrap();
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.item(ItemId(a)).unwrap().path.subpaths[0].anchors.len(), 3, "one point gone, the frame stays");
|
||||
// Cut copies whole objects, so it removes whole objects.
|
||||
s.execute("selection.set", &json!({"anchors": [[a, 0, 0]]})).unwrap();
|
||||
s.execute("edit.cut", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.item(ItemId(a)).is_none(), "the cut object is gone");
|
||||
}
|
||||
|
||||
/// Shift-adding an object to a point selection drops the points, so Clear deletes the objects.
|
||||
#[test]
|
||||
fn clear_after_adding_an_object_to_a_point_selection_deletes_objects() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let a = s.execute("frame.create", &json!({"rect": [72, 72, 172, 172]})).unwrap()["id"].as_u64().unwrap();
|
||||
let b = s.execute("frame.create", &json!({"rect": [200, 72, 300, 172]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("selection.set", &json!({"anchors": [[a, 0, 0]]})).unwrap();
|
||||
s.execute("selection.set", &json!({"ids": [b], "add": true})).unwrap();
|
||||
assert!(s.doc().unwrap().selection.anchors.is_empty());
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.item(ItemId(a)).is_none());
|
||||
assert!(s.doc().unwrap().doc.item(ItemId(b)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_point_kinds() {
|
||||
let mut s = Session::new();
|
||||
@@ -486,16 +651,45 @@ mod tests {
|
||||
let pts: Vec<Value> = (0..=20).map(|i| json!({"p": [100.0 + i as f64 * 20.0, if i % 2 == 0 { 300.0 } else { 306.0 }]})).collect();
|
||||
let id = s.execute("path.create", &json!({"anchors": pts})).unwrap()["id"].as_u64().unwrap();
|
||||
let n = s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.anchor_count();
|
||||
s.execute("selection.set", &json!({"anchors": [[id, 0, 20]]})).unwrap();
|
||||
let drag: Vec<Value> = (0..=12).map(|i| json!([150.0 + i as f64 * 20.0, 303.0])).collect();
|
||||
s.execute("path.smooth", &json!({"id": id, "points": drag})).unwrap();
|
||||
let after = s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.anchor_count();
|
||||
assert!(after < n, "{after} < {n}");
|
||||
assert!(s.doc().unwrap().selection.anchors.is_empty(), "stored point indices are stale after the path is rebuilt");
|
||||
let drag: Vec<Value> = (0..=5).map(|i| json!([200.0 + i as f64 * 20.0, 303.0])).collect();
|
||||
let r = s.execute("path.erase", &json!({"id": id, "points": drag})).unwrap();
|
||||
assert_eq!(r["subpaths"], 2, "a gap in the middle");
|
||||
assert!(s.execute("path.erase", &json!({"id": id, "points": [[100, 600]]})).is_err());
|
||||
}
|
||||
|
||||
/// The Direct Selection's selected points stay the same points when the path changes under
|
||||
/// them: a point added before them, one of them deleted, the path reversed.
|
||||
#[test]
|
||||
fn selected_anchors_follow_added_deleted_and_reversed_points() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
|
||||
let point = |s: &Session, a: (usize, usize)| s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.subpaths[a.0].anchors[a.1].p;
|
||||
s.execute("selection.set", &json!({"anchors": [[id, 0, 2], [id, 0, 3]]})).unwrap();
|
||||
let picked = |s: &Session| s.doc().unwrap().selection.anchors.iter().map(|a| (a.1, a.2)).collect::<Vec<_>>();
|
||||
let before: Vec<_> = picked(&s).into_iter().map(|a| point(&s, a)).collect();
|
||||
// A point added on the first side, before both.
|
||||
let first = point(&s, (0, 0));
|
||||
let second = point(&s, (0, 1));
|
||||
let mid = [(first.x + second.x) / 2.0, (first.y + second.y) / 2.0];
|
||||
s.execute("path.addAnchor", &json!({"id": id, "at": mid})).unwrap();
|
||||
assert_eq!(picked(&s).into_iter().map(|a| point(&s, a)).collect::<Vec<_>>(), before, "after an added point");
|
||||
// One of them deleted: the other stays selected, the same point.
|
||||
let (si, ai) = picked(&s)[0];
|
||||
s.execute("path.deleteAnchor", &json!({"id": id, "subpath": si, "anchor": ai})).unwrap();
|
||||
assert_eq!(picked(&s).len(), 1);
|
||||
assert_eq!(point(&s, picked(&s)[0]), before[1], "after a deleted point");
|
||||
// Reversed: still the same point.
|
||||
s.execute("path.reverse", &json!({"ids": [id]})).unwrap();
|
||||
assert_eq!(point(&s, picked(&s)[0]), before[1], "after reversing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_convert_delete_anchor_points() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -8,6 +8,21 @@ use serde_json::{Value, json};
|
||||
use super::{CommandSpec, bool_or, cmd, has_doc, has_selection, ids_param, ok};
|
||||
use crate::{HistoryEntry, Result, Session};
|
||||
|
||||
/// `anchors` of `selection.set`: the points that exist, once each.
|
||||
fn anchors_param(doc: &designcraft_doc::Document, p: &Value) -> Vec<(ItemId, usize, usize)> {
|
||||
let mut out: Vec<(ItemId, usize, usize)> = Vec::new();
|
||||
for v in p.get("anchors").and_then(Value::as_array).into_iter().flatten() {
|
||||
let n = |i: usize| v.get(i).and_then(Value::as_u64);
|
||||
let (Some(id), Some(si), Some(ai)) = (n(0), n(1), n(2)) else { continue };
|
||||
let (Ok(si), Ok(ai)) = (usize::try_from(si), usize::try_from(ai)) else { continue };
|
||||
let a = (ItemId(id), si, ai);
|
||||
if doc.item(a.0).is_some_and(|it| it.path.subpaths.get(si).is_some_and(|sp| ai < sp.anchors.len())) && !out.contains(&a) {
|
||||
out.push(a);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn can_undo(s: &Session) -> std::result::Result<(), String> {
|
||||
s.active().filter(|d| !d.history.undo.is_empty()).map(|_| ()).ok_or_else(|| "nothing to undo".into())
|
||||
}
|
||||
@@ -235,20 +250,36 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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| {
|
||||
let ids = ids_param(p, "ids").unwrap_or_default();
|
||||
cmd!(noundo "selection.set", "Select", [], None, "{ids: [id], add?: bool, content?: bool, anchors?: [[id, subpath, index]] (Direct Selection: the selected anchor points; their paths are selected too)}", has_doc, |s, p| {
|
||||
let mut ids = ids_param(p, "ids").unwrap_or_default();
|
||||
let add = bool_or(p, "add", false);
|
||||
let content = bool_or(p, "content", false);
|
||||
let st = s.doc_mut()?;
|
||||
let anchors = anchors_param(&st.doc, p);
|
||||
for (id, _, _) in &anchors {
|
||||
if !ids.contains(id) {
|
||||
ids.push(*id);
|
||||
}
|
||||
}
|
||||
let ids: Vec<ItemId> = ids.into_iter().filter(|i| st.doc.item(*i).is_some()).collect();
|
||||
if add {
|
||||
// Adding objects (no points) leaves no live points: Delete would otherwise remove
|
||||
// the stale points instead of the objects.
|
||||
if anchors.is_empty() {
|
||||
st.selection.anchors.clear();
|
||||
}
|
||||
for i in ids {
|
||||
if !st.selection.items.contains(&i) {
|
||||
st.selection.items.push(i);
|
||||
}
|
||||
}
|
||||
for a in anchors {
|
||||
if !st.selection.anchors.contains(&a) {
|
||||
st.selection.anchors.push(a);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
st.selection = Selection { items: ids, content, ..Default::default() };
|
||||
st.selection = Selection { items: ids, content, anchors, ..Default::default() };
|
||||
}
|
||||
st.selection.text = None;
|
||||
st.selection.cells = None;
|
||||
@@ -262,12 +293,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
st.selection.cells = None;
|
||||
if let Some(i) = st.selection.items.iter().position(|x| *x == id) {
|
||||
st.selection.items.remove(i);
|
||||
// Its points go with it: arrow keys and Delete act on the selected points.
|
||||
st.selection.anchors.retain(|a| a.0 != id);
|
||||
// A key object needs another object to align to it.
|
||||
if st.selection.key == Some(id) || st.selection.items.len() < 2 {
|
||||
st.selection.key = None;
|
||||
}
|
||||
} else {
|
||||
st.selection.items.push(id);
|
||||
st.selection.anchors.clear();
|
||||
}
|
||||
st.bump_revision();
|
||||
ok()
|
||||
@@ -320,7 +354,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Clear",
|
||||
["Edit"],
|
||||
Some("Delete"),
|
||||
"{ids?} — delete selected items (or the selected text or ruler guides)",
|
||||
"{ids?} — delete selected items (or the selected text, points or ruler guides)",
|
||||
has_doc,
|
||||
|s, p| {
|
||||
if s.doc()?.selection.text.is_some() && p.get("ids").is_none() {
|
||||
@@ -329,6 +363,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
if p.get("ids").is_none() && s.doc()?.selection.items.is_empty() && !s.doc()?.selection.guides.is_empty() {
|
||||
return s.execute("guide.delete", &json!({}));
|
||||
}
|
||||
// Points selected with Direct Selection: they go, not their paths (as InDesign).
|
||||
if p.get("ids").is_none() && !s.doc()?.selection.anchors.is_empty() {
|
||||
return s.execute("path.deletePoints", &json!({}));
|
||||
}
|
||||
let ids = super::targets(s, p)?;
|
||||
// With a placed graphic itself selected (Direct Selection), only the graphic goes: its
|
||||
// frame stays, empty and selected (as in InDesign). Other items are deleted.
|
||||
@@ -362,6 +400,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
return Ok(json!({"text": text}));
|
||||
}
|
||||
copy_items(s)?;
|
||||
// The whole objects are on the clipboard, so the whole objects go, not just selected points.
|
||||
if p.get("ids").is_none() {
|
||||
let ids: Vec<u64> = s.doc()?.selection.items.iter().map(|i| i.0).collect();
|
||||
return s.execute("edit.clear", &json!({"ids": ids}));
|
||||
}
|
||||
s.execute("edit.clear", p)
|
||||
}),
|
||||
cmd!(
|
||||
|
||||
@@ -16,6 +16,7 @@ mod registry;
|
||||
use flatten::{rasterize_effects, shadow_coverage};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) use pdf::pdf_export;
|
||||
pub(crate) use pdf::{check_pdf_options, pdf_report};
|
||||
pub(crate) use pdf::{flatten_ppi, options, print_pdf};
|
||||
pub use registry::specs;
|
||||
|
||||
|
||||
@@ -169,41 +169,21 @@ pub(crate) fn print_pdf(
|
||||
/// The PDF that File › Export PDF writes for `p`, with its warnings: the print PDF
|
||||
/// ([`print_pdf`]) with its view options, timings and transitions. File › Print prints it.
|
||||
pub(crate) fn pdf_export(s: &Session, p: &Value) -> Result<designcraft_pdf::ExportReport> {
|
||||
let st = s.doc()?;
|
||||
let opts = options(p, st.doc.page_count())?;
|
||||
pdf_report(&s.doc()?.doc, &s.cache, p)
|
||||
}
|
||||
|
||||
/// [`pdf_export`] of `doc`: `file.package` writes its PDF of the packaged copy with it.
|
||||
pub(crate) fn pdf_report(doc: &designcraft_doc::Document, cache: &designcraft_compose::Cache, p: &Value) -> Result<designcraft_pdf::ExportReport> {
|
||||
let opts = options(p, doc.page_count())?;
|
||||
let ppi = flatten_ppi("file.exportPdf", p)?;
|
||||
let mut r = print_pdf(&st.doc, &s.cache, &opts, ppi)?;
|
||||
let doc: &designcraft_doc::Document = &st.doc;
|
||||
let (cat, dest) = view_options(p)?;
|
||||
let mut r = print_pdf(doc, cache, &opts, ppi)?;
|
||||
let spreads = designcraft_pdf::sheet_spreads(doc, &opts);
|
||||
// Interactive PDF view options.
|
||||
let mut cat = String::new();
|
||||
if p.get("fullScreen").and_then(Value::as_bool) == Some(true) {
|
||||
cat.push_str("/PageMode/FullScreen");
|
||||
} else if p.get("bookmarksPanel").and_then(Value::as_bool) == Some(true) {
|
||||
cat.push_str("/PageMode/UseOutlines");
|
||||
}
|
||||
match str_param(p, "pageLayout") {
|
||||
Some("single") => cat.push_str("/PageLayout/SinglePage"),
|
||||
Some("continuous") => cat.push_str("/PageLayout/OneColumn"),
|
||||
Some("twoUp") => cat.push_str("/PageLayout/TwoPageLeft"),
|
||||
Some("twoUpCover") => cat.push_str("/PageLayout/TwoPageRight"),
|
||||
Some("twoUpContinuous") => cat.push_str("/PageLayout/TwoColumnRight"),
|
||||
Some(o) => return Err(bad("file.exportPdf", format!("unknown pageLayout `{o}`"))),
|
||||
None => {}
|
||||
}
|
||||
if let Some(v) = str_param(p, "view") {
|
||||
// The first page opens with this view (pages are objects in order, so refer by index 0).
|
||||
let dest = match v {
|
||||
"fitPage" => "/Fit",
|
||||
"fitWidth" => "/FitH null",
|
||||
"actual" => "/XYZ null null 1",
|
||||
o => {
|
||||
return Err(bad("file.exportPdf", format!("unknown view `{o}` (fitPage, fitWidth, actual)")));
|
||||
}
|
||||
};
|
||||
if let Some(first) = first_page_ref(&r.bytes) {
|
||||
cat.push_str(&format!("/OpenAction[{first} 0 R{dest}]"));
|
||||
}
|
||||
let mut cat = cat;
|
||||
if let Some(dest) = dest
|
||||
&& let Some(first) = first_page_ref(&r.bytes)
|
||||
{
|
||||
cat.push_str(&format!("/OpenAction[{first} 0 R{dest}]"));
|
||||
}
|
||||
if !cat.is_empty() {
|
||||
match designcraft_pdf::add_catalog_entries(&r.bytes, &cat) {
|
||||
@@ -213,8 +193,7 @@ pub(crate) fn pdf_export(s: &Session, p: &Value) -> Result<designcraft_pdf::Expo
|
||||
}
|
||||
// Flip pages every N seconds (presentation).
|
||||
if let Some(secs) = p.get("advanceSeconds").and_then(Value::as_f64).filter(|s| *s > 0.0) {
|
||||
let n = designcraft_pdf::sheet_spreads(doc, &opts).len();
|
||||
let dur: Vec<Option<String>> = (0..n).map(|_| Some(format!("/Dur {secs:.2}"))).collect();
|
||||
let dur: Vec<Option<String>> = (0..spreads.len()).map(|_| Some(format!("/Dur {secs:.2}"))).collect();
|
||||
match designcraft_pdf::add_page_entries(&r.bytes, &dur) {
|
||||
Some(b) => r.bytes = b,
|
||||
None => r.warnings.push("page timings couldn't be set".into()),
|
||||
@@ -232,6 +211,44 @@ pub(crate) fn pdf_export(s: &Session, p: &Value) -> Result<designcraft_pdf::Expo
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// The catalog entries of the interactive view options in `p` (page mode and layout) and the
|
||||
/// destination the first page opens at (`view`); unknown values are refused.
|
||||
fn view_options(p: &Value) -> Result<(String, Option<&'static str>)> {
|
||||
let mut cat = String::new();
|
||||
if p.get("fullScreen").and_then(Value::as_bool) == Some(true) {
|
||||
cat.push_str("/PageMode/FullScreen");
|
||||
} else if p.get("bookmarksPanel").and_then(Value::as_bool) == Some(true) {
|
||||
cat.push_str("/PageMode/UseOutlines");
|
||||
}
|
||||
match str_param(p, "pageLayout") {
|
||||
Some("single") => cat.push_str("/PageLayout/SinglePage"),
|
||||
Some("continuous") => cat.push_str("/PageLayout/OneColumn"),
|
||||
Some("twoUp") => cat.push_str("/PageLayout/TwoPageLeft"),
|
||||
Some("twoUpCover") => cat.push_str("/PageLayout/TwoPageRight"),
|
||||
Some("twoUpContinuous") => cat.push_str("/PageLayout/TwoColumnRight"),
|
||||
Some(o) => return Err(bad("file.exportPdf", format!("unknown pageLayout `{o}`"))),
|
||||
None => {}
|
||||
}
|
||||
// The first page opens with this view (pages are objects in order, so refer by index 0).
|
||||
let dest = match str_param(p, "view") {
|
||||
None => None,
|
||||
Some("fitPage") => Some("/Fit"),
|
||||
Some("fitWidth") => Some("/FitH null"),
|
||||
Some("actual") => Some("/XYZ null null 1"),
|
||||
Some(o) => return Err(bad("file.exportPdf", format!("unknown view `{o}` (fitPage, fitWidth, actual)"))),
|
||||
};
|
||||
Ok((cat, dest))
|
||||
}
|
||||
|
||||
/// Refuses PDF options `p` that [`pdf_report`] would refuse for a document of `pages` pages,
|
||||
/// without exporting: `file.package` checks them before it writes anything.
|
||||
pub(crate) fn check_pdf_options(p: &Value, pages: usize) -> Result<()> {
|
||||
options(p, pages)?;
|
||||
flatten_ppi("file.exportPdf", p)?;
|
||||
view_options(p)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tagged_tests {
|
||||
use serde_json::json;
|
||||
|
||||
@@ -13,10 +13,11 @@ fn find_preset(s: &Session, name: &str) -> Option<NewDocument> {
|
||||
}
|
||||
|
||||
/// The settings `file.new` makes a document with for `p`: its preset, then the settings over it.
|
||||
/// Without a preset or `units`, it is in the default units (Preferences with no document).
|
||||
fn new_document_settings(s: &Session, p: &Value) -> Result<NewDocument> {
|
||||
let mut nd = match str_param(p, "preset") {
|
||||
Some(name) => find_preset(s, name).ok_or_else(|| bad("file.new", format!("unknown preset `{name}`")))?,
|
||||
None => NewDocument::default(),
|
||||
None => s.default_new_document(),
|
||||
};
|
||||
apply_settings(&mut nd, p);
|
||||
check_size("file.new", &nd)?;
|
||||
@@ -40,12 +41,19 @@ pub(super) fn file_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut nd = new_document_settings(s, p)?;
|
||||
s.untitled += 1;
|
||||
nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| format!("Untitled-{}", s.untitled));
|
||||
let d = new_document(&nd);
|
||||
let mut d = new_document(&nd);
|
||||
s.prefs.default_units.apply(&mut d.settings, p.get("preset").is_some_and(|v| !v.is_null()));
|
||||
let i = s.add_document(DocState::new(d, None));
|
||||
Ok(json!({"index": i}))
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// What New Document starts with: the factory settings, in the default units (Preferences
|
||||
/// with no document open).
|
||||
pub fn default_new_document(&self) -> NewDocument {
|
||||
NewDocument { units: self.prefs.default_units.horizontal_units, ..NewDocument::default() }
|
||||
}
|
||||
|
||||
/// New Document's Preview: a document made as `file.new` would make it with `p`, shown while
|
||||
/// the New Document window is open. It replaces the previous preview document in its tab and
|
||||
/// takes no Untitled number, no undo step and no recovery entry. Settings `file.new` refuses
|
||||
@@ -64,7 +72,9 @@ impl Session {
|
||||
self.untitled += 1;
|
||||
}
|
||||
nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| self.next_untitled_title());
|
||||
let st = DocState::new(new_document(&nd), None);
|
||||
let mut d = new_document(&nd);
|
||||
self.prefs.default_units.apply(&mut d.settings, p.get("preset").is_some_and(|v| !v.is_null()));
|
||||
let st = DocState::new(d, None);
|
||||
let uid = st.uid;
|
||||
match replace.and_then(|i| self.docs.get_mut(i).map(|d| (i, d))) {
|
||||
Some((i, d)) => {
|
||||
|
||||
@@ -276,7 +276,7 @@ pub(super) fn place_drop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// Drag: fit proportionally; click: actual size at the point.
|
||||
let (r, k) = match rect {
|
||||
Some(r) if r.width() > 2.0 && r.height() > 2.0 => {
|
||||
Some(r) if r.width() > designcraft_tools::PLACE_DRAG_MIN && r.height() > designcraft_tools::PLACE_DRAG_MIN => {
|
||||
let k = (r.width() / nw).min(r.height() / nh);
|
||||
(Rect::new(r.x0, r.y0, r.x0 + nw * k, r.y0 + nh * k), k)
|
||||
}
|
||||
|
||||
+395
-28
@@ -1,12 +1,12 @@
|
||||
//! Edit → Find/Change: plain text and GREP (regular expressions) with InDesign's metacharacters,
|
||||
//! optional formatting in the change, scoped to the document, a story or the selection.
|
||||
//! a Find Format and a Change Format, scoped to the document, a story or the selection.
|
||||
|
||||
use std::ops::Range;
|
||||
use std::sync::{Arc, Weak};
|
||||
|
||||
use designcraft_compose::grep::{Grep, GrepOptions, translate_replacement};
|
||||
use designcraft_doc::story::floor_char_boundary;
|
||||
use designcraft_doc::{CellAddr, Document, Selection, StoryId, TextSel};
|
||||
use designcraft_doc::{CellAddr, Document, Selection, Story, StoryId, Styles, TextSel};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, bool_or, cmd, has_doc, str_param};
|
||||
@@ -61,18 +61,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
),
|
||||
cmd!(query "find.find", "Find", ["Edit", "Find/Change"], None,
|
||||
"{find, grep?: bool, caseSensitive?: bool, wholeWord?: bool, scope?: document|story|selection, story?, change?|first?|attrs?: ignored (find.change takes them; one request serves all three Find/Change commands)} → matches [{story, start, end, text, cell?: {table, row, col}}] (document scope also searches table cells; start/end then index the cell's text)",
|
||||
"{find, grep?: bool, caseSensitive?: bool, wholeWord?: bool, scope?: document|story|selection, story?, findFormat?: {charStyle?, paraStyle?, chars?: resolved character attributes} (Find Format: only text with it all through; with an empty find, each stretch of text with it), change?|first?|found?|attrs?|charStyle?|paraStyle?: ignored (find.change takes them; one request serves all three Find/Change commands)} → matches [{story, start, end, text, cell?: {table, row, col}}] (document scope also searches table cells; start/end then index the cell's text)",
|
||||
has_doc, find),
|
||||
cmd!(
|
||||
"find.change",
|
||||
"Change All",
|
||||
["Edit", "Find/Change"],
|
||||
None,
|
||||
"{find, change, grep?, caseSensitive?, wholeWord?, scope?, story?, first?: bool (change only the first match), attrs?: {character attributes applied to changed text}} → {count}",
|
||||
"{find, change, grep?, caseSensitive?, wholeWord?, scope?, story?, findFormat?: {charStyle?, paraStyle?, chars?: resolved character attributes} (Find Format: only text with it all through; with an empty find, each stretch of text with it), first?: bool (change only the first match), found?: bool (Change: only the text Find Next found, while it is selected and still a match; it stays selected, and Find Next goes on after it in the same domain), attrs?: {character attributes applied to changed text}, charStyle?: a character style applied to it, paraStyle?: a paragraph style applied to its paragraphs} → {count} — attrs, charStyle and paraStyle are the Change Format: with one and an empty change, the found text stays and is only formatted; with no find, change is literal text",
|
||||
has_doc,
|
||||
change
|
||||
),
|
||||
cmd!(noundo "find.next", "Find Next", ["Edit"], Some("Cmd+Alt+F"), "{find, grep?, caseSensitive?, wholeWord?, scope?: document|story|selection, story?, change?|first?|attrs?: ignored (find.change takes them; one request serves all three Find/Change commands)} — Selection starts at its original beginning; later calls advance within the scope", has_doc, find_next),
|
||||
cmd!(noundo "find.next", "Find Next", ["Edit"], Some("Cmd+Alt+F"), "{find, grep?, caseSensitive?, wholeWord?, scope?: document|story|selection, story?, findFormat?: {charStyle?, paraStyle?, chars?: resolved character attributes} (Find Format: only text with it all through; with an empty find, each stretch of text with it), change?|first?|found?|attrs?|charStyle?|paraStyle?: ignored (find.change takes them; one request serves all three Find/Change commands)} — Selection starts at its original beginning; later calls advance within the scope", has_doc, find_next),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -108,6 +108,227 @@ fn build(p: &Value) -> Result<Grep> {
|
||||
re.map_err(|e| bad("find", e))
|
||||
}
|
||||
|
||||
/// Find/Change's Find Format: the text a search finds must have it all through.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
struct FindFormat {
|
||||
char_style: Option<String>,
|
||||
para_style: Option<String>,
|
||||
/// Resolved character attributes, as `type.selectionAttrs` reports them.
|
||||
chars: serde_json::Map<String, Value>,
|
||||
}
|
||||
|
||||
impl FindFormat {
|
||||
fn from_params(p: &Value) -> Result<Option<Self>> {
|
||||
let Some(f) = p.get("findFormat").filter(|f| !f.is_null()) else { return Ok(None) };
|
||||
let o = f.as_object().ok_or_else(|| bad("find", "`findFormat` is an object"))?;
|
||||
// A style is named; a null asks for none. Any other value is refused rather than
|
||||
// searched without.
|
||||
let name = |k: &str| match o.get(k) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(n)) => Ok(Some(n.clone())),
|
||||
Some(v) => Err(bad("find", format!("findFormat: `{k}` is a style name, not {v}"))),
|
||||
};
|
||||
let chars = match o.get("chars") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::Object(m)) => Some(m),
|
||||
Some(v) => return Err(bad("find", format!("findFormat: `chars` is an object of character attributes, not {v}"))),
|
||||
};
|
||||
// Only attributes text has, kept as the document writes them: `font_style` and
|
||||
// `fontStyle`, or 12 and 12.0, then find the same text. A null asks for nothing.
|
||||
let mut known = designcraft_doc::CharAttrs::default();
|
||||
for (k, v) in chars.into_iter().flatten() {
|
||||
known.set_json(k, v).map_err(|e| bad("find", format!("findFormat: {e}")))?;
|
||||
}
|
||||
let chars = match serde_json::to_value(&known) {
|
||||
Ok(Value::Object(m)) => m,
|
||||
_ => serde_json::Map::new(),
|
||||
};
|
||||
let f = Self { char_style: name("charStyle")?, para_style: name("paraStyle")?, chars };
|
||||
Ok((f != Self::default()).then_some(f))
|
||||
}
|
||||
|
||||
/// Styles the document doesn't have are refused, as `find.change` refuses them.
|
||||
fn check(&self, styles: &Styles) -> Result<()> {
|
||||
if let Some(n) = self.char_style.as_ref().filter(|n| styles.char_style(n).is_none()) {
|
||||
return Err(bad("find", format!("no character style `{n}`")));
|
||||
}
|
||||
if let Some(n) = self.para_style.as_ref().filter(|n| styles.para(n).is_none()) {
|
||||
return Err(bad("find", format!("no paragraph style `{n}`")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The text in `r` of `story` that has the format, relative to `r.start`: the longest
|
||||
/// stretches, each paragraph's apart (a paragraph's `\n` isn't in one).
|
||||
fn spans(&self, styles: &Styles, story: &Story, r: &Range<usize>) -> Vec<Range<usize>> {
|
||||
let mut out: Vec<Range<usize>> = Vec::new();
|
||||
// Paragraphs and runs both go in text order: the runs before a paragraph are passed once.
|
||||
let runs: Vec<_> = story.runs().collect();
|
||||
let mut first = 0;
|
||||
for (pi, pr) in story.para_ranges().into_iter().enumerate() {
|
||||
let (a, b) = (pr.start.max(r.start), pr.end.min(r.end));
|
||||
let Some(pf) = story.paras.get(pi) else { continue };
|
||||
if a >= b || self.para_style.as_ref().is_some_and(|n| *n != pf.style) {
|
||||
continue;
|
||||
}
|
||||
while runs.get(first).is_some_and(|(run, _)| run.end <= a) {
|
||||
first += 1;
|
||||
}
|
||||
let base = (!self.chars.is_empty()).then(|| styles.resolve_para(pf).1);
|
||||
for (run, cf) in runs[first..].iter().take_while(|(run, _)| run.start < b) {
|
||||
let (x, y) = (run.start.max(a), run.end.min(b));
|
||||
if x >= y || self.char_style.as_ref().is_some_and(|n| *n != cf.style) {
|
||||
continue;
|
||||
}
|
||||
if let Some(base) = &base {
|
||||
let cp = serde_json::to_value(styles.resolve_char(base, cf)).unwrap_or_default();
|
||||
if !self.chars.iter().all(|(k, v)| cp.get(k).is_some_and(|c| same_value(c, v))) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
match out.last_mut() {
|
||||
Some(last) if last.end == x - r.start => last.end = y - r.start,
|
||||
_ => out.push(x - r.start..y - r.start),
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Attribute values alike: numbers to within rounding (inside objects and lists too), the rest
|
||||
/// exactly.
|
||||
fn same_value(a: &Value, b: &Value) -> bool {
|
||||
match (a, b) {
|
||||
(Value::Number(_), Value::Number(_)) => match (a.as_f64(), b.as_f64()) {
|
||||
(Some(x), Some(y)) => (x - y).abs() < 1e-6,
|
||||
_ => a == b,
|
||||
},
|
||||
(Value::Array(x), Value::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(a, b)| same_value(a, b)),
|
||||
(Value::Object(x), Value::Object(y)) => x.len() == y.len() && x.iter().all(|(k, v)| y.get(k).is_some_and(|w| same_value(v, w))),
|
||||
_ => a == b,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether match `m` lies in one of `spans`.
|
||||
fn within(spans: &[Range<usize>], m: &Range<usize>) -> bool {
|
||||
spans.iter().any(|s| s.start <= m.start && m.end <= s.end)
|
||||
}
|
||||
|
||||
/// What a search finds: the pattern's matches (with a Find Format, those whose text has it all
|
||||
/// through), or, with no pattern, the stretches of text with the Find Format.
|
||||
struct Search {
|
||||
re: Option<Grep>,
|
||||
format: Option<FindFormat>,
|
||||
}
|
||||
|
||||
/// The search of a Find/Change request: an empty `find` needs a Find Format.
|
||||
fn search(p: &Value) -> Result<Search> {
|
||||
let format = FindFormat::from_params(p)?;
|
||||
if format.is_some() && str_param(p, "find") == Some("") {
|
||||
return Ok(Search { re: None, format });
|
||||
}
|
||||
Ok(Search { re: Some(build(p)?), format })
|
||||
}
|
||||
|
||||
impl Search {
|
||||
fn check(&self, styles: &Styles) -> Result<()> {
|
||||
self.format.as_ref().map_or(Ok(()), |f| f.check(styles))
|
||||
}
|
||||
|
||||
/// Where the Find Format holds in target `r` of story `sid` (of its table cell `cell`, if any;
|
||||
/// `None`: no Find Format).
|
||||
fn spans(&self, d: &Document, sid: StoryId, cell: Option<CellAddr>, r: &Range<usize>) -> Option<Vec<Range<usize>>> {
|
||||
let f = self.format.as_ref()?;
|
||||
Some(d.text_story(sid, cell).map(|story| f.spans(&d.styles, story, r)).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Up to `limit` matches in `text`, target `r` of story `sid` (offsets in `text`).
|
||||
fn find_all(&self, d: &Document, sid: StoryId, cell: Option<CellAddr>, r: &Range<usize>, text: &str, limit: usize) -> Result<Vec<Range<usize>>> {
|
||||
let spans = self.spans(d, sid, cell, r);
|
||||
let Some(re) = &self.re else { return Ok(spans.unwrap_or_default().into_iter().take(limit).collect()) };
|
||||
let Some(spans) = spans else { return re.find_all(text, limit).map_err(|e| bad("find", e)) };
|
||||
Ok(re.find_all(text, usize::MAX).map_err(|e| bad("find", e))?.into_iter().filter(|m| within(&spans, m)).take(limit).collect())
|
||||
}
|
||||
|
||||
/// The first match in `text` (target `r` of story `sid`) at or after byte `from`.
|
||||
fn find_at(&self, d: &Document, sid: StoryId, cell: Option<CellAddr>, r: &Range<usize>, text: &str, from: usize) -> Result<Option<Range<usize>>> {
|
||||
let spans = self.spans(d, sid, cell, r);
|
||||
let Some(re) = &self.re else {
|
||||
// From inside a stretch, the rest of it.
|
||||
return Ok(spans.unwrap_or_default().into_iter().find(|s| s.end > from).map(|s| s.start.max(from)..s.end));
|
||||
};
|
||||
let mut at = from;
|
||||
loop {
|
||||
let Some(m) = re.find_at(text, at).map_err(|e| bad("find.next", e))? else { return Ok(None) };
|
||||
if spans.as_ref().is_none_or(|s| within(s, &m)) {
|
||||
return Ok(Some(m));
|
||||
}
|
||||
// On past it: its end, or a character on from an empty match.
|
||||
at = if m.is_empty() { text.get(m.end..).and_then(|t| t.chars().next()).map_or(text.len() + 1, |c| m.end + c.len_utf8()) } else { m.end };
|
||||
if at > text.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The edit Change makes when the selection `range` (offsets in `text`, target `r` of story
|
||||
/// `sid`) is what Find Next found there, so the two commands agree on what a match is. With
|
||||
/// no pattern: a selection inside one stretch with the Find Format (Find Next selects the rest
|
||||
/// of a stretch from inside it). With a pattern: the match it makes from the selection's start
|
||||
/// must be the selection, overlapping matches Find Next can select included.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn found_edit(
|
||||
&self,
|
||||
d: &Document,
|
||||
sid: StoryId,
|
||||
cell: Option<CellAddr>,
|
||||
r: &Range<usize>,
|
||||
text: &str,
|
||||
range: &Range<usize>,
|
||||
rep: &str,
|
||||
grep: bool,
|
||||
) -> Result<Option<(Range<usize>, String)>> {
|
||||
let spans = self.spans(d, sid, cell, r);
|
||||
let Some(re) = &self.re else {
|
||||
let inside = !range.is_empty() && spans.unwrap_or_default().iter().any(|s| s.start <= range.start && range.end <= s.end);
|
||||
return Ok(inside.then(|| (range.clone(), rep.to_string())));
|
||||
};
|
||||
let found =
|
||||
if grep { re.replacement_at(text, range.start, rep) } else { re.find_at(text, range.start).map(|m| m.map(|m| (m, rep.to_string()))) };
|
||||
let found = found.map_err(|e| bad("find.change", e))?;
|
||||
Ok(found.filter(|(m, _)| m == range && spans.as_ref().is_none_or(|s| within(s, m))))
|
||||
}
|
||||
|
||||
/// The edits Change All makes in `text` (target `r` of story `sid`): `rep` expanded for each
|
||||
/// GREP match, else `rep` itself.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn replacements(
|
||||
&self,
|
||||
d: &Document,
|
||||
sid: StoryId,
|
||||
cell: Option<CellAddr>,
|
||||
r: &Range<usize>,
|
||||
text: &str,
|
||||
rep: &str,
|
||||
grep: bool,
|
||||
first_only: bool,
|
||||
) -> Result<Vec<(Range<usize>, String)>> {
|
||||
let limit = if first_only { 1 } else { usize::MAX };
|
||||
match (&self.re, self.spans(d, sid, cell, r)) {
|
||||
(Some(re), None) if grep => re.replacements(text, rep, first_only).map_err(|e| bad("find.change", e)),
|
||||
(Some(re), Some(spans)) if grep => Ok(re
|
||||
.replacements(text, rep, false)
|
||||
.map_err(|e| bad("find.change", e))?
|
||||
.into_iter()
|
||||
.filter(|(m, _)| within(&spans, m))
|
||||
.take(limit)
|
||||
.collect()),
|
||||
_ => Ok(self.find_all(d, sid, cell, r, text, limit)?.into_iter().map(|m| (m, rep.to_string())).collect()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
enum Domain {
|
||||
Document,
|
||||
@@ -140,6 +361,7 @@ struct Matcher {
|
||||
grep: bool,
|
||||
case_sensitive: bool,
|
||||
whole_word: bool,
|
||||
format: Option<FindFormat>,
|
||||
}
|
||||
|
||||
impl Matcher {
|
||||
@@ -149,6 +371,7 @@ impl Matcher {
|
||||
grep: bool_or(p, "grep", false),
|
||||
case_sensitive: bool_or(p, "caseSensitive", false),
|
||||
whole_word: bool_or(p, "wholeWord", false),
|
||||
format: FindFormat::from_params(p).ok().flatten(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,6 +406,29 @@ impl FindContext {
|
||||
self.selection = st.selection.clone();
|
||||
}
|
||||
|
||||
/// After Change put `new_len` bytes in place of `old` in story `sid`: the domain keeps the
|
||||
/// changed text and the search goes on after it.
|
||||
fn after_change(&mut self, st: &DocState, sid: StoryId, cell: Option<CellAddr>, old: &Range<usize>, new_len: usize) {
|
||||
let moved = |x: usize| if x >= old.end { x - old.len() + new_len } else { x };
|
||||
for (tid, tcell, r) in &mut self.targets {
|
||||
if *tid == sid && *tcell == cell {
|
||||
*r = moved(r.start)..moved(r.end);
|
||||
}
|
||||
}
|
||||
self.offset = old.start + new_len;
|
||||
self.last_match_end = Some(self.offset);
|
||||
let at = self.offset;
|
||||
if let Some(i) = self
|
||||
.order
|
||||
.iter()
|
||||
.position(|i| self.targets.get(*i).is_some_and(|(tid, tcell, r)| *tid == sid && *tcell == cell && r.start <= at && at <= r.end))
|
||||
{
|
||||
self.target = i;
|
||||
}
|
||||
self.document = Arc::downgrade(&st.doc);
|
||||
self.acknowledge(st);
|
||||
}
|
||||
|
||||
fn restart(&mut self, st: &DocState) -> Result<()> {
|
||||
self.target = 0;
|
||||
self.last_match_end = None;
|
||||
@@ -299,13 +545,14 @@ fn scope(s: &Session, p: &Value) -> Result<FindContext> {
|
||||
const MAX_MATCHES: usize = 10_000;
|
||||
|
||||
fn find(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let re = build(p)?;
|
||||
let re = search(p)?;
|
||||
re.check(&s.doc()?.doc.styles)?;
|
||||
let context = scope(s, p)?;
|
||||
let st = s.doc()?;
|
||||
let mut out = Vec::new();
|
||||
for (sid, cell, r) in &context.targets {
|
||||
let text = target_text(&st.doc, *sid, *cell, r)?;
|
||||
for m in re.find_all(text, MAX_MATCHES.saturating_sub(out.len())).map_err(|e| bad("find", e))? {
|
||||
for m in re.find_all(&st.doc, *sid, *cell, r, text, MAX_MATCHES.saturating_sub(out.len()))? {
|
||||
let mut hit = json!({"story": sid.0, "start": r.start + m.start, "end": r.start + m.end, "text": text.get(m)});
|
||||
if let Some(c) = cell {
|
||||
hit["cell"] = json!({"table": c.table, "row": c.row, "col": c.col});
|
||||
@@ -349,51 +596,143 @@ fn char_overrides(attrs: Option<&Value>) -> Result<designcraft_doc::CharAttrs> {
|
||||
}
|
||||
|
||||
fn change(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let re = build(p)?;
|
||||
let grep = bool_or(p, "grep", false);
|
||||
let rep = replacement(p);
|
||||
let re = search(p)?;
|
||||
re.check(&s.doc()?.doc.styles)?;
|
||||
// With no pattern (a Find Format alone), the change is literal text.
|
||||
let grep = bool_or(p, "grep", false) && re.re.is_some();
|
||||
let rep = if re.re.is_some() { replacement(p) } else { metachars(str_param(p, "change").unwrap_or("")) };
|
||||
let first_only = bool_or(p, "first", false);
|
||||
let attrs = p.get("attrs").cloned();
|
||||
let over = char_overrides(attrs.as_ref())?;
|
||||
// A null attribute clears that override on the found text.
|
||||
let cleared: Vec<String> = attrs
|
||||
.as_ref()
|
||||
.and_then(Value::as_object)
|
||||
.map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect())
|
||||
.unwrap_or_default();
|
||||
super::text::validate_char_fill(&s.doc()?.doc, &over, "find.change")?;
|
||||
let targets = scope(s, p)?.targets;
|
||||
s.edit(|d, sel| {
|
||||
let char_style = str_param(p, "charStyle").map(str::to_string);
|
||||
let para_style = str_param(p, "paraStyle").map(str::to_string);
|
||||
let styles = &s.doc()?.doc.styles;
|
||||
if let Some(n) = char_style.as_ref().filter(|n| styles.char_style(n).is_none()) {
|
||||
return Err(bad("find.change", format!("no character style `{n}`")));
|
||||
}
|
||||
if let Some(n) = para_style.as_ref().filter(|n| styles.para(n).is_none()) {
|
||||
return Err(bad("find.change", format!("no paragraph style `{n}`")));
|
||||
}
|
||||
// InDesign: with a Change Format and nothing in Change to, the found text is only formatted.
|
||||
let format_only =
|
||||
str_param(p, "change").unwrap_or("").is_empty() && (char_style.is_some() || para_style.is_some() || !over.is_empty() || !cleared.is_empty());
|
||||
let context = scope(s, p)?;
|
||||
let targets = context.targets.clone();
|
||||
// Change: only the text Find Next found, the selection, while it is still a match (as Find
|
||||
// Next defines one), with its replacement.
|
||||
let (found, found_out) = if bool_or(p, "found", false) {
|
||||
let st = s.doc()?;
|
||||
let selected = st.selection.text.filter(|t| st.selection.cells.is_none() && !t.is_caret()).map(|t| (t.story, t.cell, t.range()));
|
||||
let hit = selected.and_then(|(sid, cell, range)| {
|
||||
targets.iter().find_map(|(tid, tcell, r)| {
|
||||
if *tid != sid || *tcell != cell || range.start < r.start || r.end < range.end {
|
||||
return None;
|
||||
}
|
||||
let text = target_text(&st.doc, *tid, cell, r).ok()?;
|
||||
let local = range.start - r.start..range.end - r.start;
|
||||
let (m, out) = re.found_edit(&st.doc, *tid, cell, r, text, &local, &rep, grep).ok()??;
|
||||
Some(((sid, cell, r.start + m.start..r.start + m.end), out))
|
||||
})
|
||||
});
|
||||
match hit {
|
||||
Some((hit, out)) => (Some(hit), Some(out)),
|
||||
None => return Ok(json!({"count": 0})),
|
||||
}
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
let (count, changed) = s.edit(|d, sel| {
|
||||
let styles = d.styles.clone();
|
||||
let mut count = 0usize;
|
||||
let mut changed = None;
|
||||
for (sid, cell, r) in targets {
|
||||
if found.as_ref().is_some_and(|(at, in_cell, _)| *at != sid || *in_cell != cell) {
|
||||
continue;
|
||||
}
|
||||
let Some(story) = d.text_story(sid, cell) else { continue };
|
||||
// Collect matches first (positions refer to the original text), apply back to front.
|
||||
let text = story.text.get(r.clone()).ok_or_else(|| bad("find", "invalid selection bounds or UTF-8 boundary"))?;
|
||||
let edits: Vec<(std::ops::Range<usize>, String)> = if grep {
|
||||
re.replacements(text, &rep, first_only)
|
||||
} else {
|
||||
re.find_all(text, if first_only { 1 } else { usize::MAX }).map(|ms| ms.into_iter().map(|m| (m, rep.clone())).collect())
|
||||
}
|
||||
.map_err(|e| bad("find.change", e))?
|
||||
.into_iter()
|
||||
.map(|(m, out)| (r.start + m.start..r.start + m.end, out))
|
||||
.collect();
|
||||
let edits: Vec<(std::ops::Range<usize>, String)> = match (&found, &found_out) {
|
||||
// Change: the found text alone, in the target it lies in.
|
||||
(Some((_, _, range)), Some(out)) => {
|
||||
if r.start <= range.start && range.end <= r.end {
|
||||
vec![(range.clone(), out.clone())]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
_ => re
|
||||
.replacements(d, sid, cell, &r, text, &rep, grep, first_only)?
|
||||
.into_iter()
|
||||
.map(|(m, out)| (r.start + m.start..r.start + m.end, out))
|
||||
.collect(),
|
||||
};
|
||||
let Some(st) = d.text_story_mut(sid, cell) else { continue };
|
||||
for (range, new) in edits.iter().rev() {
|
||||
st.replace(range.clone(), new);
|
||||
if !over.is_empty() && !new.is_empty() {
|
||||
st.format_chars(range.start..range.start + new.len(), |f| f.over.merge(&over));
|
||||
let changed = if format_only {
|
||||
range.clone()
|
||||
} else {
|
||||
st.replace(range.clone(), new);
|
||||
range.start..range.start + new.len()
|
||||
};
|
||||
// A paragraph style goes to the match's paragraphs, an empty match's (`^`) too;
|
||||
// character formatting needs characters.
|
||||
if let Some(n) = ¶_style {
|
||||
super::style::apply_para_style(st, &styles, n, &changed, false);
|
||||
}
|
||||
if !changed.is_empty() {
|
||||
if let Some(n) = &char_style {
|
||||
super::style::apply_char_style(st, &styles, n, &changed);
|
||||
}
|
||||
if !over.is_empty() || !cleared.is_empty() {
|
||||
st.format_chars(changed, |f| {
|
||||
f.over.merge(&over);
|
||||
for k in &cleared {
|
||||
let _ = f.over.set_json(k, &Value::Null);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid && t.cell == cell) {
|
||||
repair_selection(t, st);
|
||||
}
|
||||
// Change leaves the changed text selected.
|
||||
if let Some((_, _, range)) = &found
|
||||
&& let Some((_, new)) = edits.first()
|
||||
&& let Some(t) = sel.text.as_mut()
|
||||
{
|
||||
let len = if format_only { range.len() } else { new.len() };
|
||||
(t.anchor, t.focus) = (range.start, range.start + len);
|
||||
changed = Some(len);
|
||||
}
|
||||
// `first` is the first match of the whole search, not of each story.
|
||||
if first_only && count > 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(json!({"count": count}))
|
||||
})
|
||||
Ok((count, changed))
|
||||
})?;
|
||||
// Change: Find Next goes on after the changed text, in the search's domain.
|
||||
if let (Some((sid, cell, old)), Some(len)) = (&found, changed) {
|
||||
let mut context = context;
|
||||
context.after_change(s.doc()?, *sid, *cell, old, len);
|
||||
s.find_context = Some(context);
|
||||
}
|
||||
Ok(json!({"count": count}))
|
||||
}
|
||||
|
||||
fn find_next(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let re = build(p)?;
|
||||
let re = search(p)?;
|
||||
re.check(&s.doc()?.doc.styles)?;
|
||||
let mut context = scope(s, p)?;
|
||||
let st = s.doc()?;
|
||||
let mut hit = None;
|
||||
@@ -403,7 +742,7 @@ fn find_next(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if text.get(from..).is_none() {
|
||||
return Err(bad("find", "invalid UTF-8 search cursor"));
|
||||
}
|
||||
if let Some(m) = re.find_at(text, from).map_err(|e| bad("find.next", e))? {
|
||||
if let Some(m) = re.find_at(&st.doc, sid, cell, &r, text, from)? {
|
||||
let (a, b) = (r.start + m.start, r.start + m.end);
|
||||
// Match find_iter/captures_iter: suppress an empty match adjacent to the
|
||||
// previous match's end, but keep adjacent nonempty matches.
|
||||
@@ -489,6 +828,34 @@ mod object_find_tests {
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// A GREP match that is empty (`^`, the start of each paragraph) gets the paragraph style:
|
||||
/// only character formatting needs characters.
|
||||
#[test]
|
||||
fn change_format_styles_paragraphs_of_empty_matches() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s
|
||||
.execute(
|
||||
"frame.create",
|
||||
&json!({"rect": [36, 36, 300, 300], "content": "text", "text": "One
|
||||
Two
|
||||
Three"}),
|
||||
)
|
||||
.unwrap();
|
||||
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
|
||||
s.execute("style.paragraph.create", &json!({"name": "Method"})).unwrap();
|
||||
let r = s.execute("find.change", &json!({"find": "^", "change": "", "grep": true, "paraStyle": "Method"})).unwrap();
|
||||
assert_eq!(r["count"], 3);
|
||||
let story = s.doc().unwrap().doc.story(sid).unwrap().clone();
|
||||
assert!(story.paras.iter().all(|p| p.style == "Method"), "{:?}", story.paras.iter().map(|p| &p.style).collect::<Vec<_>>());
|
||||
assert_eq!(
|
||||
story.text,
|
||||
"One
|
||||
Two
|
||||
Three"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_and_change_objects_by_attributes() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -621,6 +621,27 @@ fn document_scope_searches_and_changes_table_cells() {
|
||||
assert_eq!(s.execute("find.find", &json!({"find": "13.00", "scope": "document"})).unwrap().as_array().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
/// Change (`found`) on text Find Next found in a table cell changes it there, leaves the
|
||||
/// same text in the story alone, and Find Next goes on after it.
|
||||
#[test]
|
||||
fn change_changes_the_text_find_next_found_in_a_cell() {
|
||||
let (mut s, sid) = scope_session("red here");
|
||||
scope_select(&mut s, sid, 0, 0);
|
||||
let table = s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap()["table"].as_u64().unwrap();
|
||||
s.execute("table.setCell", &json!({"row": 0, "col": 1, "text": "red"})).unwrap();
|
||||
let p = json!({"find": "red", "change": "blue", "scope": "document"});
|
||||
let hit = s.execute("find.next", &p).unwrap();
|
||||
assert_eq!(hit["cell"], json!({"table": table, "row": 0, "col": 1}), "{hit}");
|
||||
let mut change = p.clone();
|
||||
change["found"] = json!(true);
|
||||
assert_eq!(s.execute("find.change", &change).unwrap()["count"], 1);
|
||||
let t = s.execute("table.get", &json!({"table": table})).unwrap();
|
||||
assert_eq!(t["cells"][1]["text"], "blue");
|
||||
assert!(s.doc().unwrap().doc.story(sid).unwrap().text.contains("red here"), "the story's red stays");
|
||||
let next = s.execute("find.next", &p).unwrap();
|
||||
assert!(next.get("cell").is_none() && next.get("start").is_some(), "on to the story: {next}");
|
||||
}
|
||||
|
||||
fn child_scope_session(note: bool, child: &str) -> (Session, designcraft_doc::StoryId, designcraft_doc::TextSel) {
|
||||
let (mut s, sid) = scope_session("red");
|
||||
scope_select(&mut s, sid, 3, 3);
|
||||
@@ -747,3 +768,194 @@ fn find_scope_revert_releases_context_with_same_document_uid() {
|
||||
assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red | red");
|
||||
assert!(s.execute("find.find", &p).is_err(), "revert's empty selection cannot inherit a previous range");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_format_styles_the_found_text_and_keeps_it() {
|
||||
// The course's chapter 22: every lowercase "eggs" gets the character style "eggs", with
|
||||
// Change to empty, so the words stay.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let text = "Poached Eggs\nBreak eggs; cook until eggs are set.";
|
||||
let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": text})).unwrap();
|
||||
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
|
||||
s.execute("style.character.create", &json!({"name": "eggs", "chars": {"fontStyle": "Bold", "fill": "C=15 M=100 Y=100 K=0"}})).unwrap();
|
||||
s.execute("style.paragraph.create", &json!({"name": "Method"})).unwrap();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let r = s.execute("find.change", &json!({"find": "eggs", "change": "", "caseSensitive": true, "charStyle": "eggs"})).unwrap();
|
||||
assert_eq!(r["count"], 2);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
let style_at = |s: &Session, at: usize| s.doc().unwrap().doc.story(sid).unwrap().format_after(at).style.clone();
|
||||
let story = s.doc().unwrap().doc.story(sid).unwrap().clone();
|
||||
assert_eq!(story.text, text, "the words stay");
|
||||
for at in [19, 36] {
|
||||
assert_eq!(&story.text[at..at + 4], "eggs");
|
||||
assert_eq!(style_at(&s, at), "eggs");
|
||||
}
|
||||
assert_eq!(style_at(&s, 8), designcraft_doc::NO_CHAR_STYLE, "case sensitive: not \"Eggs\"");
|
||||
assert_eq!(style_at(&s, 25), designcraft_doc::NO_CHAR_STYLE, "only the found text");
|
||||
// A paragraph style goes to the found text's paragraphs, with attributes over the text.
|
||||
s.execute("find.change", &json!({"find": "cook", "change": "", "paraStyle": "Method", "attrs": {"underline": true}})).unwrap();
|
||||
let story = s.doc().unwrap().doc.story(sid).unwrap().clone();
|
||||
assert_eq!((story.paras[0].style.as_str(), story.paras[1].style.as_str()), (designcraft_doc::BASIC_PARAGRAPH, "Method"));
|
||||
assert_eq!(story.format_after(25).over.underline, Some(true));
|
||||
assert_eq!(story.format_after(19).style, "eggs", "the other text keeps its character style");
|
||||
// Change to and a Change Format: the new text is formatted.
|
||||
s.execute("find.change", &json!({"find": "set", "change": "firm", "charStyle": "eggs"})).unwrap();
|
||||
assert_eq!(style_at(&s, s.doc().unwrap().doc.story(sid).unwrap().text.find("firm").unwrap()), "eggs");
|
||||
// No such style: refused, nothing changed.
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
assert!(s.execute("find.change", &json!({"find": "eggs", "change": "", "charStyle": "Nope"})).is_err());
|
||||
assert!(s.execute("find.change", &json!({"find": "eggs", "change": "", "paraStyle": "Nope"})).is_err());
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
// A null attribute is a Change Format too: the found text stays and loses that override.
|
||||
let before = s.doc().unwrap().doc.story(sid).unwrap().clone();
|
||||
s.execute("find.change", &json!({"find": "cook", "change": "", "attrs": {"underline": null}})).unwrap();
|
||||
let story = s.doc().unwrap().doc.story(sid).unwrap().clone();
|
||||
assert_eq!(story.text, before.text, "the words stay");
|
||||
assert_eq!(story.format_after(25).over.underline, None);
|
||||
// With no Change Format, an empty Change to still deletes the found text.
|
||||
s.execute("find.change", &json!({"find": "Break ", "change": ""})).unwrap();
|
||||
assert!(!s.doc().unwrap().doc.story(sid).unwrap().text.contains("Break"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_find_format_finds_only_text_with_it() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r =
|
||||
s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Break eggs; cook eggs.\nEggs and eggs"})).unwrap();
|
||||
let sid = r["story"].as_u64().unwrap();
|
||||
s.execute("style.character.create", &json!({"name": "eggs", "chars": {"fontStyle": "Bold"}})).unwrap();
|
||||
s.execute("style.paragraph.create", &json!({"name": "Method"})).unwrap();
|
||||
for (a, b) in [(6, 10), (12, 16)] {
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": a, "focus": b})).unwrap();
|
||||
s.execute("style.character.apply", &json!({"name": "eggs"})).unwrap();
|
||||
}
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 25, "focus": 25})).unwrap();
|
||||
s.execute("style.paragraph.apply", &json!({"name": "Method"})).unwrap();
|
||||
let found = |s: &mut Session, p: serde_json::Value| -> Vec<(u64, u64)> {
|
||||
let m = s.execute("find.find", &p).unwrap();
|
||||
m.as_array().unwrap().iter().map(|m| (m["start"].as_u64().unwrap(), m["end"].as_u64().unwrap())).collect()
|
||||
};
|
||||
// "eggs" where it has the character style: the first only.
|
||||
assert_eq!(found(&mut s, json!({"find": "eggs", "findFormat": {"charStyle": "eggs"}})), [(6, 10)]);
|
||||
// Nothing in Find what: each stretch of text in the style.
|
||||
assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"charStyle": "eggs"}})), [(6, 10), (12, 16)]);
|
||||
// A paragraph style, and resolved attributes.
|
||||
assert_eq!(found(&mut s, json!({"find": "eggs", "findFormat": {"paraStyle": "Method"}})), [(23, 27), (32, 36)]);
|
||||
assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"chars": {"fontStyle": "Bold"}}})), [(6, 10), (12, 16)]);
|
||||
assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"chars": {"size": 12}}})), [(0, 22), (23, 36)], "each paragraph apart");
|
||||
// Find Next walks the stretches.
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap();
|
||||
let next = json!({"find": "", "findFormat": {"charStyle": "eggs"}});
|
||||
assert_eq!(s.execute("find.next", &next).unwrap()["start"], 6);
|
||||
assert_eq!(s.execute("find.next", &next).unwrap()["start"], 12);
|
||||
// Equivalent spellings the attributes accept find the same text: a snake_case key, and a
|
||||
// nested number written as an integer (14 pt leading on the second paragraph).
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 23, "focus": 36})).unwrap();
|
||||
s.execute("type.char", &json!({"leading": {"kind": "points", "value": 14.0}})).unwrap();
|
||||
for (a, b) in [
|
||||
(json!({"fontStyle": "Bold"}), json!({"font_style": "Bold"})),
|
||||
(json!({"leading": {"kind": "points", "value": 14.0}}), json!({"leading": {"kind": "points", "value": 14}})),
|
||||
] {
|
||||
let (a, b) = (json!({"find": "", "findFormat": {"chars": a}}), json!({"find": "", "findFormat": {"chars": b}}));
|
||||
let all = found(&mut s, a.clone());
|
||||
assert!(!all.is_empty(), "{a}");
|
||||
assert_eq!(found(&mut s, b.clone()), all, "{b}");
|
||||
for p in [&a, &b] {
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap();
|
||||
assert_eq!(s.execute("find.next", p).unwrap()["start"].as_u64(), Some(all[0].0), "{p}");
|
||||
}
|
||||
}
|
||||
// A null attribute asks for nothing.
|
||||
assert_eq!(found(&mut s, json!({"find": "eggs", "findFormat": {"charStyle": "eggs", "chars": {"size": null}}})), [(6, 10)]);
|
||||
// With a Change Format: the text in the style goes back to [None], underlined.
|
||||
let r = s
|
||||
.execute(
|
||||
"find.change",
|
||||
&json!({"find": "", "change": "", "findFormat": {"charStyle": "eggs", "chars": {"font_style": "Bold"}}, "charStyle": "[None]", "attrs": {"underline": true}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["count"], 2, "a snake_case attribute changes the same text");
|
||||
assert!(found(&mut s, json!({"find": "", "findFormat": {"charStyle": "eggs"}})).is_empty());
|
||||
assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"chars": {"underline": true}}})), [(6, 10), (12, 16)]);
|
||||
// Unknown styles and attributes are refused; an empty search needs a Find Format.
|
||||
assert!(s.execute("find.find", &json!({"find": "", "findFormat": {"charStyle": "Nope"}})).is_err());
|
||||
assert!(s.execute("find.next", &json!({"find": "eggs", "findFormat": {"paraStyle": "Nope"}})).is_err());
|
||||
assert!(s.execute("find.find", &json!({"find": "eggs", "findFormat": {"chars": {"bogus": 1}}})).is_err());
|
||||
// Malformed fields are refused, not searched without.
|
||||
for f in [json!({"chars": [1]}), json!({"chars": 12}), json!({"charStyle": 3}), json!({"paraStyle": ["Body"]})] {
|
||||
assert!(s.execute("find.find", &json!({"find": "eggs", "findFormat": f})).is_err(), "{f}");
|
||||
}
|
||||
assert!(s.execute("find.find", &json!({"find": ""})).is_err());
|
||||
}
|
||||
|
||||
/// Change and Change/Find: only the text Find Next found changes; it stays selected and the
|
||||
/// search goes on after it in its domain (here the original selection). A selection that
|
||||
/// isn't a match changes nothing and makes no undo step.
|
||||
#[test]
|
||||
fn change_changes_the_found_text_and_the_search_goes_on() {
|
||||
let (mut s, sid) = scope_session("red red | red");
|
||||
scope_select(&mut s, sid, 0, 7);
|
||||
let p = json!({"find": "red", "change": "blue", "scope": "selection"});
|
||||
let mut change = p.clone();
|
||||
change["found"] = json!(true);
|
||||
scope_hit(&mut s, &p, sid, 0, 3);
|
||||
assert_eq!(s.execute("find.change", &change).unwrap()["count"], 1);
|
||||
let text = |s: &Session| s.doc().unwrap().doc.story(sid).unwrap().text.clone();
|
||||
assert_eq!(text(&s), "blue red | red");
|
||||
assert_eq!(s.doc().unwrap().selection.text.unwrap().range(), 0..4, "the changed text stays selected");
|
||||
scope_hit(&mut s, &p, sid, 5, 8);
|
||||
s.execute("find.change", &change).unwrap();
|
||||
assert_eq!(text(&s), "blue blue | red");
|
||||
assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null, "the last red is outside the selection searched");
|
||||
// Not a match: nothing changes, no undo step.
|
||||
scope_select(&mut s, sid, 0, 4);
|
||||
let history = s.doc().unwrap().history.undo.len();
|
||||
assert_eq!(s.execute("find.change", &json!({"find": "red", "change": "blue", "found": true})).unwrap()["count"], 0);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), history);
|
||||
// A Change Format with nothing in Change to formats the found text only.
|
||||
scope_select(&mut s, sid, 0, 0);
|
||||
let fmt = json!({"find": "red", "change": "", "attrs": {"underline": true}});
|
||||
let hit = s.execute("find.next", &fmt).unwrap();
|
||||
assert_eq!(hit["start"], 12);
|
||||
let mut found = fmt.clone();
|
||||
found["found"] = json!(true);
|
||||
s.execute("find.change", &found).unwrap();
|
||||
assert_eq!(text(&s), "blue blue | red");
|
||||
assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().format_after(12).over.underline, Some(true));
|
||||
assert_eq!(s.doc().unwrap().selection.text.unwrap().range(), 12..15);
|
||||
}
|
||||
|
||||
/// Change accepts what Find Next selected even when Change All wouldn't match there: the
|
||||
/// rest of a Find Format stretch from a caret inside it, and an overlapping match.
|
||||
#[test]
|
||||
fn change_takes_any_selection_find_next_made() {
|
||||
// A caret inside a bold stretch: Find Next with only a Find Format selects the rest of it.
|
||||
let (mut s, sid) = scope_session("plain bold text");
|
||||
s.execute("style.character.create", &json!({"name": "Strong", "chars": {"fontStyle": "Bold"}})).unwrap();
|
||||
scope_select(&mut s, sid, 6, 10);
|
||||
s.execute("style.character.apply", &json!({"name": "Strong"})).unwrap();
|
||||
scope_select(&mut s, sid, 8, 8);
|
||||
let p = json!({"find": "", "change": "", "findFormat": {"charStyle": "Strong"}, "attrs": {"underline": true}, "scope": "story"});
|
||||
let hit = s.execute("find.next", &p).unwrap();
|
||||
assert_eq!((hit["start"].as_u64(), hit["end"].as_u64()), (Some(8), Some(10)));
|
||||
let mut change = p.clone();
|
||||
change["found"] = json!(true);
|
||||
assert_eq!(s.execute("find.change", &change).unwrap()["count"], 1);
|
||||
let st = s.doc().unwrap().doc.story(sid).unwrap().clone();
|
||||
assert_eq!((st.format_after(7).over.underline, st.format_after(8).over.underline), (None, Some(true)), "only the selected rest");
|
||||
// "aa" in "aaa" from the second a: Find Next selects 1..3, which Change All never finds.
|
||||
for grep in [false, true] {
|
||||
let (mut s, sid) = scope_session("aaa");
|
||||
scope_select(&mut s, sid, 1, 1);
|
||||
let (find, change) = if grep { ("(a)a", "<$1>") } else { ("aa", "b") };
|
||||
let p = json!({"find": find, "change": change, "grep": grep, "scope": "story"});
|
||||
let hit = s.execute("find.next", &p).unwrap();
|
||||
assert_eq!((hit["start"].as_u64(), hit["end"].as_u64()), (Some(1), Some(3)));
|
||||
let mut found = p.clone();
|
||||
found["found"] = json!(true);
|
||||
assert_eq!(s.execute("find.change", &found).unwrap()["count"], 1, "grep: {grep}");
|
||||
assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, if grep { "a<a>" } else { "ab" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use serde_json::{Value, json};
|
||||
use super::{CommandSpec, bad, cmd, has_doc, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// Register commands for editing hyperlinks and manual bookmarks.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
@@ -73,7 +74,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let name = str_param(p, "name").map(str::to_string);
|
||||
s.edit(|d, _| {
|
||||
let name = name.clone().unwrap_or_else(|| format!("Bookmark {}", d.bookmarks.len() + 1));
|
||||
d.bookmarks.push(Bookmark { name, page, children: vec![] });
|
||||
d.bookmarks.push(Bookmark { name, page, children: vec![], generated_by_toc: false });
|
||||
Ok(json!({"index": d.bookmarks.len() - 1}))
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -117,6 +117,52 @@ mod parent_margin_tests {
|
||||
assert!(s.execute("layout.marginsAndColumns", &json!({"parent": "Z", "margins": 10})).is_err());
|
||||
}
|
||||
|
||||
/// Insert Pages before a page, or at the start of the document (`before: 0`), as InDesign's
|
||||
/// dialog offers; `after` and `before` together are refused without an undo step.
|
||||
#[test]
|
||||
fn pages_go_in_before_a_page_or_at_the_start() {
|
||||
let mut s = Session::new();
|
||||
// Facing: [1] [2 3].
|
||||
s.execute("file.new", &json!({"pages": 3})).unwrap();
|
||||
let b = s.execute("layout.parents.new", &json!({"prefix": "B"})).unwrap()["id"].as_u64().unwrap();
|
||||
let ids = |s: &Session| -> Vec<u64> {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
(0..d.page_count()).map(|i| d.page(i).unwrap().id.0).collect()
|
||||
};
|
||||
let parent = |s: &Session, i: usize| s.doc().unwrap().doc.page(i).unwrap().parent.map(|p| p.0);
|
||||
let old = ids(&s);
|
||||
s.execute("layout.pages.insert", &json!({"before": 0, "parent": "B"})).unwrap();
|
||||
let now = ids(&s);
|
||||
assert_eq!(now.len(), 4);
|
||||
assert_eq!(now[1..], old[..], "the old pages follow the new first page");
|
||||
assert_eq!(parent(&s, 0), Some(b));
|
||||
// Before page 4 (index 3, the old page 3): two pages without a parent.
|
||||
s.execute("layout.pages.insert", &json!({"before": 3, "count": 2, "parent": null})).unwrap();
|
||||
let then = ids(&s);
|
||||
assert_eq!(then.len(), 6);
|
||||
assert_eq!(then[..3], now[..3]);
|
||||
assert_eq!(then[5], old[2]);
|
||||
assert_eq!((parent(&s, 3), parent(&s, 4)), (None, None));
|
||||
// Refused without a change or an undo step: both placements, even one of them null, and a
|
||||
// placement that isn't a page index.
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
for bad in [
|
||||
json!({"after": 0, "before": 1}),
|
||||
json!({"after": 0, "before": null}),
|
||||
json!({"after": null, "before": 1}),
|
||||
json!({"before": -1}),
|
||||
json!({"before": "0"}),
|
||||
json!({"before": null}),
|
||||
json!({"after": 1.5}),
|
||||
json!({"before": 6}),
|
||||
json!({"before": 99}),
|
||||
] {
|
||||
assert!(s.execute("layout.pages.insert", &bad).is_err(), "{bad}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{bad}");
|
||||
assert_eq!(ids(&s), then, "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applying_a_parent_gives_its_margins_and_local_margins_stay_local() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -12,7 +12,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Insert Pages…",
|
||||
["Layout", "Pages"],
|
||||
Some("Cmd+Shift+P"),
|
||||
"{count?: 1 (1 to 9999), after?: 0-based page index (default: the last page), parent?: id|\"A-Parent\"|\"A\"|null}",
|
||||
"{count?: 1 (1 to 9999), after?: 0-based page index (default: the last page; past it: at the end), before?: 0-based page index (instead of `after`; 0: at the start), parent?: id|\"A-Parent\"|\"A\"|null}",
|
||||
has_doc,
|
||||
|s, p| {
|
||||
const ID: &str = "layout.pages.insert";
|
||||
@@ -27,12 +27,30 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
};
|
||||
let st = s.doc()?;
|
||||
let n = st.doc.page_count();
|
||||
// An `after` past the last page appends, which `insert_after_u64_max_appends_at_the_end`
|
||||
// pins deliberately.
|
||||
let after = p.get("after").and_then(Value::as_u64).map(|v| usize::try_from(v).unwrap_or(usize::MAX)).unwrap_or(n.saturating_sub(1));
|
||||
// A placement given (null included) must be a page index; only when neither is
|
||||
// given do the pages go after the last.
|
||||
if p.get("after").is_some() && p.get("before").is_some() {
|
||||
return Err(bad("layout.pages.insert", "give `after` or `before`, not both"));
|
||||
}
|
||||
// `before` names a page of the document; an `after` past the last page appends
|
||||
// (`insert_pages_bounds.rs` pins that).
|
||||
let index = |k: &str| match p.get(k) {
|
||||
None => Ok(None),
|
||||
Some(v) => v
|
||||
.as_u64()
|
||||
.map(|v| usize::try_from(v).unwrap_or(usize::MAX))
|
||||
.filter(|&i| k == "after" || i < n)
|
||||
.map(|i| Some(i.min(n.saturating_sub(1))))
|
||||
.ok_or_else(|| bad(ID, format!("`{k}` must be the index of one of the {n} pages, not {v}"))),
|
||||
};
|
||||
// The page the new ones follow; none: they go first.
|
||||
let after = match (index("after")?, index("before")?) {
|
||||
(_, Some(b)) => b.checked_sub(1),
|
||||
(a, None) => Some(a.unwrap_or(n.saturating_sub(1))),
|
||||
};
|
||||
let parent = parent_ref(ID, &st.doc, p.get("parent"))?.unwrap_or_else(|| st.doc.parents.first().map(|x| x.id));
|
||||
s.edit(|d, _| {
|
||||
let ids = d.insert_pages(Some(after), count, parent)?;
|
||||
let ids = d.insert_pages(after, count, parent)?;
|
||||
Ok(json!({"pages": ids.len(), "total": d.page_count()}))
|
||||
})
|
||||
}
|
||||
|
||||
+290
-54
@@ -382,9 +382,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Drop Shadow",
|
||||
["Object", "Effects"],
|
||||
Some("Cmd+Alt+M"),
|
||||
"{on?: bool, distance?, angle?, globalLight?: bool, opacity?, size?, spread? (%), color? (swatch), ids?}",
|
||||
"{on?: bool, distance?, angle?, globalLight?: bool, opacity?, size?, spread? (%), color? (swatch), mode?: blend mode (multiply…), ids?}",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let mode = blend_param(p, "object.dropShadow")?;
|
||||
let p2 = p.clone();
|
||||
set_effects(s, p, "object.dropShadow", move |fx| {
|
||||
let ds = &mut fx.drop_shadow;
|
||||
@@ -398,6 +399,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
if let Some(c) = str_param(&p2, "color") {
|
||||
ds.color = c.into();
|
||||
}
|
||||
if let Some(m) = mode {
|
||||
ds.mode = m;
|
||||
}
|
||||
})
|
||||
}
|
||||
),
|
||||
@@ -406,9 +410,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Inner Shadow",
|
||||
["Object", "Effects"],
|
||||
None,
|
||||
"{on?: bool, distance?, angle?, globalLight?: bool, opacity?, size?, choke? (%), color?, ids?}",
|
||||
"{on?: bool, distance?, angle?, globalLight?: bool, opacity?, size?, choke? (%), color?, mode?: blend mode, ids?}",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let mode = blend_param(p, "object.innerShadow")?;
|
||||
let p2 = p.clone();
|
||||
set_effects(s, p, "object.innerShadow", move |fx| {
|
||||
let e = &mut fx.inner_shadow;
|
||||
@@ -422,6 +427,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
if let Some(c) = str_param(&p2, "color") {
|
||||
e.color = c.into();
|
||||
}
|
||||
if let Some(m) = mode {
|
||||
e.mode = m;
|
||||
}
|
||||
})
|
||||
}
|
||||
),
|
||||
@@ -430,9 +438,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Outer Glow",
|
||||
["Object", "Effects"],
|
||||
None,
|
||||
"{on?: bool, opacity?, size?, spread? (%), color?, ids?}",
|
||||
"{on?: bool, opacity?, size?, spread? (%), color?, mode?: blend mode (screen…), ids?}",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let mode = blend_param(p, "object.outerGlow")?;
|
||||
let p2 = p.clone();
|
||||
set_effects(s, p, "object.outerGlow", move |fx| {
|
||||
let e = &mut fx.outer_glow;
|
||||
@@ -443,6 +452,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
if let Some(c) = str_param(&p2, "color") {
|
||||
e.color = c.into();
|
||||
}
|
||||
if let Some(m) = mode {
|
||||
e.mode = m;
|
||||
}
|
||||
})
|
||||
}
|
||||
),
|
||||
@@ -598,7 +610,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Text Frame Options…",
|
||||
["Object"],
|
||||
Some("Cmd+B"),
|
||||
"{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, columnsKind?: fixedNumber|fixedWidth, columnWidth? (pt, Fixed Width), firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin? (pt), autoSize?: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference?: topLeft|topCenter|topRight|leftCenter|center|rightCenter|bottomLeft|bottomCenter|bottomRight (the point that stays put), useMinimumHeight?: bool, minimumHeight? (pt), useMinimumWidth?: bool, minimumWidth? (pt), noLineBreaks?: bool, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), baselineGrid?: {start? (pt), increment? (pt), relativeTo?: page|margin|frame|inset} (the frame's custom baseline grid; fields not given keep the frame's, or come from the document grid; null turns it off), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}",
|
||||
"{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, paragraphSpacingLimit? (pt: Justify adds at most this between paragraphs, the rest between lines), columnsKind?: fixedNumber|fixedWidth, columnWidth? (pt, Fixed Width), firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin? (pt), autoSize?: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference?: topLeft|topCenter|topRight|leftCenter|center|rightCenter|bottomLeft|bottomCenter|bottomRight (the point that stays put), useMinimumHeight?: bool, minimumHeight? (pt), useMinimumWidth?: bool, minimumWidth? (pt), noLineBreaks?: bool, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), baselineGrid?: {start? (pt), increment? (pt), relativeTo?: page|margin|frame|inset} (the frame's custom baseline grid; fields not given keep the frame's, or come from the document grid; null turns it off), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}",
|
||||
has_selection,
|
||||
text_frame_options
|
||||
),
|
||||
@@ -607,7 +619,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Corner Options…",
|
||||
["Object"],
|
||||
None,
|
||||
"{shape: none|rounded|inverseRounded|inset|bevel|fancy, size, corners?: [{shape, size}] (4 entries in path order: top-left, top-right, bottom-right, bottom-left; replaces shape and size), ids?}",
|
||||
"{shape: none|rounded|inverseRounded|inset|bevel|fancy, size, corners?: [{shape, size} | null] (4 entries in path order: top-left, top-right, bottom-right, bottom-left; replaces shape and size; null keeps that corner of each object), ids?}",
|
||||
has_selection,
|
||||
corner_options
|
||||
),
|
||||
@@ -616,7 +628,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Text Wrap",
|
||||
["Window"],
|
||||
Some("Cmd+Alt+W"),
|
||||
"{mode?: none|boundingBox|contour|jumpObject|jumpToNextColumn (boundingBox when absent, unless side or contour is given: then unchanged), offset?: number|[t,l,b,r], invert?: bool, side?: bothSides|leftSide|rightSide|towardsSpine|awayFromSpine|largestArea, contour?: boundingBox|graphicFrame|sameAsClipping, content?: bool (the placed graphic instead of its frame; default: whether the selection is the frame's content), ids?}; offset or contour drops an imported custom wrap boundary",
|
||||
"{mode?: none|boundingBox|contour|jumpObject|jumpToNextColumn (boundingBox when absent, unless side or contour is given; null or those: each object's own, unchanged), offset?: number|[t,l,b,r] (a null side keeps each object's own), invert?: bool, side?: bothSides|leftSide|rightSide|towardsSpine|awayFromSpine|largestArea, contour?: boundingBox|graphicFrame|sameAsClipping, content?: bool (the placed graphic instead of its frame; default: whether the selection is the frame's content), ids?}; offset or contour drops an imported custom wrap boundary",
|
||||
has_selection,
|
||||
text_wrap
|
||||
),
|
||||
@@ -638,6 +650,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection,
|
||||
fitting_options
|
||||
),
|
||||
cmd!(
|
||||
"object.clearFittingOptions",
|
||||
"Clear Frame Fitting Options",
|
||||
["Object", "Fitting"],
|
||||
None,
|
||||
"{ids?} — the frames' fitting options back to the defaults: no auto-fit, no crop amounts, the centre reference point; the content stays where it is",
|
||||
has_selection,
|
||||
clear_fitting_options
|
||||
),
|
||||
cmd!("object.rename", "Rename", [], None, "{id | ids?, name}", has_doc, |s, p| {
|
||||
let name = str_param(p, "name").unwrap_or("").to_string();
|
||||
set_flag(s, p, move |i| i.name = name.clone(), false)
|
||||
@@ -680,7 +701,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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!(
|
||||
"path.moveAnchors",
|
||||
"Move Anchors",
|
||||
[],
|
||||
None,
|
||||
"{id, anchors: [[subpath, index]], dx, dy, handle?: in|out} | {points: [[id, subpath, index, dx?, dy?]], dx?, dy?} (anchors of several paths, as Direct Selection drags them: one step; a point's own dx, dy win over the shared ones) — spread space",
|
||||
has_doc,
|
||||
path_move_anchors
|
||||
),
|
||||
cmd!(
|
||||
"object.align",
|
||||
"Align",
|
||||
@@ -2101,13 +2130,18 @@ fn text_wrap(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => None,
|
||||
};
|
||||
let mode: Option<WrapMode> = match p.get("mode") {
|
||||
// Null keeps each object's own mode (an edit of the offsets or Invert alone).
|
||||
Some(Value::Null) => None,
|
||||
Some(v) => Some(serde_json::from_value(v.clone()).map_err(|_| bad("object.textWrap", "unknown mode"))?),
|
||||
None if side.is_some() || contour.is_some() => None,
|
||||
None => Some(WrapMode::BoundingBox),
|
||||
};
|
||||
let off = match p.get("offset") {
|
||||
Some(Value::Number(n)) => Some([n.as_f64().unwrap_or(0.0); 4]),
|
||||
Some(v) => serde_json::from_value(v.clone()).ok(),
|
||||
// Per side [top, left, bottom, right]; a null side keeps each object's own offset there, so
|
||||
// an edit of one side keeps the others of every object.
|
||||
// An array with no number in it changes no offset (and so keeps a custom boundary).
|
||||
let off: Option<[Option<f64>; 4]> = match p.get("offset") {
|
||||
Some(Value::Number(n)) => Some([n.as_f64(); 4]),
|
||||
Some(v) => serde_json::from_value::<[Option<f64>; 4]>(v.clone()).ok().filter(|o| o.iter().any(Option::is_some)),
|
||||
None => None,
|
||||
};
|
||||
let invert = p.get("invert").and_then(Value::as_bool);
|
||||
@@ -2127,7 +2161,11 @@ fn text_wrap(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
w.mode = m;
|
||||
}
|
||||
if let Some(o) = off {
|
||||
w.offsets = o;
|
||||
for (w, v) in w.offsets.iter_mut().zip(o) {
|
||||
if let Some(v) = v {
|
||||
*w = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(v) = invert {
|
||||
w.invert = v;
|
||||
@@ -2152,19 +2190,31 @@ fn corner_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use designcraft_geom::corners::{Corner, CornerOptions, CornerShape};
|
||||
let opts = match p.get("corners") {
|
||||
Some(v) => {
|
||||
let corners: [Corner; 4] =
|
||||
serde_json::from_value(v.clone()).map_err(|e| bad("object.cornerOptions", format!("corners: four {{shape, size}} entries ({e})")))?;
|
||||
if corners.iter().any(|c| !c.size.is_finite() || c.size < 0.0) {
|
||||
let corners: [Option<Corner>; 4] = serde_json::from_value(v.clone())
|
||||
.map_err(|e| bad("object.cornerOptions", format!("corners: four {{shape, size}} or null entries ({e})")))?;
|
||||
if corners.iter().flatten().any(|c| !c.size.is_finite() || c.size < 0.0) {
|
||||
return Err(bad("object.cornerOptions", "corner sizes must be finite and not negative"));
|
||||
}
|
||||
CornerOptions { corners }
|
||||
corners
|
||||
}
|
||||
None => {
|
||||
let shape = p.get("shape").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or(CornerShape::Rounded);
|
||||
CornerOptions::uniform(shape, f64_or(p, "size", 12.0))
|
||||
CornerOptions::uniform(shape, f64_or(p, "size", 12.0)).corners.map(Some)
|
||||
}
|
||||
};
|
||||
set_flag(s, p, move |i| i.corners = opts, false)
|
||||
// A null corner keeps each object's own.
|
||||
set_flag(
|
||||
s,
|
||||
p,
|
||||
move |i| {
|
||||
for (c, new) in i.corners.corners.iter_mut().zip(opts) {
|
||||
if let Some(new) = new {
|
||||
*c = new;
|
||||
}
|
||||
}
|
||||
},
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
/// `object.matchAttributes`: the selected objects (or `ids`) take `from`'s appearance.
|
||||
@@ -2311,6 +2361,15 @@ fn effects_all(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
if let Some(patch) = &patch {
|
||||
let mut cur = serde_json::to_value(&it.effects).map_err(|e| bad(ID, e.to_string()))?;
|
||||
// Default effects are left out of the serialized form; a patch without `mode` would
|
||||
// then read the legacy Normal instead of the effect's current (Multiply, Screen) mode.
|
||||
for (key, now) in
|
||||
[("innerShadow", serde_json::to_value(&it.effects.inner_shadow)), ("outerGlow", serde_json::to_value(&it.effects.outer_glow))]
|
||||
{
|
||||
if let (Some(o), Ok(now)) = (cur.as_object_mut(), now) {
|
||||
o.entry(key).or_insert(now);
|
||||
}
|
||||
}
|
||||
merge_json(&mut cur, patch);
|
||||
let mut fx: designcraft_doc::Effects = serde_json::from_value(cur).map_err(|e| bad(ID, format!("effects: {e}")))?;
|
||||
sanitize_effects(&mut fx)?;
|
||||
@@ -2505,6 +2564,7 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let min_width = ranged("minimumWidth", 0.0, crate::autosize::MAX_SIDE, "a number of points")?;
|
||||
let column_width = ranged("columnWidth", 0.0, 15552.0, "a number of points")?;
|
||||
let first_baseline_min = ranged("firstBaselineMin", 0.0, 15552.0, "a number of points")?;
|
||||
let spacing_limit = ranged("paragraphSpacingLimit", 0.0, 15552.0, "a number of points")?;
|
||||
let reference = match p.get("autoSizeReference") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(
|
||||
@@ -2591,6 +2651,7 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = reference {
|
||||
o.auto_size_ref = v;
|
||||
}
|
||||
o.vj_paragraph_spacing_limit = spacing_limit.unwrap_or(o.vj_paragraph_spacing_limit);
|
||||
if let Some(v) = p.get("useMinimumHeight").and_then(Value::as_bool) {
|
||||
o.auto_size_use_min_height = v;
|
||||
}
|
||||
@@ -2938,7 +2999,9 @@ fn fitting_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else if fitting.is_some() && g.auto_fit != designcraft_doc::Fitting::None {
|
||||
g.auto_fit = mode;
|
||||
}
|
||||
if let Some(xf) = g.fitted(r, mode) {
|
||||
// Turning Auto-Fit off alone leaves the picture where the user put it.
|
||||
let turn_off_only = auto == Some(false) && fitting.is_none() && align.is_none() && crop.is_none();
|
||||
if !turn_off_only && let Some(xf) = g.fitted(r, mode) {
|
||||
g.xf = xf;
|
||||
}
|
||||
n += 1;
|
||||
@@ -2947,6 +3010,35 @@ fn fitting_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Clear Frame Fitting Options: auto-fit off, no crop, the centre reference point. The content
|
||||
/// stays where it is, as in InDesign.
|
||||
fn clear_fitting_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Locked frames (or frames on a locked layer) keep their options, as with Fit; frames whose
|
||||
// options are already clear are left out; with nothing to change, no edit (and no empty undo
|
||||
// step).
|
||||
let doc = &s.doc()?.doc;
|
||||
let set = |g: &designcraft_doc::Graphic| g.auto_fit != designcraft_doc::Fitting::None || g.crop != [0.0; 4] || g.fit_align != 4;
|
||||
let ids: Vec<ItemId> = targets(s, p)?
|
||||
.into_iter()
|
||||
.filter(|id| doc.item(*id).is_some_and(|it| unlocked(doc, it) && matches!(&it.content, Content::Graphic(g) if set(g))))
|
||||
.collect();
|
||||
if ids.is_empty() {
|
||||
return Ok(json!({"changed": 0}));
|
||||
}
|
||||
s.edit(|d, _| {
|
||||
let mut n = 0;
|
||||
for id in &ids {
|
||||
let Some(it) = d.item_mut(*id) else { continue };
|
||||
let Content::Graphic(g) = &mut it.content else { continue };
|
||||
g.auto_fit = designcraft_doc::Fitting::None;
|
||||
g.crop = [0.0; 4];
|
||||
g.fit_align = 4;
|
||||
n += 1;
|
||||
}
|
||||
Ok(json!({"changed": n}))
|
||||
})
|
||||
}
|
||||
|
||||
fn fit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let doc = &s.doc()?.doc;
|
||||
let ids: Vec<_> =
|
||||
@@ -3174,6 +3266,20 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// An effect's `mode` parameter: a blend mode by name ("multiply", "screen", or as saved).
|
||||
/// An effect's `mode`: `None` when omitted or null, an error when it names no blend mode.
|
||||
fn blend_param(p: &Value, cmd: &str) -> Result<Option<BlendMode>> {
|
||||
match p.get("mode") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v
|
||||
.as_str()
|
||||
.and_then(BlendMode::parse)
|
||||
.or_else(|| serde_json::from_value(v.clone()).ok())
|
||||
.map(Some)
|
||||
.ok_or_else(|| bad(cmd, format!("unknown blend mode {v}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
if ids.len() < 3 && p.get("spacing").is_none() {
|
||||
@@ -3266,29 +3372,36 @@ 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);
|
||||
// An omitted subpath is the last one; one given must name a subpath (never falls back).
|
||||
let subpath = match p.get("subpath") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(v.as_u64().ok_or_else(|| bad("path.appendAnchor", "`subpath` must be a subpath index"))?),
|
||||
};
|
||||
let at_start = match p.get("at") {
|
||||
None => false,
|
||||
Some(v) if v.as_str() == Some("end") => false,
|
||||
Some(v) if v.as_str() == Some("start") => true,
|
||||
Some(_) => return Err(bad("path.appendAnchor", "`at` must be `start` or `end`")),
|
||||
};
|
||||
s.edit(|d, _| {
|
||||
s.edit(|d, sel| {
|
||||
path_editable(d, id, "path.appendAnchor")?;
|
||||
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 = 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"))?,
|
||||
let si = match subpath {
|
||||
Some(i) => usize::try_from(i).ok().filter(|i| *i < it.path.subpaths.len()).ok_or_else(|| bad("path.appendAnchor", "no such subpath"))?,
|
||||
None => it.path.subpaths.len().checked_sub(1).ok_or_else(|| bad("path.appendAnchor", "empty path"))?,
|
||||
};
|
||||
let sp = &mut it.path.subpaths[si];
|
||||
if sp.closed {
|
||||
return Err(bad("path.appendAnchor", "the subpath is closed"));
|
||||
}
|
||||
if at_start {
|
||||
sp.anchors.insert(0, a.transform(inv));
|
||||
// The selected points of this sub-path move up one place with the others.
|
||||
for a in sel.anchors.iter_mut().filter(|a| a.0 == id && a.1 == si) {
|
||||
a.2 += 1;
|
||||
}
|
||||
} else {
|
||||
sp.anchors.push(a.transform(inv));
|
||||
}
|
||||
@@ -3367,7 +3480,7 @@ fn path_reverse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
path_editable(doc, *id, "path.reverse")?;
|
||||
}
|
||||
s.edit(|d, _| {
|
||||
s.edit(|d, sel| {
|
||||
let mut reversed = 0;
|
||||
for id in &editable {
|
||||
let Some(it) = d.item_mut(*id) else { continue };
|
||||
@@ -3378,6 +3491,11 @@ fn path_reverse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if only.is_some_and(|o| o != i) {
|
||||
continue;
|
||||
}
|
||||
// Selected points keep pointing at the same points, now counted from the other end.
|
||||
let n = sp.anchors.len();
|
||||
for a in sel.anchors.iter_mut().filter(|a| a.0 == *id && a.1 == i && a.2 < n) {
|
||||
a.2 = n - 1 - a.2;
|
||||
}
|
||||
sp.anchors.reverse();
|
||||
for a in &mut sp.anchors {
|
||||
std::mem::swap(&mut a.h_in, &mut a.h_out);
|
||||
@@ -3411,38 +3529,75 @@ fn path_close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn path_move_anchors(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = super::id_param(p, "id").ok_or_else(|| bad("path.moveAnchors", "missing id"))?;
|
||||
let which: Vec<(usize, usize)> = p.get("anchors").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
|
||||
// The anchors to move, by path: `points` (several paths) or `id` with `anchors`.
|
||||
// Each anchor with its own spread-space movement, when the points give one.
|
||||
let mut targets: Vec<(ItemId, Vec<(usize, usize, Option<Vec2>)>)> = Vec::new();
|
||||
// A `points` that isn't a list is an error, never the `id` form (which could move another path).
|
||||
let points = match p.get("points") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::Array(a)) => Some(a),
|
||||
Some(_) => return Err(bad("path.moveAnchors", "`points` must be a list of [id, subpath, index, dx?, dy?]")),
|
||||
};
|
||||
if let Some(points) = points {
|
||||
for v in points {
|
||||
let n = |i: usize| v.get(i).and_then(Value::as_u64).and_then(|x| usize::try_from(x).ok());
|
||||
let (Some(id), Some(si), Some(ai)) = (v.get(0).and_then(Value::as_u64), n(1), n(2)) else {
|
||||
return Err(bad("path.moveAnchors", "each point is [id, subpath, index] or [id, subpath, index, dx, dy]"));
|
||||
};
|
||||
let own = match (v.get(3), v.get(4)) {
|
||||
(None, None) => None,
|
||||
(Some(x), Some(y)) => match (x.as_f64(), y.as_f64()) {
|
||||
(Some(x), Some(y)) if x.is_finite() && y.is_finite() => Some(Vec2::new(x, y)),
|
||||
_ => return Err(bad("path.moveAnchors", "a point's dx and dy are finite numbers")),
|
||||
},
|
||||
_ => return Err(bad("path.moveAnchors", "a point gives both dx and dy, or neither")),
|
||||
};
|
||||
match targets.iter_mut().find(|(i, _)| i.0 == id) {
|
||||
Some((_, which)) => which.push((si, ai, own)),
|
||||
None => targets.push((ItemId(id), vec![(si, ai, own)])),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let id = super::id_param(p, "id").ok_or_else(|| bad("path.moveAnchors", "missing id"))?;
|
||||
let which: Vec<(usize, usize)> = p.get("anchors").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
|
||||
targets.push((id, which.into_iter().map(|(s, a)| (s, a, None)).collect()));
|
||||
}
|
||||
let d0 = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
|
||||
let handle = str_param(p, "handle").map(str::to_string);
|
||||
s.edit(|d, _| {
|
||||
path_editable(d, id, "path.moveAnchors")?;
|
||||
let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
let m = d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default();
|
||||
// Spread-space delta → inner-space delta (linear part only).
|
||||
let inv = m.inverse();
|
||||
let dv = (inv * Point::new(d0.x, d0.y)) - (inv * Point::ZERO);
|
||||
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
for (si, ai) in &which {
|
||||
let Some(a) = it.path.anchor_mut(*si, *ai) else { continue };
|
||||
match handle.as_deref() {
|
||||
Some("in") => {
|
||||
a.h_in += dv;
|
||||
if a.kind == designcraft_geom::AnchorKind::Smooth {
|
||||
a.h_out = a.p - (a.h_in - a.p);
|
||||
}
|
||||
}
|
||||
Some("out") => {
|
||||
a.h_out += dv;
|
||||
if a.kind == designcraft_geom::AnchorKind::Smooth {
|
||||
a.h_in = a.p - (a.h_out - a.p);
|
||||
}
|
||||
}
|
||||
_ => a.translate(dv),
|
||||
}
|
||||
for (id, _) in &targets {
|
||||
path_editable(d, *id, "path.moveAnchors")?;
|
||||
}
|
||||
if it.shape == Shape::Rectangle || it.shape == Shape::Oval || it.shape == Shape::Polygon {
|
||||
it.shape = Shape::Path;
|
||||
for (id, which) in &targets {
|
||||
let id = *id;
|
||||
let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
let m = d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default();
|
||||
// Spread-space delta → inner-space delta (linear part only).
|
||||
let inv = m.inverse();
|
||||
let inner = |d: Vec2| (inv * Point::new(d.x, d.y)) - (inv * Point::ZERO);
|
||||
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
for (si, ai, own) in which {
|
||||
let dv = inner(own.unwrap_or(d0));
|
||||
let Some(a) = it.path.anchor_mut(*si, *ai) else { continue };
|
||||
match handle.as_deref() {
|
||||
Some("in") => {
|
||||
a.h_in += dv;
|
||||
if a.kind == designcraft_geom::AnchorKind::Smooth {
|
||||
a.h_out = a.p - (a.h_in - a.p);
|
||||
}
|
||||
}
|
||||
Some("out") => {
|
||||
a.h_out += dv;
|
||||
if a.kind == designcraft_geom::AnchorKind::Smooth {
|
||||
a.h_in = a.p - (a.h_out - a.p);
|
||||
}
|
||||
}
|
||||
_ => a.translate(dv),
|
||||
}
|
||||
}
|
||||
if it.shape == Shape::Rectangle || it.shape == Shape::Oval || it.shape == Shape::Polygon {
|
||||
it.shape = Shape::Path;
|
||||
}
|
||||
}
|
||||
ok()
|
||||
})
|
||||
@@ -4143,6 +4298,31 @@ mod label_tests {
|
||||
mod light_tests {
|
||||
use super::*;
|
||||
|
||||
/// The shadow and glow commands take a blend mode by name.
|
||||
#[test]
|
||||
fn shadow_and_glow_commands_take_a_blend_mode() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = s.execute("frame.create", &json!({"rect": [72, 72, 172, 172]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("object.dropShadow", &json!({"on": true, "mode": "screen", "ids": [id]})).unwrap();
|
||||
s.execute("object.innerShadow", &json!({"on": true, "mode": "overlay", "ids": [id]})).unwrap();
|
||||
s.execute("object.outerGlow", &json!({"on": true, "mode": "normal", "ids": [id]})).unwrap();
|
||||
let fx = s.doc().unwrap().doc.item(ItemId(id)).unwrap().effects.clone();
|
||||
assert_eq!(fx.drop_shadow.mode, BlendMode::Screen);
|
||||
assert_eq!(fx.inner_shadow.mode, BlendMode::Overlay);
|
||||
assert_eq!(fx.outer_glow.mode, BlendMode::Normal);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
for cmd in ["object.dropShadow", "object.innerShadow", "object.outerGlow"] {
|
||||
for mode in [json!("multply"), json!(3)] {
|
||||
let e = s.execute(cmd, &json!({"on": true, "mode": mode, "ids": [id]})).unwrap_err();
|
||||
assert!(e.to_string().contains("unknown blend mode"), "{cmd}: {e}");
|
||||
}
|
||||
}
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc), "nothing changed");
|
||||
s.execute("object.dropShadow", &json!({"mode": null, "ids": [id]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.item(ItemId(id)).unwrap().effects.drop_shadow.mode, BlendMode::Screen, "null keeps the mode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_light_moves_shadows_that_use_it() {
|
||||
let mut s = Session::new();
|
||||
@@ -4352,6 +4532,60 @@ mod fitting_tests {
|
||||
let g = back.spreads.iter().flat_map(|sp| sp.items.iter()).find_map(|i| i.graphic().cloned()).unwrap();
|
||||
assert_eq!(g.auto_fit, designcraft_doc::Fitting::FillProportionally);
|
||||
}
|
||||
|
||||
/// Turning Auto-Fit off by itself keeps a moved picture where it is.
|
||||
#[test]
|
||||
fn auto_fit_off_alone_keeps_the_picture_position() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let f = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300]})).unwrap()["id"].as_u64().unwrap();
|
||||
let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png();
|
||||
s.execute("file.place", &json!({"base64": crate::cmd::base64_encode(&png), "name": "p.png", "frame": f})).unwrap();
|
||||
s.execute("object.fittingOptions", &json!({"ids": [f], "autoFit": true, "fitting": "fitProportionally"})).unwrap();
|
||||
let xf = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().graphic().unwrap().xf;
|
||||
s.edit(|d, _| {
|
||||
if let Some(Content::Graphic(g)) = d.item_mut(ItemId(f)).map(|it| &mut it.content) {
|
||||
g.xf = designcraft_geom::Affine::translate((7.0, 9.0)) * g.xf;
|
||||
}
|
||||
Ok(Value::Null)
|
||||
})
|
||||
.unwrap();
|
||||
let moved = xf(&s);
|
||||
s.execute("object.fittingOptions", &json!({"ids": [f], "autoFit": false})).unwrap();
|
||||
assert_eq!(xf(&s), moved);
|
||||
}
|
||||
|
||||
/// Clear Frame Fitting Options puts auto-fit, crop and the reference point back to their
|
||||
/// defaults and leaves the picture where it is.
|
||||
#[test]
|
||||
fn clear_fitting_options_resets_the_frames_options_and_keeps_the_picture() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let f = s.execute("frame.create", &json!({"rect": [72, 72, 272, 172]})).unwrap()["id"].as_u64().unwrap();
|
||||
let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png();
|
||||
s.execute("file.place", &json!({"base64": crate::cmd::base64_encode(&png), "name": "p.png", "frame": f})).unwrap();
|
||||
s.execute("object.fittingOptions", &json!({"ids": [f], "autoFit": true, "fitting": "fitProportionally", "crop": 10, "align": 0})).unwrap();
|
||||
let graphic = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().graphic().unwrap().clone();
|
||||
let before = graphic(&s);
|
||||
assert_ne!(before.auto_fit, designcraft_doc::Fitting::None);
|
||||
s.execute("object.clearFittingOptions", &json!({"ids": [f]})).unwrap();
|
||||
let g = graphic(&s);
|
||||
assert_eq!(g.auto_fit, designcraft_doc::Fitting::None);
|
||||
assert_eq!(g.crop, [0.0; 4]);
|
||||
assert_eq!(g.fit_align, 4, "the centre reference point");
|
||||
assert_eq!(g.xf, before.xf, "the picture stays where it is");
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let r = s.execute("object.clearFittingOptions", &json!({"ids": [f]})).unwrap();
|
||||
assert_eq!((r["changed"].as_u64(), s.doc().unwrap().history.undo.len()), (Some(0), undo), "already clear: no change, no step");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(graphic(&s).crop, [10.0; 4], "one undo step");
|
||||
s.execute("object.lock", &json!({"ids": [f]})).unwrap();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let r = s.execute("object.clearFittingOptions", &json!({"ids": [f]})).unwrap();
|
||||
assert_eq!(r["changed"], 0);
|
||||
assert_eq!(graphic(&s).crop, [10.0; 4], "a locked frame keeps its options");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no empty undo step");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -5538,12 +5772,14 @@ mod effects_command_tests {
|
||||
let it = st.doc.item(ItemId(id)).unwrap();
|
||||
let e = &it.effects;
|
||||
assert!(e.inner_shadow.on && e.inner_shadow.angle == 120.0 && e.inner_shadow.size == 7.0);
|
||||
assert_eq!(e.inner_shadow.mode, designcraft_color::BlendMode::Multiply, "a patch without a mode keeps the default");
|
||||
assert!(e.drop_shadow.on && e.drop_shadow.distance == 10.0 && e.drop_shadow.size == 9.0, "untouched fields stay");
|
||||
assert_eq!((e.feather, it.opacity, it.blend), (4.0, 0.5, designcraft_color::BlendMode::Multiply));
|
||||
// Out of range values are kept in range; garbage changes nothing.
|
||||
s.execute("object.effects", &json!({"ids": [id], "effects": {"outerGlow": {"on": true, "opacity": 3, "spread": 400, "size": -5}}})).unwrap();
|
||||
let og = s.doc().unwrap().doc.item(ItemId(id)).unwrap().effects.outer_glow.clone();
|
||||
assert_eq!((og.opacity, og.spread, og.size), (1.0, 100.0, 0.0));
|
||||
assert_eq!(og.mode, designcraft_color::BlendMode::Screen);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
for bad in [json!({"effects": {"dropShadow": {"distance": "far"}}}), json!({"effects": 5}), json!({"blend": "loud"}), json!({"opacity": "x"})]
|
||||
{
|
||||
|
||||
@@ -203,7 +203,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Package…",
|
||||
["File"],
|
||||
None,
|
||||
"{dir: folder to create, idml?: true, pdf?: false, instructions?: text} — the document (its placed files relinked to Links/), Links/, the font files that draw its text in Document Fonts/ (fallback fonts included; unless their licence restricts it), an IDML copy (linking the files in Links/, nothing embedded), an optional PDF and a report → {dir, files, report}",
|
||||
"{dir: folder to create, idml?: true, pdf?: false|true|{…options of file.exportPdf}, instructions?: text} — the document (its placed files relinked to Links/), Links/, the font files that draw its text in Document Fonts/ (fallback fonts included; unless their licence restricts it), an IDML copy (linking the files in Links/, nothing embedded), an optional PDF and a report → {dir, files, report}",
|
||||
has_doc,
|
||||
package
|
||||
),
|
||||
@@ -390,6 +390,17 @@ 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()?;
|
||||
// The PDF: with the default settings, or with the options of a PDF preset, made as
|
||||
// file.exportPdf makes it. Read first, so that bad options write nothing.
|
||||
let pdf = match p.get("pdf") {
|
||||
None | Some(Value::Null) | Some(Value::Bool(false)) => None,
|
||||
Some(Value::Bool(true)) => Some(json!({})),
|
||||
Some(o @ Value::Object(_)) => {
|
||||
super::export::check_pdf_options(o, st.doc.page_count())?;
|
||||
Some(o.clone())
|
||||
}
|
||||
Some(v) => return Err(bad("file.package", format!("`pdf` is true, false or PDF export options, not {v}"))),
|
||||
};
|
||||
let root = OutputRoot::new(dir.clone())?;
|
||||
let display_title = st.doc.title.clone();
|
||||
let title = safe_component(&display_title, "Untitled");
|
||||
@@ -441,18 +452,18 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
idml_layer_note = "\nPlaced PDF layers\nThe IDML copy links the placed PDFs, so it shows their file defaults. The DesignCraft document keeps each placement's Object Layer Options choice.\n";
|
||||
}
|
||||
}
|
||||
if p.get("pdf").and_then(Value::as_bool).unwrap_or(false) {
|
||||
if let Some(params) = pdf {
|
||||
let path = package_output_path(&dir, &title, "pdf")?;
|
||||
let bytes = designcraft_pdf::export_pdf(&super::interchange::with_pdf_boxes(&packed), &s.cache, &Default::default())
|
||||
.map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
root.write(&path, &bytes)?;
|
||||
root.write(&path, &super::export::pdf_report(&packed, &s.cache, ¶ms)?.bytes)?;
|
||||
files.push(path);
|
||||
}
|
||||
// The report: fonts (missing first), links, preflight, the user's instructions.
|
||||
let mut r = format!("Package report: {display_title}\n\n");
|
||||
// The printing instructions first, as the form gave them; then the report: fonts (missing
|
||||
// first), links, preflight.
|
||||
let mut r = String::new();
|
||||
if let Some(t) = str_param(p, "instructions").filter(|t| !t.trim().is_empty()) {
|
||||
r += &format!("Instructions\n{t}\n\n");
|
||||
}
|
||||
r += &format!("Package report: {display_title}\n\n");
|
||||
r += "Fonts\n";
|
||||
r += &font_lines.concat();
|
||||
if !fallback_lines.is_empty() {
|
||||
|
||||
@@ -25,6 +25,58 @@ fn relocated_packages_rebind_embedded_image_links_on_open() {
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// `pdf` takes file.exportPdf's options (a PDF preset's): a PDF/X-4 package PDF; `true` is the
|
||||
/// default PDF, and bad options are refused.
|
||||
#[test]
|
||||
fn package_pdf_takes_export_options() {
|
||||
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("dc-package-pdf-options-{}-{nonce}", std::process::id()));
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"title": "Options", "width": 72, "height": 72})).unwrap();
|
||||
let x4 = dir.join("x4");
|
||||
s.execute("file.package", &json!({"dir": x4.to_string_lossy(), "idml": false, "pdf": {"standard": "x4"}})).unwrap();
|
||||
let has = |path: &std::path::Path, what: &[u8]| std::fs::read(path).unwrap().windows(what.len()).any(|w| w == what);
|
||||
assert!(has(&x4.join("Options.pdf"), b"GTS_PDFX"));
|
||||
let plain = dir.join("plain");
|
||||
s.execute("file.package", &json!({"dir": plain.to_string_lossy(), "idml": false, "pdf": true})).unwrap();
|
||||
assert!(!has(&plain.join("Options.pdf"), b"GTS_PDFX"));
|
||||
// Refused before anything is written: bad options, a bad flattener or view option, and a
|
||||
// `pdf` that is neither a switch nor options.
|
||||
for bad in [
|
||||
json!({"standard": "nope"}),
|
||||
json!({"flatten": "extreme"}),
|
||||
json!({"pageLayout": "spiral"}),
|
||||
json!({"view": "zoomed"}),
|
||||
json!("print"),
|
||||
json!(3),
|
||||
] {
|
||||
let out = dir.join("bad");
|
||||
assert!(s.execute("file.package", &json!({"dir": out.to_string_lossy(), "pdf": bad})).is_err(), "{bad}");
|
||||
assert!(!out.exists(), "{bad}: bad options write nothing");
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// A preset that flattens transparency (Screen and Email's `flatten: low`) gives the packaged
|
||||
/// PDF the flattening `file.exportPdf` does.
|
||||
#[test]
|
||||
fn the_packaged_pdf_is_flattened_as_export_flattens_it() {
|
||||
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("dc-package-pdf-flatten-{}-{nonce}", std::process::id()));
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"title": "Flat", "width": 144, "height": 144})).unwrap();
|
||||
let id = s.execute("frame.create", &json!({"rect": [10, 10, 100, 100], "content": "unassigned"})).unwrap()["id"].clone();
|
||||
s.execute("object.fill", &json!({"ids": [id], "swatch": "[Black]"})).unwrap();
|
||||
s.execute("object.opacity", &json!({"ids": [id], "opacity": 0.5})).unwrap();
|
||||
let exported = super::super::base64_decode(s.execute("file.exportPdf", &json!({"flatten": "low"})).unwrap()["base64"].as_str().unwrap());
|
||||
s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false, "pdf": {"flatten": "low"}})).unwrap();
|
||||
let packaged = std::fs::read(dir.join("Flat.pdf")).unwrap();
|
||||
let images = |pdf: &[u8]| pdf.windows(b"/Subtype/Image".len()).filter(|w| *w == b"/Subtype/Image").count();
|
||||
assert!(images(&exported) > 0, "the export rasterises the transparent object");
|
||||
assert_eq!(images(&packaged), images(&exported), "so does the package");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// A per-placement PDF layer choice cannot travel in an IDML copy that links its files, so the
|
||||
/// report says the copies differ instead of leaving it to be discovered.
|
||||
#[test]
|
||||
@@ -169,6 +221,7 @@ fn package_writes_document_links_and_report() {
|
||||
assert!(out.join("Links").join("logo.svg").exists());
|
||||
let report = std::fs::read_to_string(out.join("Instructions.txt")).unwrap();
|
||||
assert!(report.contains("Print on matte.") && report.contains("logo.svg"), "{report}");
|
||||
assert!(report.find("Print on matte.") < report.find("Package report:"), "the instructions come first: {report}");
|
||||
assert!(report.contains("Preflight:"), "{report}");
|
||||
// The packaged document links to its Links folder.
|
||||
let doc_file = files.iter().find(|f| f.ends_with(".designcraft")).unwrap();
|
||||
|
||||
@@ -50,12 +50,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Preferences",
|
||||
[],
|
||||
None,
|
||||
"{showHiddenCharacters?, typographersQuotes?, polygonSides?, starInset?, scaleStrokes?, dimensionsIncludeStroke?, transformationsAreTotals?, absolutePageNumbers?, highlightHj?, highlightKeeps?, highlightCustomTracking?, highlightSubstitutedFonts?, richBlackOutput?, favoriteFonts?: [family], recentFonts?: [family], showFontNamesInEnglish?, smartTextReflow?, autocorrect?, autocorrectList?: [[word, correction]], showAddedText?, showNoteAnchors?, cjkFeatures?: bool|null (CJK-only menu items and dialogs shown; null follows the interface language), linkTextFiles? (Create Links When Placing Text Files), recoveryMinutes?, recentFilesMax?: 0–100} → all application preferences",
|
||||
"{showHiddenCharacters?, typographersQuotes?, polygonSides?, starInset?, scaleStrokes?, dimensionsIncludeStroke?, transformationsAreTotals?, absolutePageNumbers?, highlightHj?, highlightKeeps?, highlightCustomTracking?, highlightSubstitutedFonts?, richBlackOutput?, favoriteFonts?: [family], recentFonts?: [family], showFontNamesInEnglish?, smartTextReflow?, autocorrect?, autocorrectList?: [[word, correction]], showAddedText?, showNoteAnchors?, cjkFeatures?: bool|null (CJK-only menu items and dialogs shown; null follows the interface language), linkTextFiles? (Create Links When Placing Text Files), recoveryMinutes?, recentFilesMax?: 0–100, defaultUnits?: {horizontalUnits?, verticalUnits?, rulerOrigin?, keyboardIncrement? (pt), kerningIncrement? (1/1000 em)} (Units & Increments of new documents)} → all application preferences",
|
||||
super::always,
|
||||
|s, p| {
|
||||
let cur = serde_json::to_value(&s.prefs).map_err(|e| bad("prefs.set", e.to_string()))?;
|
||||
let new = merge("prefs.set", cur, p, None)?;
|
||||
s.prefs = serde_json::from_value(new).map_err(|e| bad("prefs.set", e.to_string()))?;
|
||||
let prefs: crate::Prefs = serde_json::from_value(new).map_err(|e| bad("prefs.set", e.to_string()))?;
|
||||
prefs.default_units.check().map_err(|e| bad("prefs.set", e))?;
|
||||
s.prefs = prefs;
|
||||
s.prefs.trim_recent_files();
|
||||
serde_json::to_value(&s.prefs).map_err(|e| bad("prefs.set", e.to_string()))
|
||||
}
|
||||
@@ -100,6 +102,9 @@ fn merge(cmd: &str, mut cur: Value, p: &Value, allowed: Option<&[&str]>) -> Resu
|
||||
match (c.get_mut(k), v) {
|
||||
(Some(Value::Object(old)), Value::Object(v)) => {
|
||||
for (k2, v2) in v {
|
||||
if !old.is_empty() && !old.contains_key(k2) {
|
||||
return Err(bad(cmd, format!("unknown preference {k}.{k2}")));
|
||||
}
|
||||
old.insert(k2.clone(), v2.clone());
|
||||
}
|
||||
}
|
||||
@@ -127,6 +132,7 @@ mod tests {
|
||||
s.execute("prefs.set", &json!({"showFontNamesInEnglish": true})).unwrap();
|
||||
assert!(s.prefs.show_font_names_in_english);
|
||||
assert!(s.execute("prefs.set", &json!({"nope": 1})).is_err());
|
||||
assert!(s.execute("prefs.set", &json!({"defaultUnits": {"horizontalUnit": "inches"}})).is_err());
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s
|
||||
.execute("document.preferences", &json!({"horizontalUnits": "millimeters", "keyboardIncrement": 2.5, "grid": {"subdivisions": 4}}))
|
||||
@@ -141,6 +147,53 @@ mod tests {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.settings.horizontal_units, designcraft_geom::Unit::Picas);
|
||||
}
|
||||
|
||||
/// Units & Increments set with no document open are what new documents start with, as in
|
||||
/// InDesign: a reader who works in inches sets them once, not in every file.
|
||||
#[test]
|
||||
fn default_units_are_what_new_documents_start_with() {
|
||||
use designcraft_geom::Unit;
|
||||
let mut s = Session::new();
|
||||
let r = s
|
||||
.execute(
|
||||
"prefs.set",
|
||||
&json!({"defaultUnits": {"horizontalUnits": "inches", "verticalUnits": "inches", "keyboardIncrement": 0.5, "rulerOrigin": "page"}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["defaultUnits"]["horizontalUnits"], "inches");
|
||||
assert_eq!(r["defaultUnits"]["kerningIncrement"], 20.0, "the rest kept");
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let st = s.doc().unwrap().doc.settings.clone();
|
||||
assert_eq!((st.horizontal_units, st.vertical_units), (Unit::Inches, Unit::Inches));
|
||||
assert_eq!((st.keyboard_increment, st.ruler_origin), (0.5, designcraft_doc::RulerOrigin::Page));
|
||||
assert!(!s.doc().unwrap().is_dirty(), "made so, not edited");
|
||||
// Inches across, millimetres down: New Document's units (the horizontal ones) keep the
|
||||
// vertical preference; other units picked there set both rulers.
|
||||
s.execute("prefs.set", &json!({"defaultUnits": {"verticalUnits": "millimeters"}})).unwrap();
|
||||
s.execute("file.new", &json!({"units": "inches"})).unwrap();
|
||||
let st = s.doc().unwrap().doc.settings.clone();
|
||||
assert_eq!((st.horizontal_units, st.vertical_units), (Unit::Inches, Unit::Millimeters));
|
||||
s.execute("file.new", &json!({"units": "points"})).unwrap();
|
||||
let st = s.doc().unwrap().doc.settings.clone();
|
||||
assert_eq!((st.horizontal_units, st.vertical_units), (Unit::Points, Unit::Points));
|
||||
// A preset keeps its own units, both of them, even when the default horizontal units are
|
||||
// the preset's.
|
||||
s.execute("prefs.set", &json!({"defaultUnits": {"horizontalUnits": "millimeters", "verticalUnits": "inches"}})).unwrap();
|
||||
s.execute("file.new", &json!({"preset": "A4"})).unwrap();
|
||||
let st = s.doc().unwrap().doc.settings.clone();
|
||||
assert_eq!((st.horizontal_units, st.vertical_units), (Unit::Millimeters, Unit::Millimeters));
|
||||
assert_eq!(st.keyboard_increment, 0.5, "the increments still apply");
|
||||
s.execute("prefs.set", &json!({"defaultUnits": {"horizontalUnits": "inches", "verticalUnits": "millimeters"}})).unwrap();
|
||||
// New Document opens with them; bad values are refused and change nothing.
|
||||
assert_eq!(s.default_new_document().units, Unit::Inches);
|
||||
for bad in [json!({"horizontalUnits": "furlongs"}), json!({"keyboardIncrement": 0}), json!({"kerningIncrement": -5})] {
|
||||
assert!(s.execute("prefs.set", &json!({"defaultUnits": bad})).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(s.prefs.default_units.keyboard_increment, 0.5);
|
||||
// Kept with the application preferences.
|
||||
let saved: crate::Prefs = serde_json::from_slice(&serde_json::to_vec(&s.prefs).unwrap()).unwrap();
|
||||
assert_eq!(saved.default_units, s.prefs.default_units);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -81,13 +81,91 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Common words the hyphenation word list (from the early 1990s) lacks: everyday short words
|
||||
/// such as "we", "us" and "if", titles, and words that came with computers and the web. Sorted,
|
||||
/// lowercase, base forms (the inflections below reach "emails", "downloaded", "bloggers").
|
||||
const COMMON: &[&str] = &[
|
||||
"ah",
|
||||
"bitmap",
|
||||
"blog",
|
||||
"bluetooth",
|
||||
"chatbot",
|
||||
"checkbox",
|
||||
"cybersecurity",
|
||||
"database",
|
||||
"dataset",
|
||||
"desktop",
|
||||
"download",
|
||||
"dr",
|
||||
"dropdown",
|
||||
"ebook",
|
||||
"ecommerce",
|
||||
"eh",
|
||||
"email",
|
||||
"emoji",
|
||||
"firewall",
|
||||
"gigabyte",
|
||||
"hashtag",
|
||||
"hm",
|
||||
"hmm",
|
||||
"homepage",
|
||||
"hyperlink",
|
||||
"if",
|
||||
"inbox",
|
||||
"internet",
|
||||
"jr",
|
||||
"kilobyte",
|
||||
"laptop",
|
||||
"livestream",
|
||||
"login",
|
||||
"logout",
|
||||
"malware",
|
||||
"megabyte",
|
||||
"metadata",
|
||||
"mr",
|
||||
"offline",
|
||||
"ok",
|
||||
"online",
|
||||
"ow",
|
||||
"pm",
|
||||
"podcast",
|
||||
"popup",
|
||||
"printout",
|
||||
"screenshot",
|
||||
"selfie",
|
||||
"smartphone",
|
||||
"smartwatch",
|
||||
"spreadsheet",
|
||||
"sr",
|
||||
"startup",
|
||||
"terabyte",
|
||||
"touchscreen",
|
||||
"um",
|
||||
"upload",
|
||||
"us",
|
||||
"username",
|
||||
"voicemail",
|
||||
"vs",
|
||||
"we",
|
||||
"webinar",
|
||||
"webmaster",
|
||||
"webpage",
|
||||
"website",
|
||||
"wifi",
|
||||
"workflow",
|
||||
"yay",
|
||||
];
|
||||
|
||||
/// Is `w` (lowercase) a known word, allowing common inflections of dictionary stems?
|
||||
pub fn known(dict: &Dictionary, user: &[String], w: &str) -> bool {
|
||||
if w.len() <= 1 || dict.get(w).is_some() || user.iter().any(|u| u == w) {
|
||||
// A typographer's apostrophe (don’t, we’re) is the word list's straight one.
|
||||
let w = &w.replace('’', "'");
|
||||
let has = |w: &str| dict.get(w).is_some() || COMMON.binary_search(&w).is_ok();
|
||||
if w.len() <= 1 || has(w) || user.iter().any(|u| u.replace('’', "'") == *w) {
|
||||
return true;
|
||||
}
|
||||
let w = w.trim_end_matches("'s").trim_end_matches("’s");
|
||||
if dict.get(w).is_some() {
|
||||
let w = w.trim_end_matches("'s");
|
||||
if has(w) {
|
||||
return true;
|
||||
}
|
||||
let stems: [(&str, &[&str]); 9] = [
|
||||
@@ -105,14 +183,14 @@ pub fn known(dict: &Dictionary, user: &[String], w: &str) -> bool {
|
||||
if let Some(base) = w.strip_suffix(suf) {
|
||||
for add in adds {
|
||||
let cand = format!("{base}{add}");
|
||||
if cand.len() > 1 && dict.get(&cand).is_some() {
|
||||
if cand.len() > 1 && has(&cand) {
|
||||
return true;
|
||||
}
|
||||
// Doubled consonant: "stopped" → "stop".
|
||||
if let Some(c) = base.chars().last()
|
||||
&& base.len() > 2
|
||||
&& base.ends_with(&format!("{c}{c}"))
|
||||
&& dict.get(&base[..base.len() - c.len_utf8()]).is_some()
|
||||
&& has(&base[..base.len() - c.len_utf8()])
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -128,7 +206,7 @@ pub fn suggest(dict: &Dictionary, w: &str) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut push = |c: Vec<char>| {
|
||||
let c: String = c.into_iter().collect();
|
||||
if c != w && !out.contains(&c) && dict.get(&c).is_some() {
|
||||
if c != w && !out.contains(&c) && (dict.get(&c).is_some() || COMMON.binary_search(&c.as_str()).is_ok()) {
|
||||
out.push(c);
|
||||
}
|
||||
};
|
||||
@@ -238,6 +316,7 @@ mod tests {
|
||||
}
|
||||
assert!(!known(d, &[], "typograpy"));
|
||||
assert!(suggest(d, "typograpy").contains(&"typography".to_string()));
|
||||
assert!(suggest(d, "emial").contains(&"email".to_string()), "words in COMMON are suggested too");
|
||||
assert!(known(d, &["designcraft".into()], "designcraft"));
|
||||
}
|
||||
|
||||
@@ -253,6 +332,53 @@ mod tests {
|
||||
assert_eq!(bad, vec![6..10]);
|
||||
}
|
||||
|
||||
/// Everyday words the hyphenation list lacks aren't misspelled ("we", "us", "if", "email"…),
|
||||
/// with their inflections, and neither are contractions typed with a typographer's
|
||||
/// apostrophe, which Use Typographer's Quotes turns ' into.
|
||||
#[test]
|
||||
fn common_words_and_curly_apostrophes_are_known() {
|
||||
let d = Dictionary::en_us();
|
||||
if d.is_empty() {
|
||||
return; // data not bundled in this build
|
||||
}
|
||||
assert!(COMMON.is_sorted(), "COMMON is binary-searched");
|
||||
for w in [
|
||||
"we",
|
||||
"us",
|
||||
"if",
|
||||
"ok",
|
||||
"mr",
|
||||
"email",
|
||||
"emails",
|
||||
"emailed",
|
||||
"online",
|
||||
"website",
|
||||
"websites",
|
||||
"downloaded",
|
||||
"database",
|
||||
"databases",
|
||||
"blogger",
|
||||
"don’t",
|
||||
"can’t",
|
||||
"i’m",
|
||||
"we’re",
|
||||
"they’ve",
|
||||
"it’s",
|
||||
"don't",
|
||||
] {
|
||||
assert!(known(d, &[], w), "{w}");
|
||||
}
|
||||
for w in ["dont", "wee’re", "emial"] {
|
||||
assert!(!known(d, &[], w), "{w}");
|
||||
}
|
||||
// A user word added with a typographer's apostrophe matches either form.
|
||||
assert!(known(d, &["zorbo’s".into()], "zorbo's") && known(d, &["zorbo's".into()], "zorbo’s"));
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "We don’t email us if we’re online."})).unwrap();
|
||||
assert_eq!(s.execute("spelling.check", &json!({})).unwrap(), json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_finds_misspellings() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -17,7 +17,7 @@ use object::{apply_object, clear_object_overrides, create_object, edit_object};
|
||||
use paragraph::{
|
||||
apply_char, apply_para, create_char, create_para, delete_char, delete_para, edit_char, edit_para, export_tag_key, group_styles, redefine,
|
||||
};
|
||||
pub(crate) use paragraph::{check_grep_styles, grep_style_warnings};
|
||||
pub(crate) use paragraph::{apply_char_style, apply_para_style, check_grep_styles, grep_style_warnings};
|
||||
pub use swatches::parse_color;
|
||||
use swatches::{add_to_swatches, create_swatch, load_swatches, new_swatch, save_swatches, swatch_options};
|
||||
|
||||
@@ -134,7 +134,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Paragraph Style Options…",
|
||||
[],
|
||||
None,
|
||||
"{name, rename?, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, spaceBefore, ignoreOpticalMargin, …; ruleAbove?/ruleBelow?: only the named rule fields change; startAt?: null = continue numbering; kinsoku?: set | null (default rules); a grepStyles pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}}",
|
||||
"{name, rename?, basedOn?, nextStyle?: style | null ([Same style]), resetToBase?: bool (Reset To Base: the style sets nothing of its own; `para`/`chars` then apply over its Based On), para?: {paragraph attributes, as type.para names them: align, leftIndent, spaceBefore, ignoreOpticalMargin, …; ruleAbove?/ruleBelow?: only the named rule fields change; startAt?: null = continue numbering; kinsoku?: set | null (default rules); a grepStyles pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}}",
|
||||
has_doc,
|
||||
edit_para
|
||||
),
|
||||
|
||||
@@ -328,7 +328,7 @@ fn apply_para_in_story(
|
||||
f.chars.retain_differences(style_chars);
|
||||
}
|
||||
});
|
||||
for (pi, range) in st.para_ranges().into_iter().enumerate().take(last.saturating_add(1)).skip(first) {
|
||||
for (pi, range) in st.paras_touching(r.clone()).into_iter().filter(|(pi, _)| (first..=last).contains(pi)) {
|
||||
let Some(pf) = st.paras.get(pi) else { continue };
|
||||
let (_, base) = styles.resolve_para(pf);
|
||||
let end = range.end.saturating_add(1).min(st.len());
|
||||
@@ -345,6 +345,13 @@ fn apply_para_in_story(
|
||||
}
|
||||
}
|
||||
|
||||
/// Paragraph style `name` applied to the paragraphs of `r` in `st`: the paragraphs' and their
|
||||
/// text's overrides that the style now gives go (all of them with `clear`).
|
||||
pub(crate) fn apply_para_style(st: &mut designcraft_doc::Story, styles: &Styles, name: &str, r: &std::ops::Range<usize>, clear: bool) {
|
||||
let (style_para, style_chars) = styles.resolve_para_style(name);
|
||||
apply_para_in_story(st, r, name, clear, (&style_para, &style_chars), styles);
|
||||
}
|
||||
|
||||
pub(super) fn apply_para(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("style.paragraph.apply", "missing name"))?.to_string();
|
||||
let clear = p.get("clearOverrides").and_then(Value::as_bool).unwrap_or(false);
|
||||
@@ -374,25 +381,31 @@ pub(super) fn apply_char(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let styles = d.styles.clone();
|
||||
for t in &targets {
|
||||
if let Some(st) = d.text_story_mut(t.story, t.cell) {
|
||||
for (pi, range) in st.para_ranges().into_iter().enumerate() {
|
||||
let range = range.start.max(t.range.start)..range.end.saturating_add(1).min(st.len()).min(t.range.end);
|
||||
if range.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(pf) = st.paras.get(pi) else { continue };
|
||||
let (_, mut inherited) = styles.resolve_para(pf);
|
||||
inherited.apply(&styles.char_style_attrs(&name));
|
||||
st.format_chars(range, |f| {
|
||||
f.style = name.clone();
|
||||
f.over.retain_differences(&inherited);
|
||||
});
|
||||
}
|
||||
apply_char_style(st, &styles, &name, &t.range);
|
||||
}
|
||||
}
|
||||
ok()
|
||||
})
|
||||
}
|
||||
|
||||
/// Character style `name` applied to `r` in `st`: the text's overrides that the style (over its
|
||||
/// paragraph) now gives go.
|
||||
pub(crate) fn apply_char_style(st: &mut designcraft_doc::Story, styles: &Styles, name: &str, r: &std::ops::Range<usize>) {
|
||||
for (pi, range) in st.paras_touching(r.clone()) {
|
||||
let range = range.start.max(r.start)..range.end.saturating_add(1).min(st.len()).min(r.end);
|
||||
if range.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(pf) = st.paras.get(pi) else { continue };
|
||||
let (_, mut inherited) = styles.resolve_para(pf);
|
||||
inherited.apply(&styles.char_style_attrs(name));
|
||||
st.format_chars(range, |f| {
|
||||
f.style = name.to_string();
|
||||
f.over.retain_differences(&inherited);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// In a style, `startAt: null` is Continue from Previous Number: it is stored as an explicit
|
||||
/// "continue" so it also overrides a Start At the style would inherit.
|
||||
fn continue_numbering(p: Option<&Value>, para: &mut ParaAttrs) {
|
||||
@@ -403,8 +416,19 @@ fn continue_numbering(p: Option<&Value>, para: &mut ParaAttrs) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The style `p[key]` names: none when absent or null; anything but a name is refused.
|
||||
fn style_name_param(cmd: &str, p: &Value, key: &str) -> Result<Option<String>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(n)) => Ok(Some(n.clone())),
|
||||
Some(_) => Err(bad(cmd, format!("`{key}` is a style name or null"))),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn create_para(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let base = str_param(p, "name").unwrap_or("Paragraph Style 1").to_string();
|
||||
let based_on = style_name_param("style.paragraph.create", p, "basedOn")?;
|
||||
let next = style_name_param("style.paragraph.create", p, "nextStyle")?;
|
||||
let mut para: ParaAttrs =
|
||||
attrs("style.paragraph.create", "para", p, |a: &mut ParaAttrs, k, v| a.set_json_over(k, v, &designcraft_doc::ParaProps::default()))?;
|
||||
continue_numbering(p.get("para"), &mut para);
|
||||
@@ -428,8 +452,6 @@ pub(super) fn create_para(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
chars = full;
|
||||
}
|
||||
}
|
||||
let based_on = str_param(p, "basedOn").map(str::to_string);
|
||||
let next = str_param(p, "nextStyle").map(str::to_string);
|
||||
s.edit(|d, _| {
|
||||
let name = Styles::unique_name(|n| d.styles.para(n).is_some(), &base);
|
||||
if let Some(b) = &based_on
|
||||
@@ -437,6 +459,13 @@ pub(super) fn create_para(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
return Err(bad("style.paragraph.create", format!("no style `{b}`")));
|
||||
}
|
||||
// Next Style: a style of the document, or this one.
|
||||
if let Some(n) = &next
|
||||
&& *n != name
|
||||
&& d.styles.para(n).is_none()
|
||||
{
|
||||
return Err(bad("style.paragraph.create", format!("no style `{n}`")));
|
||||
}
|
||||
d.styles_mut().paragraph.push(ParagraphStyle { name: name.clone(), based_on, next_style: next, para, chars, shortcut: String::new() });
|
||||
Ok(json!({"name": name}))
|
||||
})
|
||||
@@ -499,11 +528,41 @@ pub(super) fn edit_para(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
crate::cmd::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.edit")?;
|
||||
let rename = str_param(p, "rename").map(str::trim).filter(|n| *n != name).map(str::to_string);
|
||||
let based = p.get("basedOn").cloned();
|
||||
let next = p.get("nextStyle").cloned();
|
||||
let reset = p.get("resetToBase").and_then(Value::as_bool).unwrap_or(false);
|
||||
for (key, v) in [("basedOn", &based), ("nextStyle", &next)] {
|
||||
if v.as_ref().is_some_and(|v| !v.is_string() && !v.is_null()) {
|
||||
return Err(bad("style.paragraph.edit", format!("`{key}` must be a style name or null")));
|
||||
}
|
||||
}
|
||||
s.edit(|d, _| {
|
||||
if let Some(Value::String(b)) = &based
|
||||
&& d.styles.para_based_on_cycles(&name, b)
|
||||
if let Some(Value::String(b)) = &based {
|
||||
if d.styles.para(b).is_none() {
|
||||
return Err(bad("style.paragraph.edit", format!("no style `{b}`")));
|
||||
}
|
||||
if d.styles.para_based_on_cycles(&name, b) {
|
||||
return Err(bad("style.paragraph.edit", "based-on would create a cycle"));
|
||||
}
|
||||
}
|
||||
if let Some(Value::String(n)) = &next
|
||||
&& d.styles.para(n).is_none()
|
||||
{
|
||||
return Err(bad("style.paragraph.edit", "based-on would create a cycle"));
|
||||
return Err(bad("style.paragraph.edit", format!("no style `{n}`")));
|
||||
}
|
||||
// Reset To Base: what the style set of its own goes before the edits apply.
|
||||
if reset {
|
||||
let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?;
|
||||
st.para = ParaAttrs::default();
|
||||
st.chars = CharAttrs::default();
|
||||
}
|
||||
// A new Based On first, so the fields below resolve over the new parent.
|
||||
if based.is_some() {
|
||||
let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?;
|
||||
match &based {
|
||||
Some(Value::String(b)) => st.based_on = Some(b.clone()),
|
||||
Some(Value::Null) => st.based_on = None,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(n) = &rename {
|
||||
if n.is_empty() || n.starts_with('[') {
|
||||
@@ -522,9 +581,9 @@ pub(super) fn edit_para(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?;
|
||||
st.para.merge(¶);
|
||||
st.chars.merge(&chars);
|
||||
match based {
|
||||
Some(Value::String(b)) => st.based_on = Some(b),
|
||||
Some(Value::Null) => st.based_on = None,
|
||||
match next {
|
||||
Some(Value::String(n)) => st.next_style = Some(n),
|
||||
Some(Value::Null) => st.next_style = None,
|
||||
_ => {}
|
||||
}
|
||||
if let Some(n) = rename.clone() {
|
||||
@@ -781,7 +840,7 @@ pub(super) fn create_char(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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);
|
||||
let based_on = style_name_param("style.character.create", p, "basedOn")?.filter(|b| b != designcraft_doc::NO_CHAR_STYLE);
|
||||
s.edit(|d, _| {
|
||||
if let Some(b) = &based_on
|
||||
&& d.styles.char_style(b).is_none()
|
||||
@@ -1141,6 +1200,7 @@ mod delete_tests {
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
}
|
||||
|
||||
/// Deleting a paragraph style also redirects TOC style references to its replacement.
|
||||
#[test]
|
||||
fn deleting_a_paragraph_style_points_every_use_at_the_replacement() {
|
||||
let mut s = Session::new();
|
||||
@@ -1162,6 +1222,7 @@ mod delete_tests {
|
||||
entries: vec![TocEntry { entry_style: Some("Table Body".into()), ..TocEntry::new("Table Body", 1) }],
|
||||
page_numbers: false,
|
||||
title_style: Some("Table Body".into()),
|
||||
create_bookmarks: false,
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
|
||||
@@ -28,4 +28,5 @@ mod styles;
|
||||
mod target;
|
||||
|
||||
pub use registry::specs;
|
||||
pub(crate) use selection::step_cell;
|
||||
pub(crate) use selection::{one_selected_cell_to_text, step_cell};
|
||||
pub use structure::{MAX_BODY_ROWS, MAX_COLUMNS, MAX_CONVERTED_CELLS, MAX_HEADER_FOOTER_ROWS};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::cell::{get_cell_stroke, set_cell};
|
||||
use super::content::{drop_cells, place_graphic, sort_rows};
|
||||
use super::options::{get, options};
|
||||
use super::selection::{move_rc, select, step_cell};
|
||||
use super::selection::{move_rc, select, step_cell, toggle_selection};
|
||||
use super::sizing::{distribute_cols, distribute_rows, resize_border, set_col_width, set_row_height};
|
||||
use super::structure::{
|
||||
can_convert_rows, convert_from_text, convert_rows, convert_to_text, delete_cols, delete_rows, delete_table, insert, insert_col, insert_row,
|
||||
@@ -82,8 +82,9 @@ fn table_specs() -> Vec<CommandSpec> {
|
||||
"table.insert",
|
||||
"Create Table",
|
||||
[],
|
||||
Some("Cmd+Alt+Shift+T"),
|
||||
"{rows?: body rows (4), cols? | columns? (4), headerRows? (0), footerRows? (0), width?} — at the text insertion point",
|
||||
// InDesign's Cmd+Alt+Shift+T opens the Insert Table dialog (`app.insertTableDialog`).
|
||||
None,
|
||||
"{rows?: body rows (4, at most 500), cols? | columns? (4, at most 200), headerRows? (0), footerRows? (0, each at most 100), tableStyle?: name, width?} — at the text insertion point, styled in the same step",
|
||||
has_text,
|
||||
insert
|
||||
),
|
||||
@@ -92,7 +93,7 @@ fn table_specs() -> Vec<CommandSpec> {
|
||||
"Convert Text to Table",
|
||||
["Table"],
|
||||
None,
|
||||
"{columnSeparator?: tab|comma, width?: table width (default: the column)} — selected paragraphs become rows",
|
||||
"{columnSeparator?: tab|comma|paragraph|text (tab), rowSeparator?: paragraph|tab|comma|text (paragraph), columns?: number of columns when both separators are the same (1, at most 200), tableStyle?: name, width?: table width (default: the column)} — the selected paragraphs become a table, as InDesign's Convert Text to Table; the words tab, comma and paragraph name those separators, any other text is the separator itself",
|
||||
has_text,
|
||||
convert_from_text
|
||||
),
|
||||
@@ -101,7 +102,7 @@ fn table_specs() -> Vec<CommandSpec> {
|
||||
"Convert Table to Text",
|
||||
["Table"],
|
||||
None,
|
||||
"{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — cells separated by tabs, rows by paragraphs",
|
||||
"{columnSeparator?: tab|comma|paragraph|text (tab), rowSeparator?: paragraph|tab|comma|text (paragraph), story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — the table becomes its cells' text, as InDesign's Convert Table to Text",
|
||||
in_table,
|
||||
convert_to_text
|
||||
),
|
||||
@@ -207,7 +208,7 @@ fn table_specs() -> Vec<CommandSpec> {
|
||||
cmd!(
|
||||
"table.placeGraphic",
|
||||
"Convert Cell to Graphic Cell",
|
||||
["Table", "Convert Cell Type"],
|
||||
["Table"],
|
||||
None,
|
||||
"{path? | base64?, name?: with base64, fit?: proportional|fill (default proportional), story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — an image in the target cell (its text is kept but hidden)",
|
||||
in_table,
|
||||
@@ -216,7 +217,7 @@ fn table_specs() -> Vec<CommandSpec> {
|
||||
cmd!(
|
||||
"table.textCell",
|
||||
"Convert Cell to Text Cell",
|
||||
["Table", "Convert Cell Type"],
|
||||
["Table"],
|
||||
None,
|
||||
"{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — drop the target cells' graphics",
|
||||
in_table,
|
||||
@@ -397,6 +398,15 @@ fn table_specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
cmd!(noundo "table.nextCell", "Next Cell", [], None, "{}", in_cell, |s, _| step_cell(s, true)),
|
||||
cmd!(noundo "table.prevCell", "Previous Cell", [], None, "{}", in_cell, |s, _| step_cell(s, false)),
|
||||
cmd!(
|
||||
noundo "table.toggleSelection",
|
||||
"Toggle Text and Cell Selection",
|
||||
[],
|
||||
None,
|
||||
"{} — Esc in a table, as InDesign: the insertion point or text in a cell selects that cell; selected cells select the text of the first one",
|
||||
in_cell,
|
||||
|s, _| toggle_selection(s)
|
||||
),
|
||||
cmd!(query "table.get", "Get Table", [], None, "{story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?} → rows, columns, cells (text, spans, fill), options", in_table_or_ids, get),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -66,6 +66,55 @@ pub(crate) fn step_cell(s: &mut Session, forward: bool) -> Result<Value> {
|
||||
Ok(json!({"row": row, "col": col}))
|
||||
}
|
||||
|
||||
/// With one cell selected (a merged one counts as one), select its whole text instead, so what is
|
||||
/// typed next replaces it, as InDesign does. Other selections are left as they are.
|
||||
pub(crate) fn one_selected_cell_to_text(s: &mut Session) -> Result<()> {
|
||||
let st = s.doc_mut()?;
|
||||
let (Some(cs), Some(t)) = (st.selection.cells, st.selection.text) else { return Ok(()) };
|
||||
let Some(tb) = st.doc.story(cs.story).and_then(|x| x.tables.get(&cs.table)) else { return Ok(()) };
|
||||
let (row, col) = tb.owner(cs.range.r0, cs.range.c0);
|
||||
if tb.owner(cs.range.r1, cs.range.c1) != (row, col) {
|
||||
return Ok(());
|
||||
}
|
||||
let cell = CellAddr { table: cs.table, row, col };
|
||||
let len = st.doc.text_story(cs.story, Some(cell)).map_or(0, |x| x.len());
|
||||
st.selection = Selection::text(TextSel { story: cs.story, anchor: 0, focus: len, cell: Some(cell), ..t });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Esc in a table: text in a cell becomes that cell's selection, and selected cells the text of
|
||||
/// the first one (InDesign toggles between the two).
|
||||
pub(super) fn toggle_selection(s: &mut Session) -> Result<Value> {
|
||||
const ID: &str = "table.toggleSelection";
|
||||
let st = s.doc_mut()?;
|
||||
let t = st.selection.text.ok_or_else(|| bad(ID, "no caret"))?;
|
||||
let c = t.cell.filter(|c| c.footnote_id().is_none()).ok_or_else(|| bad(ID, "not in a cell"))?;
|
||||
let cells = match st.selection.cells {
|
||||
Some(cs) => {
|
||||
// A merged cell's text belongs to the cell at its top left.
|
||||
let (row, col) = st
|
||||
.doc
|
||||
.story(cs.story)
|
||||
.and_then(|x| x.tables.get(&cs.table))
|
||||
.map_or((cs.range.r0, cs.range.c0), |tb| tb.owner(cs.range.r0, cs.range.c0));
|
||||
let cell = CellAddr { table: cs.table, row, col };
|
||||
let len = st.doc.text_story(cs.story, Some(cell)).map_or(0, |x| x.len());
|
||||
st.selection = Selection::text(TextSel { story: cs.story, anchor: 0, focus: len, cell: Some(cell), ..t });
|
||||
false
|
||||
}
|
||||
None => {
|
||||
st.selection = Selection {
|
||||
cells: Some(TableSel { story: t.story, table: c.table, range: CellRange::cell(c.row, c.col) }),
|
||||
text: Some(TextSel { anchor: 0, focus: 0, ..t }),
|
||||
..Default::default()
|
||||
};
|
||||
true
|
||||
}
|
||||
};
|
||||
st.bump_revision();
|
||||
Ok(json!({"cells": cells}))
|
||||
}
|
||||
|
||||
/// The table cell under spread point `pt` in text frame `frame`: (story, table, row, col).
|
||||
pub(super) fn cell_at(s: &Session, frame: designcraft_doc::ItemId, pt: designcraft_geom::Point) -> Option<(StoryId, u64, usize, usize)> {
|
||||
let st = s.active()?;
|
||||
|
||||
@@ -1,6 +1,31 @@
|
||||
use super::target::{column_width, count, edit_table, in_cell, para_direction, target};
|
||||
use super::{CellAddr, Result, RowKind, Selection, Session, Table, TextSel, Value, bad, json, ok, str_param};
|
||||
|
||||
/// The most body rows, columns, and header or footer rows `table.insert` makes (the Insert Table
|
||||
/// dialog's limits too).
|
||||
pub const MAX_BODY_ROWS: u64 = 500;
|
||||
pub const MAX_COLUMNS: u64 = 200;
|
||||
pub const MAX_HEADER_FOOTER_ROWS: u64 = 100;
|
||||
/// The most cells `table.convertFromText` makes: as many as the largest table `table.insert`
|
||||
/// makes, however the rows and columns divide them (a long list can make a long table).
|
||||
pub const MAX_CONVERTED_CELLS: u64 = MAX_BODY_ROWS * MAX_COLUMNS;
|
||||
|
||||
/// A command's `tableStyle`: none for an absent one or [Basic Table] (a new table's look), else the
|
||||
/// resolved style and the cell styles it names. Anything but an existing style's name is an error.
|
||||
fn table_style_param(s: &Session, p: &Value, cmd: &str) -> Result<Option<(designcraft_doc::TableStyle, Vec<designcraft_doc::CellStyle>)>> {
|
||||
let name = match p.get("tableStyle") {
|
||||
None | Some(Value::Null) => return Ok(None),
|
||||
Some(Value::String(n)) if n == designcraft_doc::BASIC_TABLE => return Ok(None),
|
||||
Some(Value::String(n)) => n,
|
||||
Some(_) => return Err(bad(cmd, "`tableStyle` must be a table style's name")),
|
||||
};
|
||||
let styles = &s.doc()?.doc.styles;
|
||||
if !styles.table.iter().any(|x| x.name == *name) {
|
||||
return Err(bad(cmd, format!("no table style `{name}`")));
|
||||
}
|
||||
Ok(Some((styles.resolve_table_style(name), styles.cell.clone())))
|
||||
}
|
||||
|
||||
pub(super) fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let t = s.doc()?.selection.text.ok_or_else(|| bad("table.insert", "no insertion point"))?;
|
||||
if t.cell.is_some() {
|
||||
@@ -20,10 +45,11 @@ pub(super) fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
},
|
||||
}
|
||||
};
|
||||
let rows = given("rows", None, 4, 1..=500)?;
|
||||
let cols = given("cols", Some("columns"), 4, 1..=200)?;
|
||||
let header = given("headerRows", None, 0, 0..=100)?;
|
||||
let footer = given("footerRows", None, 0, 0..=100)?;
|
||||
let rows = given("rows", None, 4, 1..=MAX_BODY_ROWS)?;
|
||||
let cols = given("cols", Some("columns"), 4, 1..=MAX_COLUMNS)?;
|
||||
let header = given("headerRows", None, 0, 0..=MAX_HEADER_FOOTER_ROWS)?;
|
||||
let footer = given("footerRows", None, 0, 0..=MAX_HEADER_FOOTER_ROWS)?;
|
||||
let style = table_style_param(s, p, "table.insert")?;
|
||||
let width = p.get("width").and_then(Value::as_f64).unwrap_or_else(|| column_width(s, &t));
|
||||
s.edit(|d, sel| {
|
||||
let id = d.alloc();
|
||||
@@ -37,6 +63,9 @@ pub(super) fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
c.text.paras[0] = designcraft_doc::ParaFormat { table: None, ..pf.clone() };
|
||||
c.text.chars[0].format = cf.clone();
|
||||
}
|
||||
if let Some((ts, cells)) = &style {
|
||||
ts.apply_to(&mut table, cells);
|
||||
}
|
||||
let r = t.range();
|
||||
st.delete(r.clone());
|
||||
st.insert_table(r.start, table);
|
||||
@@ -45,12 +74,41 @@ pub(super) fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn convert_from_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let t = s.doc()?.selection.text.ok_or_else(|| bad("table.convertFromText", "no text selected"))?;
|
||||
if t.cell.is_some() {
|
||||
return Err(bad("table.convertFromText", "select text outside tables"));
|
||||
/// A Convert Text to Table / Convert Table to Text separator: `tab`, `comma`, `paragraph` or the
|
||||
/// separator's own text (InDesign's fields take any characters).
|
||||
fn separator(p: &Value, key: &str, default: &str, cmd: &str) -> Result<String> {
|
||||
let sep = match p.get(key) {
|
||||
None | Some(Value::Null) => return Ok(default.to_string()),
|
||||
Some(Value::String(s)) => match s.as_str() {
|
||||
"tab" => "\t",
|
||||
"comma" => ",",
|
||||
"paragraph" => "\n",
|
||||
other => other,
|
||||
},
|
||||
Some(_) => "",
|
||||
};
|
||||
if sep.is_empty() {
|
||||
return Err(bad(cmd, format!("`{key}` must be tab, comma, paragraph or the separator's characters")));
|
||||
}
|
||||
let sep = if str_param(p, "columnSeparator") == Some("comma") { ',' } else { '\t' };
|
||||
Ok(sep.to_string())
|
||||
}
|
||||
|
||||
pub(super) fn convert_from_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "table.convertFromText";
|
||||
let t = s.doc()?.selection.text.ok_or_else(|| bad(ID, "no text selected"))?;
|
||||
if t.cell.is_some() {
|
||||
return Err(bad(ID, "select text outside tables"));
|
||||
}
|
||||
let (col_sep, row_sep) = (separator(p, "columnSeparator", "\t", ID)?, separator(p, "rowSeparator", "\n", ID)?);
|
||||
// With one separator for both, InDesign fills rows of Number of Columns cells.
|
||||
let per_row = match p.get("columns") {
|
||||
None | Some(Value::Null) => 1,
|
||||
Some(v) => v
|
||||
.as_u64()
|
||||
.filter(|n| (1..=MAX_COLUMNS).contains(n))
|
||||
.ok_or_else(|| bad(ID, format!("`columns` must be a whole number from 1 to {MAX_COLUMNS}")))? as usize,
|
||||
};
|
||||
let style = table_style_param(s, p, ID)?;
|
||||
let width = p.get("width").and_then(Value::as_f64).unwrap_or_else(|| column_width(s, &t));
|
||||
s.edit(|d, sel| {
|
||||
let id = d.alloc();
|
||||
@@ -62,11 +120,33 @@ pub(super) fn convert_from_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad("table.convertFromText", "the selection contains a table"));
|
||||
}
|
||||
let (a, b) = (ranges[pa].start, ranges[pb].end);
|
||||
let data: Vec<Vec<String>> = st.text[a..b].split('\n').map(|line| line.split(sep).map(|c| c.trim().to_string()).collect()).collect();
|
||||
let text = &st.text[a..b];
|
||||
let data: Vec<Vec<String>> = if col_sep == row_sep {
|
||||
let cells: Vec<String> = text.split(col_sep.as_str()).map(|c| c.trim().to_string()).collect();
|
||||
cells.chunks(per_row).map(<[String]>::to_vec).collect()
|
||||
} else {
|
||||
text.split(row_sep.as_str()).map(|row| row.split(col_sep.as_str()).map(|c| c.trim().to_string()).collect()).collect()
|
||||
};
|
||||
// The table is as wide as its widest row; refuse what no table here could hold.
|
||||
let cols = data.iter().map(Vec::len).max().unwrap_or(1) as u64;
|
||||
if cols > MAX_COLUMNS {
|
||||
return Err(bad(ID, format!("the text makes {cols} columns; a table has at most {MAX_COLUMNS}")));
|
||||
}
|
||||
if cols * data.len() as u64 > MAX_CONVERTED_CELLS {
|
||||
return Err(bad(ID, format!("the text makes more than {MAX_CONVERTED_CELLS} cells")));
|
||||
}
|
||||
// `Table::new` would clamp the shape and drop the rows past it, with their text.
|
||||
let (rows, _) = designcraft_doc::clamped_size(data.len(), cols as usize);
|
||||
if rows < data.len() {
|
||||
return Err(bad(ID, format!("the text makes {} rows; a table of {cols} columns has at most {rows}", data.len())));
|
||||
}
|
||||
let pf = st.paras[pa].clone();
|
||||
let cf = st.format_after(a).clone();
|
||||
let mut table = Table::from_strings(id, &data, width, &pf, &cf);
|
||||
table.options.direction = direction;
|
||||
if let Some((ts, cells)) = &style {
|
||||
ts.apply_to(&mut table, cells);
|
||||
}
|
||||
st.delete(a..b);
|
||||
let anchor = st.insert_table(a, table);
|
||||
*sel = Selection::text(TextSel::caret(t.story, anchor));
|
||||
@@ -76,9 +156,11 @@ pub(super) fn convert_from_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
pub(super) fn convert_to_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let g = target(s, p, "table.convertToText")?;
|
||||
let col_sep = separator(p, "columnSeparator", "\t", "table.convertToText")?;
|
||||
let row_sep = separator(p, "rowSeparator", "\n", "table.convertToText")?;
|
||||
s.edit(|d, sel| {
|
||||
let st = d.story_mut(g.story).ok_or_else(|| bad("table.convertToText", "no story"))?;
|
||||
let text = st.tables.get(&g.table).map(|t| t.plain_text()).unwrap_or_default();
|
||||
let text = st.tables.get(&g.table).map(|t| t.text_with(&col_sep, &row_sep)).unwrap_or_default();
|
||||
let a = st.table_anchor(g.table).ok_or_else(|| bad("table.convertToText", "no anchor"))?;
|
||||
st.replace(a..a + designcraft_doc::TABLE_ANCHOR.len_utf8(), &text);
|
||||
*sel = Selection::text(TextSel::caret(g.story, a + text.len()));
|
||||
|
||||
@@ -25,7 +25,13 @@ pub(super) fn in_table_or_ids(s: &Session) -> std::result::Result<(), String> {
|
||||
|
||||
pub(super) fn in_cell(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
if s.active().is_some_and(|d| d.selection.text.is_some_and(|t| t.cell.is_some())) { Ok(()) } else { Err("not in a table cell".into()) }
|
||||
// Footnote text is addressed as a cell too, but isn't in a table.
|
||||
let in_table = |c: CellAddr| c.footnote_id().is_none();
|
||||
if s.active().is_some_and(|d| d.selection.text.is_some_and(|t| t.cell.is_some_and(in_table))) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("not in a table cell".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// The table a command acts on and its target cell range.
|
||||
|
||||
@@ -416,7 +416,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Selection Attributes",
|
||||
[],
|
||||
None,
|
||||
"{mixed?: bool} → resolved character/paragraph attributes at the text selection; with mixed, paraMixed lists the paragraph attributes that differ across the selected paragraphs; the selection's start as `selectedText` (one line, up to 60 characters); `storyOptions` {opticalMargin, opticalMarginSize} of the stories story.options would change (null where they differ)",
|
||||
"{mixed?: bool} → resolved character/paragraph attributes at the text selection; `ownChars`: what the text has of its own over its paragraph (its character style's attributes and its overrides, what a character style made from the selection sets); with mixed, paraMixed lists the paragraph attributes that differ across the selected paragraphs; the selection's start as `selectedText` (one line, up to 60 characters); `storyOptions` {opticalMargin, opticalMarginSize} of the stories story.options would change (null where they differ)",
|
||||
has_doc,
|
||||
selection_attrs
|
||||
),
|
||||
|
||||
@@ -380,6 +380,8 @@ pub(super) fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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 } });
|
||||
// Moving the caret leaves the selected cells, so typing inserts at the caret.
|
||||
stm.selection.cells = None;
|
||||
stm.bump_revision();
|
||||
Ok(json!({"pos": new}))
|
||||
}
|
||||
|
||||
@@ -13,10 +13,13 @@ fn reserve_command_text(st: &mut designcraft_doc::Story, additional: usize) -> R
|
||||
|
||||
pub(super) fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = crate::cmd::text_param(p, "text");
|
||||
// Tab in a table cell moves to the next cell (Shift-Tab: `table.prevCell`).
|
||||
if text == "\t" && s.doc()?.selection.text.is_some_and(|t| t.cell.is_some()) {
|
||||
// Tab in a table cell moves to the next cell (Shift-Tab: `table.prevCell`). Footnote text is
|
||||
// addressed as a cell too: a Tab there is typed.
|
||||
if text == "\t" && s.doc()?.selection.text.is_some_and(|t| t.cell.is_some_and(|c| c.footnote_id().is_none())) {
|
||||
return crate::cmd::table::step_cell(s, true);
|
||||
}
|
||||
// Typing with one cell selected replaces its text, as InDesign.
|
||||
crate::cmd::table::one_selected_cell_to_text(s)?;
|
||||
let raw = p.get("raw").and_then(Value::as_bool).unwrap_or(false);
|
||||
let text = if s.prefs.typographers_quotes && !raw { smart_quotes(s, &text) } else { text };
|
||||
let tracking = s.doc()?.doc.settings.track_changes;
|
||||
|
||||
@@ -125,9 +125,12 @@ pub(super) fn selection_attrs(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let char_delta = character_base.delta(&cp);
|
||||
let style_keys = character_style.keys();
|
||||
let character_style_overrides = char_delta.keys().iter().filter(|key| style_keys.contains(key)).count();
|
||||
// The text's own formatting over its paragraph, as Create New Style from Selection captures it.
|
||||
let mut own = character_style.clone();
|
||||
own.merge(&cf.over);
|
||||
// The selected text (its start, on one line), for previews such as the font menu's.
|
||||
let selected = selected_text_preview(story.text.get(r.start..r.end).unwrap_or_default().chars());
|
||||
let mut out = json!({"story": sid.0, "paragraphStyle": pf.style, "characterStyle": cf.style, "para": pp, "chars": cp,
|
||||
let mut out = json!({"story": sid.0, "paragraphStyle": pf.style, "characterStyle": cf.style, "para": pp, "chars": cp, "ownChars": own,
|
||||
"paraOverrides": style_para.delta(&pp).count() + style_chars.delta(&base).count(),
|
||||
"charOverrides": char_delta.count(), "characterStyleOverrides": character_style_overrides,
|
||||
"selectedText": selected.trim()});
|
||||
|
||||
@@ -12,6 +12,7 @@ use serde_json::{Value, json};
|
||||
use super::{CommandSpec, bad, cmd, has_doc, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// Register TOC generation and refresh commands.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
@@ -19,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Table of Contents…",
|
||||
["Layout"],
|
||||
None,
|
||||
"{entries: [{style, level?: 1 (1-9), entryStyle?: paragraph style of its entries (default \"TOC Level n\"), pageNumber?: \"after\"|\"before\"|\"none\", separator?: text between entry and number (default a tab), pageNumberStyle?: character style of the page number, separatorStyle?: character style of the separator, sort?: bool (Sort Entries in Alphabetical Order: among their siblings, each with the entries under it)}] | styles: [names] (levels 1, 2, …), title?: \"Contents\", titleStyle?: paragraph style of the title (default \"TOC Title\"), pageNumbers?: true, replace?: true (regenerate the existing table of contents in place; false makes a new frame), page?: 1-based page for a new frame (its margins), rect?: [x0,y0,x1,y1] (spread coords)} → {story, entries}",
|
||||
"{entries: [{style, level?: 1 (1-9), entryStyle?: paragraph style of its entries (default \"TOC Level n\"), pageNumber?: \"after\"|\"before\"|\"none\", separator?: text between entry and number (default a tab), pageNumberStyle?: character style of the page number, separatorStyle?: character style of the separator, sort?: bool (Sort Entries in Alphabetical Order: among their siblings, each with the entries under it)}] | styles: [names] (levels 1, 2, …), title?: \"Contents\", titleStyle?: paragraph style of the title (default \"TOC Title\"), pageNumbers?: true, createBookmarks?: bool (generate PDF bookmarks; keeps the existing setting when omitted, which is off for a new table), replace?: true (regenerate the existing table of contents in place; false makes a new frame), page?: 1-based page for a new frame (its margins), rect?: [x0,y0,x1,y1] (spread coords)} → {story, entries}",
|
||||
has_doc,
|
||||
generate
|
||||
),
|
||||
@@ -302,9 +303,30 @@ fn fill(d: &mut Document, toc: &Toc, width: Option<f64>) -> Result<usize> {
|
||||
st.format_paras(r.start..r.start, |f| *f = ParaFormat { style: name.clone(), ..Default::default() });
|
||||
}
|
||||
}
|
||||
let insertion = d.bookmarks.iter().position(|b| b.generated_by_toc).unwrap_or(d.bookmarks.len());
|
||||
d.bookmarks.retain(|b| !b.generated_by_toc);
|
||||
if toc.create_bookmarks {
|
||||
d.bookmarks.splice(insertion..insertion, bookmarks(&found));
|
||||
}
|
||||
Ok(found.len())
|
||||
}
|
||||
|
||||
/// Levels are validated to 1–9. Recursion follows strictly increasing levels, including when
|
||||
/// a document skips a level; each destination is the physical page, not its section label.
|
||||
fn bookmarks(found: &[Found]) -> Vec<designcraft_doc::Bookmark> {
|
||||
/// Collect descendants until the next sibling or ancestor entry.
|
||||
fn children(iter: &mut std::iter::Peekable<std::slice::Iter<'_, Found>>, parent: u8) -> Vec<designcraft_doc::Bookmark> {
|
||||
let mut out = Vec::new();
|
||||
while iter.peek().is_some_and(|f| f.level > parent) {
|
||||
let Some(f) = iter.next() else { break };
|
||||
out.push(designcraft_doc::Bookmark { name: f.text.clone(), page: f.key.0, children: children(iter, f.level), generated_by_toc: true });
|
||||
}
|
||||
out
|
||||
}
|
||||
children(&mut found.iter().peekable(), 0)
|
||||
}
|
||||
|
||||
/// Validate the TOC settings and generate its frame, text, and outline in one edit.
|
||||
fn generate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let entries = entries_param(p)?;
|
||||
if entries.is_empty() {
|
||||
@@ -320,6 +342,10 @@ fn generate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad("toc.generate", format!("style names are at most {MAX_NAME} characters")));
|
||||
}
|
||||
let page_numbers = p.get("pageNumbers").and_then(Value::as_bool).unwrap_or(true);
|
||||
let create_bookmarks = match p.get("createBookmarks") {
|
||||
Some(v) => v.as_bool().ok_or_else(|| bad("toc.generate", "createBookmarks must be a boolean"))?,
|
||||
None => s.doc()?.doc.toc.as_ref().is_some_and(|t| t.create_bookmarks),
|
||||
};
|
||||
let replace = p.get("replace").and_then(Value::as_bool).unwrap_or(true);
|
||||
let rect = super::rect_param(p, "rect");
|
||||
if rect.is_some_and(|r| ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) || r.width() <= 0.0 || r.height() <= 0.0) {
|
||||
@@ -345,7 +371,7 @@ fn generate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(sid, w)
|
||||
}
|
||||
};
|
||||
let toc = Toc { story, title: title.clone(), entries: entries.clone(), page_numbers, title_style: title_style.clone() };
|
||||
let toc = Toc { story, title: title.clone(), entries: entries.clone(), page_numbers, title_style: title_style.clone(), create_bookmarks };
|
||||
let n = fill(d, &toc, width)?;
|
||||
d.toc = Some(toc);
|
||||
Ok(json!({"story": story.0, "entries": n}))
|
||||
@@ -623,6 +649,46 @@ mod tests {
|
||||
assert!(toc_runs(&s).iter().any(|(t, st)| t == "1" && st == "Folio"), "{:?}", toc_runs(&s));
|
||||
}
|
||||
|
||||
/// Refresh preserves manual outline positions, nested destinations, storage, and undo.
|
||||
#[test]
|
||||
fn toc_bookmarks_refresh_nested_page_destinations_and_preserve_manual_bookmarks() {
|
||||
let (mut s, page) = doc_with(&[("First", "Head"), ("Child", "Sub"), ("Last", "Head")]);
|
||||
s.execute("layout.section", &json!({"page": 1, "style": "lowerRoman", "startNumber": 4})).unwrap();
|
||||
s.execute("bookmark.add", &json!({"name": "Manual", "page": page})).unwrap();
|
||||
let p = json!({"page": page, "createBookmarks": true, "entries": [
|
||||
{"style": "Head", "level": 1}, {"style": "Sub", "level": 3}
|
||||
]});
|
||||
s.execute("toc.generate", &p).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.bookmarks.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(), ["Manual", "First", "Last"]);
|
||||
assert_eq!(d.bookmarks[1].page, 0, "physical page, although its label is iv");
|
||||
assert_eq!(d.bookmarks[1].children[0].name, "Child");
|
||||
assert!(d.bookmarks[1].children[0].generated_by_toc);
|
||||
let sid = d.stories.iter().find(|(_, st)| st.text.starts_with("First")).map(|(sid, _)| *sid).unwrap();
|
||||
s.execute("bookmark.add", &json!({"name": "Manual after", "page": page})).unwrap();
|
||||
s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 5})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "Changed"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.bookmarks[1].name, "First", "refresh is explicit");
|
||||
s.execute("toc.update", &json!({})).unwrap();
|
||||
let refreshed = s.doc().unwrap().doc.bookmarks.clone();
|
||||
assert_eq!(refreshed.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(), ["Manual", "Changed", "Last", "Manual after"]);
|
||||
s.execute("toc.update", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.bookmarks, refreshed, "updates do not duplicate bookmarks");
|
||||
// Native storage retains both the option and ownership; old bookmarks default to manual.
|
||||
let stored = serde_json::to_value(&s.doc().unwrap().doc).unwrap();
|
||||
let back: Document = serde_json::from_value(stored).unwrap();
|
||||
assert!(back.toc.as_ref().unwrap().create_bookmarks);
|
||||
assert_eq!(back.bookmarks, refreshed);
|
||||
let old: designcraft_doc::Bookmark = serde_json::from_value(json!({"name": "Old", "page": 0})).unwrap();
|
||||
assert!(!old.generated_by_toc);
|
||||
let mut disabled = p;
|
||||
disabled["createBookmarks"] = json!(false);
|
||||
s.execute("toc.generate", &disabled).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.bookmarks.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(), ["Manual", "Manual after"]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.bookmarks, refreshed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toc_entry_options_round_trip_through_the_native_format() {
|
||||
let mut e = TocEntry::new("Head", 2);
|
||||
|
||||
@@ -168,7 +168,7 @@ fn parse(xml: &str) -> std::result::Result<Vec<Element>, String> {
|
||||
_ => String::new(),
|
||||
};
|
||||
match (depth, child.as_mut(), out.last_mut()) {
|
||||
(3, Some(c), _) => c.1.push_str(&s),
|
||||
(3.., Some(c), _) => c.1.push_str(&s),
|
||||
(2, _, Some(el)) => el.text.push_str(&s),
|
||||
_ => {}
|
||||
}
|
||||
@@ -518,6 +518,34 @@ mod tests {
|
||||
assert_eq!(s.doc().unwrap().doc.xml.style_map.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_xml_keeps_text_of_inline_tags_in_mapped_paragraphs() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("style.paragraph.create", &json!({"name": "Head"})).unwrap();
|
||||
let a = s
|
||||
.execute("frame.create", &json!({"rect": [72, 72, 400, 150], "content": "text", "text": "See the glossary entry", "caret": false}))
|
||||
.unwrap();
|
||||
s.execute("text.select", &json!({"story": a["story"], "anchor": 0, "focus": 0})).unwrap();
|
||||
s.execute("style.paragraph.apply", &json!({"name": "Head"})).unwrap();
|
||||
s.execute("xml.tag", &json!({"tag": "story", "ids": [a["id"]]})).unwrap();
|
||||
s.execute("xml.mapStyle", &json!({"style": "Head", "tag": "title"})).unwrap();
|
||||
s.execute("text.select", &json!({"story": a["story"], "anchor": 8, "focus": 16})).unwrap();
|
||||
s.execute("xml.tagText", &json!({"tag": "term"})).unwrap();
|
||||
let x = s.execute("file.exportXml", &json!({})).unwrap()["text"].as_str().unwrap().to_string();
|
||||
assert!(x.contains("<title>See the <term>glossary</term> entry</title>"), "{x}");
|
||||
s.execute("file.importXml", &json!({"text": x})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.stories[&designcraft_doc::StoryId(a["story"].as_u64().unwrap())].text, "See the glossary entry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_keeps_text_nested_below_a_child_element() {
|
||||
let els = super::parse("<root><story><note>See the <term>x</term> y</note></story></root>").unwrap();
|
||||
assert_eq!(els.len(), 1);
|
||||
assert_eq!(els[0].children, vec![("note".to_string(), "See the x y".to_string())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_export_and_import_xml() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -250,6 +250,61 @@ pub struct Prefs {
|
||||
pub recent_files: Vec<String>,
|
||||
/// Preferences › File Handling › Number of Recent Items to Display (0 keeps no list).
|
||||
pub recent_files_max: u32,
|
||||
/// Preferences › Units & Increments set with no document open: what new documents start
|
||||
/// with, as InDesign's application defaults. A document keeps its own once made.
|
||||
pub default_units: DefaultUnits,
|
||||
}
|
||||
|
||||
/// Units & Increments for new documents (Preferences with no document open), in the keys
|
||||
/// `document.preferences` uses.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct DefaultUnits {
|
||||
pub horizontal_units: designcraft_geom::Unit,
|
||||
pub vertical_units: designcraft_geom::Unit,
|
||||
pub ruler_origin: designcraft_doc::RulerOrigin,
|
||||
/// Cursor Key, in points.
|
||||
pub keyboard_increment: f64,
|
||||
/// Kerning/Tracking, in 1/1000 em.
|
||||
pub kerning_increment: f64,
|
||||
}
|
||||
|
||||
impl Default for DefaultUnits {
|
||||
/// A new document's own settings.
|
||||
fn default() -> Self {
|
||||
let d = designcraft_doc::DocSettings::default();
|
||||
DefaultUnits {
|
||||
horizontal_units: designcraft_geom::Unit::Picas,
|
||||
vertical_units: designcraft_geom::Unit::Picas,
|
||||
ruler_origin: d.ruler_origin,
|
||||
keyboard_increment: d.keyboard_increment,
|
||||
kerning_increment: d.kerning_increment,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DefaultUnits {
|
||||
/// Gives a new document these defaults. Unless it was made from a preset (whose units are its
|
||||
/// own), its vertical ruler follows the preference while it keeps the default horizontal
|
||||
/// units (units picked in New Document set both rulers).
|
||||
pub fn apply(&self, settings: &mut designcraft_doc::DocSettings, preset: bool) {
|
||||
if !preset && settings.horizontal_units == self.horizontal_units {
|
||||
settings.vertical_units = self.vertical_units;
|
||||
}
|
||||
settings.ruler_origin = self.ruler_origin;
|
||||
settings.keyboard_increment = self.keyboard_increment;
|
||||
settings.kerning_increment = self.kerning_increment;
|
||||
}
|
||||
|
||||
/// Refuses increments that aren't finite and positive (at most 1000).
|
||||
pub fn check(&self) -> std::result::Result<(), String> {
|
||||
for (name, v) in [("keyboardIncrement", self.keyboard_increment), ("kerningIncrement", self.kerning_increment)] {
|
||||
if !(v.is_finite() && v > 0.0 && v <= 1000.0) {
|
||||
return Err(format!("`defaultUnits.{name}` must be more than 0 and at most 1000"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The most recent files Preferences lets File › Open Recent keep.
|
||||
@@ -324,6 +379,7 @@ impl Default for Prefs {
|
||||
link_text_files: false,
|
||||
recent_files: Vec::new(),
|
||||
recent_files_max: 20,
|
||||
default_units: DefaultUnits::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -486,6 +486,63 @@ fn place_gun_click_drag_and_into_frame() {
|
||||
assert_eq!(graphic_xf(&s), placed);
|
||||
}
|
||||
|
||||
/// A place-cursor drag draws the frame at the graphic's proportions from where it started, as
|
||||
/// InDesign does, shows its scale, and places exactly that frame, in any direction.
|
||||
#[test]
|
||||
fn place_gun_drag_previews_and_places_a_proportional_frame() {
|
||||
use designcraft_tools::{Cursor, Mods, Overlay, PointerEvent, PointerKind};
|
||||
let mut s = session();
|
||||
let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png();
|
||||
s.execute("place.load", &json!({"base64": cmd::base64_encode(&png), "name": "a.png"})).unwrap();
|
||||
let v = ViewInfo::at_zoom(1.0);
|
||||
let p = designcraft_geom::Point::new;
|
||||
assert_eq!(s.cursor(p(300.0, 300.0), Mods::default(), v), Cursor::LoadedGraphic);
|
||||
// Up and to the left: 80 × 200 holds the 40 × 20 graphic at 200 %, 80 × 40, hanging from the press.
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 300.0, 300.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 220.0, 100.0), v).unwrap();
|
||||
assert_eq!(s.cursor(p(220.0, 100.0), Mods::default(), v), Cursor::Crosshair, "the crosshair while drawing");
|
||||
let o = s.overlays(v);
|
||||
assert!(o.iter().any(|o| matches!(o, Overlay::Marquee(r) if *r == designcraft_geom::Rect::new(220.0, 260.0, 300.0, 300.0))), "{o:?}");
|
||||
assert!(
|
||||
o.iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == "X: 200.00%
|
||||
Y: 200.00%")),
|
||||
"{o:?}"
|
||||
);
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 220.0, 100.0), v).unwrap();
|
||||
let it = s.doc().unwrap().doc.spreads[0].items.last().unwrap().clone();
|
||||
assert_eq!(it.bounds(), designcraft_geom::Rect::new(220.0, 260.0, 300.0, 300.0), "the frame previewed");
|
||||
// 80 × 1 is past the drag tolerance, but its 2 × 1 proportional frame is too small for
|
||||
// `place.drop`, which places at natural size: the preview shows that frame and 100 %.
|
||||
s.execute("place.load", &json!({"base64": cmd::base64_encode(&png), "name": "a.png"})).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 400.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 180.0, 401.0), v).unwrap();
|
||||
let natural = designcraft_geom::Rect::new(100.0, 400.0, 140.0, 420.0);
|
||||
let o = s.overlays(v);
|
||||
assert!(o.iter().any(|o| matches!(o, Overlay::Marquee(r) if *r == natural)), "{o:?}");
|
||||
assert!(
|
||||
o.iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == "X: 100.00%
|
||||
Y: 100.00%")),
|
||||
"{o:?}"
|
||||
);
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 180.0, 401.0), v).unwrap();
|
||||
let it = s.doc().unwrap().doc.spreads[0].items.last().unwrap().clone();
|
||||
assert_eq!(it.bounds(), natural, "the frame previewed");
|
||||
// A drag under the threshold is a click: no frame drawn, the graphic at actual size where
|
||||
// the press was.
|
||||
s.execute("place.load", &json!({"base64": cmd::base64_encode(&png), "name": "a.png"})).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 500.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 102.5, 501.25), v).unwrap();
|
||||
assert!(!s.overlays(v).iter().any(|o| matches!(o, Overlay::Marquee(_))), "no frame yet");
|
||||
assert_eq!(s.cursor(p(102.5, 501.25), Mods::default(), v), Cursor::LoadedGraphic, "the thumbnail stays");
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 102.5, 501.25), v).unwrap();
|
||||
let it = s.doc().unwrap().doc.spreads[0].items.last().unwrap().clone();
|
||||
assert_eq!(it.bounds(), designcraft_geom::Rect::new(100.0, 500.0, 140.0, 520.0), "actual size at the press");
|
||||
// With no graphic loaded there's no thumbnail to show: the crosshair, before any press too.
|
||||
s.loaded = None;
|
||||
s.set_tool("placeGun");
|
||||
assert_eq!(s.cursor(p(300.0, 300.0), Mods::default(), v), Cursor::Crosshair);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preflight_reports_overset_and_missing_fonts() {
|
||||
let mut s = session();
|
||||
@@ -877,6 +934,9 @@ fn custom_wrap_path_follows_resize_and_yields_to_offsets() {
|
||||
s.execute("transform.resize", &json!({"from": [b.x0, b.y0, b.x1, b.y1], "to": to})).unwrap();
|
||||
let w = wrap_path(&s).unwrap().bounds().unwrap();
|
||||
assert!((w.width() - 80.0).abs() < 1e-6 && (w.height() - 100.0).abs() < 1e-6, "{w:?}");
|
||||
// Offsets that are all null change nothing, so the boundary stays.
|
||||
s.execute("object.textWrap", &json!({"mode": null, "offset": [null, null, null, null]})).unwrap();
|
||||
assert!(wrap_path(&s).is_some(), "an all-null offset keeps the custom boundary");
|
||||
// An offset rebuilds the wrap from the frame.
|
||||
s.execute("object.textWrap", &json!({"offset": 6})).unwrap();
|
||||
assert_eq!(wrap_path(&s), None);
|
||||
@@ -1661,6 +1721,239 @@ fn a_line_fits_when_its_baseline_does() {
|
||||
assert!(overset(&mut s, 50.0), "a baseline below the frame still oversets the line");
|
||||
}
|
||||
|
||||
/// Reset To Base with a new Based On: a partial rule edit resolves over the new parent, not the
|
||||
/// old one.
|
||||
#[test]
|
||||
fn reset_to_base_with_a_new_parent_resolves_rule_edits_over_it() {
|
||||
let mut s = session();
|
||||
let rule = |weight: f64, offset: f64| json!({"on": true, "weight": weight, "color": "[Black]", "offset": offset});
|
||||
s.execute("style.paragraph.create", &json!({"name": "Old", "para": {"ruleBelow": rule(3.0, 1.0)}})).unwrap();
|
||||
s.execute("style.paragraph.create", &json!({"name": "New", "para": {"ruleBelow": rule(1.0, 9.0)}})).unwrap();
|
||||
s.execute("style.paragraph.create", &json!({"name": "Child", "basedOn": "Old"})).unwrap();
|
||||
s.execute("style.paragraph.edit", &json!({"name": "Child", "resetToBase": true, "basedOn": "New", "para": {"ruleBelow": {"weight": 2}}}))
|
||||
.unwrap();
|
||||
let r = s.doc().unwrap().doc.styles.resolve_para_style("Child").0.rule_below;
|
||||
assert_eq!((r.weight, r.offset), (2.0, 9.0), "the offset is the new parent's");
|
||||
}
|
||||
|
||||
/// Paragraph style references are checked: a Based On or Next Style that names no style, or
|
||||
/// that isn't a name or null, is refused and changes nothing.
|
||||
#[test]
|
||||
fn paragraph_style_references_must_name_styles() {
|
||||
let mut s = session();
|
||||
s.execute("style.paragraph.create", &json!({"name": "Body"})).unwrap();
|
||||
assert!(s.execute("style.paragraph.create", &json!({"name": "Lost", "nextStyle": "Nowhere"})).is_err());
|
||||
assert!(s.doc().unwrap().doc.styles.para("Lost").is_none());
|
||||
// On creation too, a reference that isn't a name or null is refused, not dropped.
|
||||
for p in [json!({"name": "Lost", "nextStyle": true}), json!({"name": "Lost", "basedOn": 3})] {
|
||||
assert!(s.execute("style.paragraph.create", &p).is_err(), "{p}");
|
||||
assert!(s.doc().unwrap().doc.styles.para("Lost").is_none(), "{p}");
|
||||
}
|
||||
assert!(s.execute("style.character.create", &json!({"name": "Lost", "basedOn": ["Body"]})).is_err());
|
||||
assert!(s.doc().unwrap().doc.styles.char_style("Lost").is_none());
|
||||
s.execute("style.paragraph.create", &json!({"name": "Self", "nextStyle": "Self"})).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
for p in [json!({"name": "Body", "basedOn": "Nowhere"}), json!({"name": "Body", "basedOn": 3}), json!({"name": "Body", "nextStyle": true})] {
|
||||
assert!(s.execute("style.paragraph.edit", &p).is_err(), "{p}");
|
||||
}
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc), "nothing changed");
|
||||
s.execute("style.paragraph.edit", &json!({"name": "Body", "basedOn": null, "nextStyle": "Self"})).unwrap();
|
||||
}
|
||||
|
||||
/// The course's chapter 43: with Direct Selection, a circle's selected anchor shows its handles
|
||||
/// and they drag, reshaping the curve; the handles of the anchors away from it neither show nor
|
||||
/// catch a press, as in InDesign.
|
||||
#[test]
|
||||
fn direct_selection_drags_only_the_handles_around_the_selected_anchor() {
|
||||
use designcraft_tools::{PointerEvent, PointerKind};
|
||||
let mut s = session();
|
||||
let v = ViewInfo::at_zoom(1.0);
|
||||
let id = s.execute("frame.create", &json!({"rect": [100, 300, 300, 500], "shape": "ellipse"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("selection.set", &json!({"ids": [id]})).unwrap();
|
||||
s.set_tool("directSelection");
|
||||
// Each anchor and its two handles, in the spread.
|
||||
let anchors = |s: &Session| {
|
||||
let st = s.doc().unwrap();
|
||||
let it = st.doc.item(designcraft_doc::ItemId(id)).unwrap();
|
||||
it.path.subpaths[0].anchors.iter().map(|a| (it.xf * a.p, it.xf * a.h_in, it.xf * a.h_out)).collect::<Vec<_>>()
|
||||
};
|
||||
let before = anchors(&s);
|
||||
let lowest = |f: &dyn Fn(f64) -> f64| (0..before.len()).min_by(|a, b| f(before[*a].0.y).total_cmp(&f(before[*b].0.y))).unwrap();
|
||||
let (bottom, top) = (lowest(&|y| -y), lowest(&|y| y));
|
||||
let press = |s: &mut Session, from: designcraft_geom::Point, to: designcraft_geom::Point| {
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, from.x, from.y), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, (from.x + to.x) / 2.0, (from.y + to.y) / 2.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, to.x, to.y), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, to.x, to.y), v).unwrap();
|
||||
};
|
||||
// Select the bottom anchor.
|
||||
let b = before[bottom].0;
|
||||
press(&mut s, b, b);
|
||||
assert_eq!(s.doc().unwrap().selection.anchors, [(designcraft_doc::ItemId(id), 0, bottom)]);
|
||||
// Its handle drags: the handle moves.
|
||||
let h = before[bottom].2;
|
||||
press(&mut s, h, h + designcraft_geom::Vec2::new(30.0, 0.0));
|
||||
let after = anchors(&s);
|
||||
assert!((after[bottom].2 - before[bottom].2).hypot() > 20.0, "the bottom handle moved: {:?} → {:?}", before[bottom], after[bottom]);
|
||||
// The top anchor's handle isn't shown, so a press on it doesn't take it.
|
||||
let t = before[top].2;
|
||||
press(&mut s, t, t + designcraft_geom::Vec2::new(0.0, -30.0));
|
||||
let after2 = anchors(&s);
|
||||
assert!((after2[top].2 - before[top].2).hypot() < 1e-6, "the top handle stays: {:?} → {:?}", before[top], after2[top]);
|
||||
}
|
||||
|
||||
/// Direct Selection: a marquee over the top edges of two frames selects their top points, and a
|
||||
/// drag on one of them slants both frames in one undo step (InDesign's way to make a row of
|
||||
/// parallelograms).
|
||||
#[test]
|
||||
fn direct_selection_moves_the_points_of_several_frames_at_once() {
|
||||
use designcraft_tools::{Mods, PointerEvent, PointerKind};
|
||||
let mut s = session();
|
||||
let v = ViewInfo::at_zoom(1.0);
|
||||
let a = s.execute("frame.create", &json!({"rect": [100, 300, 200, 400]})).unwrap()["id"].as_u64().unwrap();
|
||||
let b = s.execute("frame.create", &json!({"rect": [200, 300, 300, 400]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("selection.set", &json!({"ids": []})).unwrap();
|
||||
s.set_tool("directSelection");
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 80.0, 280.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 320.0, 320.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 320.0, 320.0), v).unwrap();
|
||||
let sel = s.doc().unwrap().selection.clone();
|
||||
assert_eq!(sel.anchors.len(), 4, "{:?}", sel.anchors);
|
||||
assert!(sel.items.contains(&designcraft_doc::ItemId(a)) && sel.items.contains(&designcraft_doc::ItemId(b)));
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 300.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 136.0, 302.0).with_mods(shift), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 136.0, 302.0).with_mods(shift), v).unwrap();
|
||||
let corners = |s: &Session, id: u64| -> Vec<(f64, f64)> {
|
||||
let st = s.doc().unwrap();
|
||||
let it = st.doc.item(designcraft_doc::ItemId(id)).unwrap();
|
||||
it.path.subpaths[0].anchors.iter().map(|an| ((it.xf * an.p).x.round(), (it.xf * an.p).y.round())).collect()
|
||||
};
|
||||
// Both frames: top points 36 pt to the right, bottom points where they were.
|
||||
assert_eq!(corners(&s, a), vec![(136.0, 300.0), (236.0, 300.0), (200.0, 400.0), (100.0, 400.0)]);
|
||||
assert_eq!(corners(&s, b), vec![(236.0, 300.0), (336.0, 300.0), (300.0, 400.0), (200.0, 400.0)]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(corners(&s, a), vec![(100.0, 300.0), (200.0, 300.0), (200.0, 400.0), (100.0, 400.0)]);
|
||||
assert_eq!(corners(&s, b), vec![(200.0, 300.0), (300.0, 300.0), (300.0, 400.0), (200.0, 400.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_points_follow_their_path() {
|
||||
let mut s = session();
|
||||
let a = s.execute("frame.create", &json!({"rect": [100, 300, 200, 400]})).unwrap()["id"].as_u64().unwrap();
|
||||
let b = s.execute("frame.create", &json!({"rect": [200, 300, 300, 400]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("selection.set", &json!({"anchors": [[a, 0, 1], [b, 0, 2]]})).unwrap();
|
||||
// Shift-clicking a frame off the selection drops its points too.
|
||||
s.execute("selection.toggle", &json!({"id": a})).unwrap();
|
||||
let id = |n| designcraft_doc::ItemId(n);
|
||||
assert_eq!(s.doc().unwrap().selection.anchors, vec![(id(b), 0, 2)]);
|
||||
// A point added before the first one: the selected point keeps pointing at the same anchor.
|
||||
let l = s.execute("path.create", &json!({"anchors": [{"p": [100, 100]}, {"p": [200, 100]}, {"p": [300, 150]}]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("selection.set", &json!({"anchors": [[l, 0, 1]]})).unwrap();
|
||||
s.execute("path.appendAnchor", &json!({"id": l, "anchor": {"p": [50, 120]}, "at": "start"})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let (_, si, ai) = st.selection.anchors[0];
|
||||
let it = st.doc.item(id(l)).unwrap();
|
||||
assert_eq!((it.xf * it.path.subpaths[si].anchors[ai].p).round(), designcraft_geom::Point::new(200.0, 100.0));
|
||||
// `points` that isn't a list is refused, even with an `id` beside it.
|
||||
for bad in [json!("x"), json!({"0": 1}), json!(5)] {
|
||||
assert!(s.execute("path.moveAnchors", &json!({"points": bad, "id": l, "anchors": [[0, 0]], "dx": 5})).is_err(), "points {bad}");
|
||||
}
|
||||
for bad in [json!(3), json!(-1), json!("0")] {
|
||||
assert!(s.execute("path.appendAnchor", &json!({"id": l, "anchor": {"p": [0, 0]}, "subpath": bad})).is_err(), "subpath {bad}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().doc.item(id(l)).unwrap().path.subpaths[0].anchors.len(), 4, "nothing appended");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_selection_pulls_one_corner_of_an_unselected_frame() {
|
||||
// Chris Nielsen's "The Adobe InDesign Toolbox" (10:10): with the White Arrow, click outside,
|
||||
// then press on a frame's corner and pull it: only that corner moves (a triangle, nearly).
|
||||
use designcraft_tools::{PointerEvent, PointerKind, SnapView};
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 612, "height": 792, "facingPages": false})).unwrap();
|
||||
let id = s.execute("frame.create", &json!({"rect": [156, 219, 455, 569], "content": "graphic"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("edit.deselectAll", &json!({})).unwrap();
|
||||
let corners = |s: &Session| {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let it = d.item(designcraft_doc::ItemId(id)).unwrap();
|
||||
it.path.subpaths[0].anchors.iter().map(|a| it.xf * a.p).collect::<Vec<_>>()
|
||||
};
|
||||
let before = corners(&s);
|
||||
s.set_tool("directSelection");
|
||||
let v = ViewInfo { snap: SnapView::OFF, ..ViewInfo::at_zoom(1.0) };
|
||||
let layout = s.layout();
|
||||
let at = |x: f64, y: f64| layout.to_canvas(designcraft_doc::SpreadRef::Doc(0), designcraft_geom::Point::new(x, y));
|
||||
let (a, b) = (at(455.0, 219.0), at(455.0, 150.0));
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, a.x, a.y), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, a.x, a.y - 30.0), 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 after = corners(&s);
|
||||
let moved: Vec<_> = before.iter().zip(&after).filter(|(p, q)| (**p - **q).hypot() > 1e-6).collect();
|
||||
assert_eq!(moved.len(), 1, "one corner moved: {before:?} → {after:?}");
|
||||
assert!((*moved[0].1 - designcraft_geom::Point::new(455.0, 150.0)).hypot() < 1e-6, "{after:?}");
|
||||
assert_eq!(s.doc().unwrap().selection.items, [designcraft_doc::ItemId(id)], "the frame is selected");
|
||||
// One undo step puts the corner back.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(corners(&s), before);
|
||||
// Away from its corners, the White Arrow still drags the whole unselected frame.
|
||||
s.execute("edit.deselectAll", &json!({})).unwrap();
|
||||
let (c, d) = (at(300.0, 219.0), at(300.0, 269.0));
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, c.x, c.y), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, c.x, c.y + 20.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, d.x, d.y), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, d.x, d.y), v).unwrap();
|
||||
assert!(corners(&s).iter().zip(&before).all(|(p, q)| (p.y - q.y - 50.0).abs() < 1e-6), "{:?}", corners(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_selection_on_a_selected_frame_deletes_only_its_image() {
|
||||
use designcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let png = dir.path().join("photo.png");
|
||||
std::fs::write(&png, designcraft_render::Rendered { width: 100, height: 100, pixels: vec![40u8; 100 * 100 * 4] }.to_png()).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let frame = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("file.place", &json!({"path": png.to_string_lossy(), "frame": frame})).unwrap();
|
||||
s.execute("selection.set", &json!({"ids": [frame], "content": false})).unwrap();
|
||||
let v = ViewInfo::at_zoom(1.0);
|
||||
let click = |s: &mut Session| {
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 110.0, 150.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 110.0, 150.0), v).unwrap();
|
||||
};
|
||||
// The frame is selected with the Selection tool, then the image is clicked with Direct Selection.
|
||||
s.set_tool("selection");
|
||||
click(&mut s);
|
||||
assert!(!s.doc().unwrap().selection.content);
|
||||
s.set_tool("directSelection");
|
||||
click(&mut s);
|
||||
assert!(s.doc().unwrap().selection.content, "Direct Selection selects the image inside the selected frame");
|
||||
assert!(s.tool_key(ToolKey::Delete, Mods::default(), v).unwrap());
|
||||
let st = s.doc().unwrap();
|
||||
let it = st.doc.item(designcraft_doc::ItemId(frame)).expect("the frame stays");
|
||||
assert!(matches!(it.content, designcraft_doc::Content::Unassigned), "{:?}", it.content);
|
||||
assert_eq!(st.selection.items, vec![designcraft_doc::ItemId(frame)]);
|
||||
// A frame holding pasted-in items: the click selects them too, so arrow keys move them.
|
||||
let inner = s.execute("frame.create", &json!({"rect": [320, 120, 360, 160]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("selection.set", &json!({"ids": [inner]})).unwrap();
|
||||
s.execute("edit.cut", &json!({})).unwrap();
|
||||
let holder = s.execute("frame.create", &json!({"rect": [300, 100, 400, 200]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("selection.set", &json!({"ids": [holder]})).unwrap();
|
||||
s.execute("edit.pasteInto", &json!({})).unwrap();
|
||||
s.set_tool("selection");
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 340.0, 140.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 340.0, 140.0), v).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.items, vec![designcraft_doc::ItemId(holder)]);
|
||||
assert!(!s.doc().unwrap().selection.content);
|
||||
s.set_tool("directSelection");
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 340.0, 140.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 340.0, 140.0), v).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
assert!(st.selection.content && st.selection.items == [designcraft_doc::ItemId(holder)], "the pasted-in items are selected: {:?}", st.selection);
|
||||
}
|
||||
|
||||
/// Issue #1637: with several objects selected, a click on one of them (no drag) makes it the key
|
||||
/// object, and Align to Key Object leaves it in place. A second click on it drops the key.
|
||||
#[test]
|
||||
|
||||
@@ -516,6 +516,7 @@ fn type_para_sets_a_drop_cap_that_survives_idml() {
|
||||
assert_eq!(p.nested_styles, Some(vec![designcraft_doc::NestedStyle::drop_cap("Initial")]));
|
||||
}
|
||||
|
||||
/// Imported TOC definitions retain their settings and remain refreshable.
|
||||
#[test]
|
||||
fn table_of_contents_definition_round_trips_through_idml_and_updates() {
|
||||
let mut s = Session::new();
|
||||
@@ -525,8 +526,8 @@ fn table_of_contents_definition_round_trips_through_idml_and_updates() {
|
||||
let basic = designcraft_doc::story::BASIC_PARAGRAPH;
|
||||
s.execute(
|
||||
"toc.generate",
|
||||
&json!({"page": page, "title": "Inside", "titleStyle": basic, "entries": [
|
||||
{"style": "Cover Title", "level": 1, "pageNumber": "before", "separator": " ^ "},
|
||||
&json!({"page": page, "title": "Inside", "titleStyle": basic, "createBookmarks": true, "entries": [
|
||||
{"style": "Cover Title", "level": 1, "pageNumber": "before", "separator": " ^ \u{E006} "},
|
||||
{"style": "Headline", "level": 2, "entryStyle": basic, "pageNumber": "none"},
|
||||
]}),
|
||||
)
|
||||
@@ -540,6 +541,7 @@ fn table_of_contents_definition_round_trips_through_idml_and_updates() {
|
||||
let b64 = s.execute("file.exportIdml", &json!({})).unwrap()["base64"].as_str().unwrap().to_string();
|
||||
s.execute("file.openIdml", &json!({"base64": b64, "name": "toc.idml"})).unwrap();
|
||||
let after = s.doc().unwrap().doc.toc.clone().expect("the TOC definition is imported");
|
||||
assert!(after.create_bookmarks);
|
||||
assert_eq!((&after.title, &after.title_style, &after.entries), (&before.title, &before.title_style, &before.entries));
|
||||
assert_eq!(toc_text(&s), text);
|
||||
s.execute("toc.update", &json!({})).unwrap();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{Session, doc};
|
||||
use crate::Result;
|
||||
|
||||
fn session_with_frame() -> (Session, u64, u64) {
|
||||
let mut s = Session::new();
|
||||
@@ -127,6 +128,72 @@ fn table_edit_after_anchor_removal_floors_parent_caret() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Footnote text is addressed as a cell, but the table commands don't take it for one.
|
||||
#[test]
|
||||
fn the_cell_commands_refuse_footnote_text() {
|
||||
let (mut s, sid, _) = session_with_frame();
|
||||
s.execute("story.setText", &json!({"story": sid, "text": "Hello"})).unwrap();
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 5, "focus": 5})).unwrap();
|
||||
s.execute("footnote.insert", &json!({"text": "Note"})).unwrap();
|
||||
let before = s.doc().unwrap().selection.clone();
|
||||
assert!(before.text.and_then(|t| t.cell).and_then(|c| c.footnote_id()).is_some(), "the caret is in the footnote");
|
||||
for cmd in ["table.toggleSelection", "table.prevCell", "table.nextCell"] {
|
||||
assert!(s.execute(cmd, &json!({})).is_err(), "{cmd}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().selection, before);
|
||||
// A Tab there is typed into the footnote, not taken for the next cell.
|
||||
s.execute("text.insert", &json!({"text": "\t"})).unwrap();
|
||||
let notes = s.execute("footnote.list", &json!({})).unwrap();
|
||||
assert!(notes[0]["text"].as_str().is_some_and(|t| t.contains('\t')), "{notes}");
|
||||
}
|
||||
|
||||
/// Esc in a table toggles between the text and the cell (InDesign's Esc): the caret in a cell
|
||||
/// selects that cell, and selected cells select the first one's text; Shift+Tab goes back a cell.
|
||||
#[test]
|
||||
fn escape_toggles_text_and_cell_selection() {
|
||||
let (mut s, sid, _) = session_with_frame();
|
||||
let tid = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap();
|
||||
s.execute("table.nextCell", &json!({})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "Phone"})).unwrap();
|
||||
let cell = designcraft_doc::CellAddr { table: tid, row: 0, col: 1 };
|
||||
assert_eq!(s.execute("table.toggleSelection", &json!({})).unwrap(), json!({"cells": true}));
|
||||
let sel = s.doc().unwrap().selection.clone();
|
||||
let cells = sel.cells.unwrap();
|
||||
assert_eq!((cells.story.0, cells.table, cells.range), (sid, tid, designcraft_doc::CellRange::cell(0, 1)));
|
||||
assert_eq!(sel.text.unwrap().cell, Some(cell));
|
||||
assert_eq!(s.execute("table.toggleSelection", &json!({})).unwrap(), json!({"cells": false}));
|
||||
let sel = s.doc().unwrap().selection.clone();
|
||||
assert!(sel.cells.is_none());
|
||||
let t = sel.text.unwrap();
|
||||
assert_eq!((t.cell, t.range()), (Some(cell), 0..5), "the cell's text is selected");
|
||||
s.execute("table.prevCell", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.text.unwrap().cell, Some(designcraft_doc::CellAddr { col: 0, ..cell }));
|
||||
// With the cell selected, typing replaces its text and leaves the caret after it.
|
||||
s.execute("table.nextCell", &json!({})).unwrap();
|
||||
s.execute("table.toggleSelection", &json!({})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "Fax"})).unwrap();
|
||||
let t = s.execute("table.get", &json!({"table": tid})).unwrap();
|
||||
assert_eq!(t["cells"][1]["text"], "Fax");
|
||||
let sel = s.doc().unwrap().selection.clone();
|
||||
assert!(sel.cells.is_none());
|
||||
assert_eq!(sel.text.unwrap().range(), 3..3);
|
||||
// Moving the caret leaves the cell selection: typing inserts at the caret, not over the cell.
|
||||
s.execute("table.toggleSelection", &json!({})).unwrap();
|
||||
s.execute("text.move", &json!({"dir": "right"})).unwrap();
|
||||
assert!(s.doc().unwrap().selection.cells.is_none());
|
||||
s.execute("text.insert", &json!({"text": "!"})).unwrap();
|
||||
assert_eq!(s.execute("table.get", &json!({"table": tid})).unwrap()["cells"][1]["text"], "F!ax");
|
||||
// A selection that starts inside a merged cell goes back to the merged cell's text.
|
||||
s.execute("table.select", &json!({"rows": [0, 1], "cols": [0, 0]})).unwrap();
|
||||
s.execute("table.merge", &json!({})).unwrap();
|
||||
s.execute("table.select", &json!({"rows": [1, 1], "cols": [0, 1]})).unwrap();
|
||||
s.execute("table.toggleSelection", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.text.unwrap().cell, Some(designcraft_doc::CellAddr { row: 0, col: 0, ..cell }));
|
||||
// Outside a table there is nothing to toggle.
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap();
|
||||
assert!(s.execute("table.toggleSelection", &json!({})).is_err());
|
||||
}
|
||||
|
||||
fn selected_merged_table(column: bool) -> (Session, u64, u64, u64) {
|
||||
let (mut s, sid, fid) = session_with_frame();
|
||||
let tid = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap();
|
||||
@@ -426,6 +493,29 @@ fn rows_columns_merge_and_options() {
|
||||
assert_eq!(st.doc.story(sid).unwrap().text, "Intro");
|
||||
}
|
||||
|
||||
/// `table.insert` styles the table in the same step; a style that isn't there, or isn't a name,
|
||||
/// leaves the story alone. Its dimensions stop at the documented limits.
|
||||
#[test]
|
||||
fn insert_table_with_a_style_is_one_step() {
|
||||
let (mut s, sid, _) = session_with_frame();
|
||||
s.execute("style.table.create", &json!({"name": "Grid"})).unwrap();
|
||||
for bad in [json!("Missing"), json!(7)] {
|
||||
assert!(s.execute("table.insert", &json!({"tableStyle": bad})).is_err(), "{bad}");
|
||||
}
|
||||
assert!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().tables.is_empty());
|
||||
for over in [json!({"rows": 501}), json!({"cols": 201}), json!({"headerRows": 101})] {
|
||||
assert!(s.execute("table.insert", &over).is_err(), "{over}");
|
||||
}
|
||||
let r = s.execute("table.insert", &json!({"rows": 500, "cols": 200, "headerRows": 100, "tableStyle": "Grid"})).unwrap();
|
||||
let t = s.execute("table.get", &json!({"table": r["table"]})).unwrap();
|
||||
assert_eq!(t["style"], "Grid");
|
||||
let n = |k: &str| t[k].as_array().unwrap().len() as u64;
|
||||
use crate::cmd::table::{MAX_BODY_ROWS, MAX_COLUMNS, MAX_HEADER_FOOTER_ROWS};
|
||||
assert_eq!((n("rows"), n("columns")), (MAX_BODY_ROWS + MAX_HEADER_FOOTER_ROWS, MAX_COLUMNS));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().tables.is_empty(), "one undo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_text_to_table_and_back() {
|
||||
let (mut s, sid, _) = session_with_frame();
|
||||
@@ -444,6 +534,74 @@ fn convert_text_to_table_and_back() {
|
||||
assert_eq!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text, "Ink\tCoverage\nPlum\t80%\nSunset\t45%\nAfter");
|
||||
}
|
||||
|
||||
/// InDesign's Convert Text to Table and Convert Table to Text separators: tab, comma, paragraph or
|
||||
/// any characters; with one separator for both, rows of Number of Columns cells; a Table Style.
|
||||
#[test]
|
||||
fn convert_text_to_table_with_indesigns_separators() {
|
||||
let (mut s, sid, _) = session_with_frame();
|
||||
let convert = |s: &mut Session, text: &str, p: Value| -> Result<Value> {
|
||||
s.execute("story.setText", &json!({"story": sid, "text": format!("{text}\nAfter")})).unwrap();
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": text.len()})).unwrap();
|
||||
s.execute("table.convertFromText", &p)
|
||||
};
|
||||
let cells = |s: &mut Session, r: &Value| -> (usize, Vec<String>) {
|
||||
let t = s.execute("table.get", &json!({"table": r["table"]})).unwrap();
|
||||
let texts = t["cells"].as_array().unwrap().iter().map(|c| c["text"].as_str().unwrap().to_string()).collect();
|
||||
(t["columns"].as_array().unwrap().len(), texts)
|
||||
};
|
||||
// Comma-separated columns, paragraphs for rows.
|
||||
let r = convert(&mut s, "Ink, Coverage\nPlum, 80%", json!({"columnSeparator": "comma"})).unwrap();
|
||||
assert_eq!(cells(&mut s, &r), (2, vec!["Ink".into(), "Coverage".into(), "Plum".into(), "80%".into()]));
|
||||
check(&s);
|
||||
// The lesson's list: one paragraph per cell; with two columns, two cells a row.
|
||||
let list = "Name:\nAddress:\nCity:\nState:";
|
||||
let r = convert(&mut s, list, json!({"columnSeparator": "paragraph", "rowSeparator": "paragraph", "columns": 2})).unwrap();
|
||||
assert_eq!(r["rows"], 2);
|
||||
assert_eq!(cells(&mut s, &r).0, 2);
|
||||
let r = convert(&mut s, list, json!({"columnSeparator": "tab"})).unwrap();
|
||||
assert_eq!((r["rows"].as_u64(), cells(&mut s, &r).0), (Some(4), 1), "a column of paragraphs");
|
||||
// Any characters separate: here " | " between columns and ";" between rows.
|
||||
let r = convert(&mut s, "a | b;c | d", json!({"columnSeparator": " | ", "rowSeparator": ";"})).unwrap();
|
||||
assert_eq!(cells(&mut s, &r), (2, vec!["a".into(), "b".into(), "c".into(), "d".into()]));
|
||||
// A table style is applied to the new table.
|
||||
s.execute("style.table.create", &json!({"name": "Grid"})).unwrap();
|
||||
let r = convert(&mut s, "x\ty", json!({"tableStyle": "Grid"})).unwrap();
|
||||
assert_eq!(s.execute("table.get", &json!({"table": r["table"]})).unwrap()["style"], "Grid");
|
||||
// A short last row: its padding cell takes the text's format too.
|
||||
let r = convert(&mut s, "a\nb\nc", json!({"columnSeparator": "paragraph", "rowSeparator": "paragraph", "columns": 2})).unwrap();
|
||||
let t = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().tables[&r["table"].as_u64().unwrap()].clone();
|
||||
let fmt = |c: usize| t.cell(1, c).unwrap().text.chars[0].format.clone();
|
||||
assert_eq!(fmt(1), fmt(0), "the padding cell's format is the text's");
|
||||
for bad in [
|
||||
json!({"columnSeparator": ""}),
|
||||
json!({"rowSeparator": 3}),
|
||||
json!({"columns": 0}),
|
||||
json!({"columns": crate::cmd::table::MAX_COLUMNS + 1}),
|
||||
json!({"tableStyle": "Missing"}),
|
||||
json!({"tableStyle": 123}),
|
||||
] {
|
||||
assert!(convert(&mut s, "p\tq", bad.clone()).is_err(), "{bad}");
|
||||
}
|
||||
// Text that would make too wide or too big a table is refused, and stays as it was.
|
||||
use crate::cmd::table::{MAX_COLUMNS, MAX_CONVERTED_CELLS};
|
||||
let wide = vec!["x"; MAX_COLUMNS as usize + 1].join("\t");
|
||||
assert!(convert(&mut s, &wide, json!({})).is_err(), "a row wider than a table can be");
|
||||
let long = vec!["a\tb"; MAX_CONVERTED_CELLS as usize / 2 + 1].join("\n");
|
||||
assert!(convert(&mut s, &long, json!({})).is_err(), "more cells than a table can have");
|
||||
assert_eq!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text, format!("{long}\nAfter"));
|
||||
let tall = vec!["a"; designcraft_doc::MAX_TABLE_ROWS + 1].join("\n");
|
||||
assert!(convert(&mut s, &tall, json!({})).is_err(), "more rows than a table can have");
|
||||
assert_eq!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text, format!("{tall}\nAfter"), "no text is dropped");
|
||||
let at_most = vec!["x"; MAX_COLUMNS as usize].join("\t");
|
||||
assert!(convert(&mut s, &at_most, json!({})).is_ok(), "a row as wide as a table can be");
|
||||
check(&s);
|
||||
// Back to text with a comma between columns and a tab between rows.
|
||||
let r = convert(&mut s, "Plum\t80%\nSunset\t45%", json!({})).unwrap();
|
||||
s.execute("table.convertToText", &json!({"table": r["table"], "columnSeparator": "comma", "rowSeparator": "tab"})).unwrap();
|
||||
check(&s);
|
||||
assert_eq!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text, "Plum,80%\tSunset,45%\nAfter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caret_placement_in_cells_and_cell_formatting() {
|
||||
let (mut s, sid, fid) = session_with_frame();
|
||||
|
||||
@@ -110,6 +110,7 @@ impl Session {
|
||||
snap: view.snap,
|
||||
unit: view.unit,
|
||||
stroke_in_front: view.stroke_in_front,
|
||||
loaded: self.loaded.map(|l| l.size),
|
||||
};
|
||||
Some(f(self, &cx))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! `layout.pages.insert` clamps an out-of-range `after` to the end instead of overflowing.
|
||||
//! `layout.pages.insert` clamps an out-of-range `after` to the end instead of overflowing; a
|
||||
//! `before` must name a page, and one that doesn't is refused without a change.
|
||||
use designcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -14,3 +15,13 @@ fn insert_after_u64_max_appends_at_the_end() {
|
||||
assert_eq!(after[..2], before[..], "the original pages must stay first; new pages go at the end");
|
||||
assert!(after[2..].iter().all(|id| !before.contains(id)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_before_u64_max_is_refused_without_overflow() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages":2})).unwrap();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
assert!(s.execute("layout.pages.insert", &json!({"before": u64::MAX, "count": 2})).is_err());
|
||||
assert_eq!(s.doc().unwrap().doc.page_count(), 2, "no pages added");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step");
|
||||
}
|
||||
|
||||
+49
-22
@@ -40,23 +40,7 @@ pub(crate) fn char_css(doc: &Document, c: &CharProps, base_size: f64, generics:
|
||||
let (weight, style) = css_weight_style(&c.font_style);
|
||||
let _ = write!(out, "font-weight: {weight}; font-style: {style}; ");
|
||||
let _ = write!(out, "font-size: {:.3}em; ", c.size / base_size);
|
||||
if c.tracking != 0.0 {
|
||||
let _ = write!(out, "letter-spacing: {:.3}em; ", c.tracking / 1000.0);
|
||||
}
|
||||
match c.capitalization {
|
||||
designcraft_doc::Capitalization::AllCaps => out.push_str("text-transform: uppercase; "),
|
||||
designcraft_doc::Capitalization::SmallCaps | designcraft_doc::Capitalization::OpenTypeAllSmallCaps => {
|
||||
out.push_str("font-variant: small-caps; ")
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// One declaration: a later `text-decoration` would replace the earlier one.
|
||||
match (c.underline, c.strikethrough) {
|
||||
(true, true) => out.push_str("text-decoration: underline line-through; "),
|
||||
(true, false) => out.push_str("text-decoration: underline; "),
|
||||
(false, true) => out.push_str("text-decoration: line-through; "),
|
||||
(false, false) => {}
|
||||
}
|
||||
decor_css(c.tracking, c.capitalization, c.underline, c.strikethrough, out);
|
||||
if let Some(h) = hex(doc, &c.fill, c.fill_tint) {
|
||||
let _ = write!(out, "color: {h}; ");
|
||||
}
|
||||
@@ -150,6 +134,7 @@ pub(crate) fn stylesheet_with(doc: &Document, generics: &BTreeMap<String, String
|
||||
if let Some(sz) = c.size {
|
||||
let _ = write!(rule, "font-size: {:.3}em; ", sz / base);
|
||||
}
|
||||
attrs_decor_css(&st.char_style_attrs(&cs.name), &mut rule);
|
||||
let (tag, class) = export_mapping(st, &cs.name, true, &cc);
|
||||
if let Some(selector) = class_selector(tag, &class) {
|
||||
let _ = writeln!(css, "{selector} {{ {rule}}}");
|
||||
@@ -158,7 +143,37 @@ pub(crate) fn stylesheet_with(doc: &Document, generics: &BTreeMap<String, String
|
||||
css
|
||||
}
|
||||
|
||||
pub(crate) fn override_css(doc: &Document, o: &CharAttrs) -> String {
|
||||
/// Tracking, capitalization and line decorations of a style or override, as CSS declarations.
|
||||
fn attrs_decor_css(c: &CharAttrs, out: &mut String) {
|
||||
decor_css(c.tracking.unwrap_or(0.0), c.capitalization.unwrap_or_default(), c.underline == Some(true), c.strikethrough == Some(true), out);
|
||||
}
|
||||
|
||||
/// Tracking, capitalization and line decorations as CSS declarations; defaults write nothing.
|
||||
fn decor_css(tracking: f64, caps: designcraft_doc::Capitalization, underline: bool, strikethrough: bool, out: &mut String) {
|
||||
if tracking != 0.0 {
|
||||
let _ = write!(out, "letter-spacing: {:.3}em; ", tracking / 1000.0);
|
||||
}
|
||||
match caps {
|
||||
designcraft_doc::Capitalization::AllCaps => out.push_str("text-transform: uppercase; "),
|
||||
designcraft_doc::Capitalization::SmallCaps | designcraft_doc::Capitalization::OpenTypeAllSmallCaps => {
|
||||
out.push_str("font-variant: small-caps; ")
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// One declaration: a later `text-decoration` would replace the earlier one.
|
||||
match (underline, strikethrough) {
|
||||
(true, true) => out.push_str("text-decoration: underline line-through; "),
|
||||
(true, false) => out.push_str("text-decoration: underline; "),
|
||||
(false, true) => out.push_str("text-decoration: line-through; "),
|
||||
(false, false) => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inline CSS for the local override `o` of a run in character style `style`. Tracking, caps and
|
||||
/// line decorations are resolved against the style (and its Based On chain), so an override that
|
||||
/// sets only one of underline/strikethrough keeps the other, and one that clears a value the
|
||||
/// style sets writes the reset.
|
||||
pub(crate) fn override_css(doc: &Document, style: &str, o: &CharAttrs) -> String {
|
||||
let mut s = String::new();
|
||||
if let Some(f) = &o.font_style {
|
||||
let f = f.to_lowercase();
|
||||
@@ -174,11 +189,23 @@ pub(crate) fn override_css(doc: &Document, o: &CharAttrs) -> String {
|
||||
{
|
||||
let _ = write!(s, "color: {h}; ");
|
||||
}
|
||||
if o.underline == Some(true) {
|
||||
s.push_str("text-decoration: underline; ");
|
||||
let base = doc.styles.char_style_attrs(style);
|
||||
let mut merged = base.clone();
|
||||
merged.merge(o);
|
||||
let tracking = if o.tracking.is_some() { merged.tracking.unwrap_or(0.0) } else { 0.0 };
|
||||
let caps = if o.capitalization.is_some() { merged.capitalization.unwrap_or_default() } else { Default::default() };
|
||||
let lines = o.underline.is_some() || o.strikethrough.is_some();
|
||||
let (underline, strikethrough) = (lines && merged.underline == Some(true), lines && merged.strikethrough == Some(true));
|
||||
if o.tracking.is_some() && tracking == 0.0 && base.tracking.is_some_and(|t| t != 0.0) {
|
||||
s.push_str("letter-spacing: 0em; ");
|
||||
}
|
||||
if o.capitalization == Some(designcraft_doc::Capitalization::AllCaps) {
|
||||
s.push_str("text-transform: uppercase; ");
|
||||
// The class rule's other caps property would still apply, so clear both before the new mode.
|
||||
if o.capitalization.is_some() && caps != base.capitalization.unwrap_or_default() && base.capitalization.is_some_and(|c| c != Default::default()) {
|
||||
s.push_str("text-transform: none; font-variant: normal; ");
|
||||
}
|
||||
decor_css(tracking, caps, underline, strikethrough, &mut s);
|
||||
if lines && !underline && !strikethrough && (base.underline == Some(true) || base.strikethrough == Some(true)) {
|
||||
s.push_str("text-decoration: none; ");
|
||||
}
|
||||
s.trim_end().to_string()
|
||||
}
|
||||
|
||||
@@ -223,7 +223,7 @@ impl<'a, 'f> StoryWriter<'a, 'f> {
|
||||
} else {
|
||||
("span", String::new())
|
||||
};
|
||||
let style = override_css(doc, &f.over);
|
||||
let style = override_css(doc, &f.style, &f.over);
|
||||
let style_attr = if style.is_empty() { String::new() } else { format!(" style=\"{}\"", xml_attr(&style)) };
|
||||
if cls.is_empty() && style_attr.is_empty() {
|
||||
out.push_str(&t);
|
||||
|
||||
@@ -922,3 +922,58 @@ fn char_css_joins_underline_and_strikethrough_in_one_declaration() {
|
||||
assert!(css(false, true).contains("text-decoration: line-through;"));
|
||||
assert!(!css(false, false).contains("text-decoration"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_styles_keep_decorations_caps_and_tracking() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
d.styles_mut().character.push(designcraft_doc::CharacterStyle {
|
||||
name: "Under".into(),
|
||||
based_on: None,
|
||||
chars: CharAttrs {
|
||||
underline: Some(true),
|
||||
strikethrough: Some(true),
|
||||
capitalization: Some(designcraft_doc::Capitalization::AllCaps),
|
||||
tracking: Some(100.0),
|
||||
..Default::default()
|
||||
},
|
||||
shortcut: String::new(),
|
||||
});
|
||||
let css = css::stylesheet(&d);
|
||||
assert!(css.contains("text-decoration: underline line-through;"), "{css}");
|
||||
assert!(css.contains("text-transform: uppercase;"), "{css}");
|
||||
assert!(css.contains("letter-spacing: 0.100em;"), "{css}");
|
||||
let o = CharAttrs { strikethrough: Some(true), ..Default::default() };
|
||||
assert_eq!(css::override_css(&d, story::NO_CHAR_STYLE, &o), "text-decoration: line-through;");
|
||||
}
|
||||
|
||||
fn char_style(name: &str, based_on: Option<&str>, chars: CharAttrs) -> designcraft_doc::CharacterStyle {
|
||||
designcraft_doc::CharacterStyle { name: name.into(), based_on: based_on.map(Into::into), chars, shortcut: String::new() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_keeps_style_decorations_and_resets_tracking_and_caps() {
|
||||
use designcraft_doc::Capitalization;
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let styles = d.styles_mut();
|
||||
styles.character.push(char_style(
|
||||
"Base",
|
||||
None,
|
||||
CharAttrs { underline: Some(true), tracking: Some(100.0), capitalization: Some(Capitalization::AllCaps), ..Default::default() },
|
||||
));
|
||||
styles.character.push(char_style("Child", Some("Base"), CharAttrs::default()));
|
||||
let strike = CharAttrs { strikethrough: Some(true), ..Default::default() };
|
||||
for style in ["Base", "Child"] {
|
||||
assert_eq!(css::override_css(&d, style, &strike), "text-decoration: underline line-through;", "{style}");
|
||||
}
|
||||
let off = CharAttrs { underline: Some(false), ..Default::default() };
|
||||
assert_eq!(css::override_css(&d, "Child", &off), "text-decoration: none;");
|
||||
let reset = CharAttrs { tracking: Some(0.0), capitalization: Some(Capitalization::Normal), ..Default::default() };
|
||||
assert_eq!(css::override_css(&d, "Child", &reset), "letter-spacing: 0em; text-transform: none; font-variant: normal;");
|
||||
assert_eq!(css::override_css(&d, story::NO_CHAR_STYLE, &reset), "");
|
||||
let styles = d.styles_mut();
|
||||
styles.character.push(char_style("Small", None, CharAttrs { capitalization: Some(Capitalization::SmallCaps), ..Default::default() }));
|
||||
let small = CharAttrs { capitalization: Some(Capitalization::SmallCaps), ..Default::default() };
|
||||
let all = CharAttrs { capitalization: Some(Capitalization::AllCaps), ..Default::default() };
|
||||
assert_eq!(css::override_css(&d, "Base", &small), "text-transform: none; font-variant: normal; font-variant: small-caps;");
|
||||
assert_eq!(css::override_css(&d, "Small", &all), "text-transform: none; font-variant: normal; text-transform: uppercase;");
|
||||
}
|
||||
|
||||
@@ -555,6 +555,14 @@ fn r7a_nullable_document(local_clear: bool, style_clear: bool) -> Document {
|
||||
underline_offset: Some(Some(1.0)),
|
||||
strikethrough_weight: Some(Some(2.0)),
|
||||
strikethrough_offset: Some(Some(1.0)),
|
||||
kenten_size: Some(Some(14.0)),
|
||||
kenten_fill_tint: Some(Some(0.3)),
|
||||
kenten_stroke_tint: Some(Some(0.4)),
|
||||
kenten_stroke_weight: Some(Some(2.0)),
|
||||
ruby_font_size: Some(Some(14.0)),
|
||||
ruby_fill_tint: Some(Some(0.3)),
|
||||
ruby_stroke_tint: Some(Some(0.4)),
|
||||
ruby_stroke_weight: Some(Some(2.0)),
|
||||
..Default::default()
|
||||
};
|
||||
// Legacy JSON loads as format 1, whose upgrade pins Keep Lines Together and Align Left
|
||||
@@ -564,6 +572,7 @@ fn r7a_nullable_document(local_clear: bool, style_clear: bool) -> Document {
|
||||
start_at: Some(Some(7)),
|
||||
paragraph_kashida_width: Some(Some(0.5)),
|
||||
kinsoku: Some(Some(Default::default())),
|
||||
list_tab: Some(Some(36.0)),
|
||||
keep_all_lines: Some(true),
|
||||
drop_cap_align_left: Some(true),
|
||||
..Default::default()
|
||||
@@ -578,9 +587,18 @@ fn r7a_nullable_document(local_clear: bool, style_clear: bool) -> Document {
|
||||
underline_offset: Some(None),
|
||||
strikethrough_weight: Some(None),
|
||||
strikethrough_offset: Some(None),
|
||||
kenten_size: Some(None),
|
||||
kenten_fill_tint: Some(None),
|
||||
kenten_stroke_tint: Some(None),
|
||||
kenten_stroke_weight: Some(None),
|
||||
ruby_font_size: Some(None),
|
||||
ruby_fill_tint: Some(None),
|
||||
ruby_stroke_tint: Some(None),
|
||||
ruby_stroke_weight: Some(None),
|
||||
..Default::default()
|
||||
};
|
||||
let clear_para = ParaAttrs { start_at: Some(None), paragraph_kashida_width: Some(None), kinsoku: Some(None), ..Default::default() };
|
||||
let clear_para =
|
||||
ParaAttrs { start_at: Some(None), paragraph_kashida_width: Some(None), kinsoku: Some(None), list_tab: Some(None), ..Default::default() };
|
||||
let selected = if style_clear {
|
||||
std::sync::Arc::make_mut(&mut d.styles).paragraph.push(designcraft_doc::styles::ParagraphStyle {
|
||||
name: "Defaults".into(),
|
||||
@@ -621,6 +639,15 @@ fn r7a_assert_nullable_defaults_survive(d: &Document) {
|
||||
assert_eq!(expected.0.paragraph_kashida_width, None);
|
||||
assert_eq!(expected.0.kinsoku, None);
|
||||
assert_eq!(expected.1.leading_aki, None);
|
||||
assert_eq!(expected.0.list_tab, None);
|
||||
assert_eq!(expected.1.kenten_size, None);
|
||||
assert_eq!(expected.1.kenten_fill_tint, None);
|
||||
assert_eq!(expected.1.kenten_stroke_tint, None);
|
||||
assert_eq!(expected.1.kenten_stroke_weight, None);
|
||||
assert_eq!(expected.1.ruby_font_size, None);
|
||||
assert_eq!(expected.1.ruby_fill_tint, None);
|
||||
assert_eq!(expected.1.ruby_stroke_tint, None);
|
||||
assert_eq!(expected.1.ruby_stroke_weight, None);
|
||||
// Legacy JSON is format 1: its upgrade pins older defaults on the other styles, so only
|
||||
// the native ZIP compares as the whole document.
|
||||
for (bytes, native) in [(save(d).unwrap(), true), (serde_json::to_vec(d).unwrap(), false)] {
|
||||
|
||||
@@ -258,12 +258,13 @@ fn parse_single(tok: &str, default: Unit, percent_base: Option<f64>) -> Option<f
|
||||
Some(unit.to_pt(n))
|
||||
}
|
||||
|
||||
/// Format a value (points) in `unit` the way InDesign fields show it: up to 3 decimals, trailing
|
||||
/// zeros trimmed; picas as `3p6`, ciceros as `2c4`.
|
||||
/// Format a value (points) in `unit` the way InDesign fields show it: up to 4 decimals for inches,
|
||||
/// decimal inches and centimetres, up to 3 for the other units, trailing zeros trimmed; picas as
|
||||
/// `3p6`, ciceros as `2c4`.
|
||||
pub fn format_measure(pt: f64, unit: Unit) -> String {
|
||||
// As InDesign shows them: inches to four decimals (a 1p gutter is 0.1667 in, A4 is
|
||||
// 8.2677 in wide), the other units to three.
|
||||
let decimals = if matches!(unit, Unit::Inches | Unit::InchesDecimal) { 4 } else { 3 };
|
||||
// As InDesign shows them: inches and centimetres to four decimals (a 1p gutter is 0.1667 in
|
||||
// or 0.4233 cm, A4 is 8.2677 in wide), the other units to three.
|
||||
let decimals = if matches!(unit, Unit::Inches | Unit::InchesDecimal | Unit::Centimeters) { 4 } else { 3 };
|
||||
format_measure_prec(pt, unit, decimals)
|
||||
}
|
||||
|
||||
@@ -323,14 +324,18 @@ mod tests {
|
||||
assert_eq!(format_measure(-12.0, Unit::Picas), "-1p0");
|
||||
}
|
||||
|
||||
/// Inches show four decimals, as InDesign's fields do; other units three.
|
||||
/// Inches and centimetres show four decimals, as InDesign's fields do; other units three.
|
||||
#[test]
|
||||
fn inches_show_four_decimals() {
|
||||
fn inches_and_centimeters_show_four_decimals() {
|
||||
assert_eq!(format_measure(12.0, Unit::Inches), "0.1667 in");
|
||||
assert_eq!(format_measure(210.0 * PT_PER_MM, Unit::Inches), "8.2677 in");
|
||||
assert_eq!(format_measure(612.0, Unit::Inches), "8.5 in");
|
||||
assert_eq!(format_measure(9.0, Unit::Inches), "0.125 in");
|
||||
assert_eq!(format_measure(10.0, Unit::Millimeters), "3.528 mm");
|
||||
// A 1p gutter and a frame's height, as InDesign's New Document and Transform fields show them.
|
||||
assert_eq!(format_measure(12.0, Unit::Centimeters), "0.4233 cm");
|
||||
assert_eq!(format_measure(17.1125 / 2.54 * 72.0, Unit::Centimeters), "17.1125 cm");
|
||||
assert_eq!(format_measure(21.0 / 2.54 * 72.0, Unit::Centimeters), "21 cm");
|
||||
assert_eq!(format_measure(10.0, Unit::Points), "10 pt");
|
||||
}
|
||||
|
||||
|
||||
@@ -220,6 +220,7 @@ impl<'a> Ex<'a> {
|
||||
.attr("Size", num(ds.size))
|
||||
.attr("Spread", num(ds.spread))
|
||||
.attr("Opacity", pct(ds.opacity as f64))
|
||||
.attr("BlendMode", names::blend_out(ds.mode))
|
||||
.attr("EffectColor", self.sw(&ds.color)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -302,7 +302,7 @@ impl<'a> Ex<'a> {
|
||||
.attr("RunIn", "false")
|
||||
.attr("IncludeHidden", "false")
|
||||
.attr("IncludeBookDocuments", "false")
|
||||
.attr("CreatePdfBookmarks", "false");
|
||||
.attr("CreateBookmarks", if toc.create_bookmarks { "true" } else { "false" });
|
||||
for e in &toc.entries {
|
||||
let format = e.entry_style.clone().unwrap_or_else(|| format!("TOC Level {}", e.level));
|
||||
let position = match e.page_number {
|
||||
@@ -335,6 +335,17 @@ impl<'a> Ex<'a> {
|
||||
el.set(&format!("{k}Offset"), num(r.offset));
|
||||
el.set(&format!("{k}LeftIndent"), num(r.left_indent));
|
||||
el.set(&format!("{k}RightIndent"), num(r.right_indent));
|
||||
el.set(&format!("{k}Type"), names::stroke_type_out(&r.kind));
|
||||
// A gap in Text Color is a string property, as the rule's own colour is.
|
||||
if r.gap_color == designcraft_doc::TEXT_COLOR {
|
||||
props.push(p(&format!("{k}GapColor"), "string", designcraft_doc::TEXT_COLOR));
|
||||
} else {
|
||||
el.set(&format!("{k}GapColor"), self.sw(&r.gap_color));
|
||||
}
|
||||
el.set(&format!("{k}GapTint"), pct(r.gap_tint as f64));
|
||||
if k == "RuleAbove" {
|
||||
el.set("KeepRuleAboveInFrame", bool_s(r.keep_in_frame));
|
||||
}
|
||||
props.push(if r.color == designcraft_doc::TEXT_COLOR {
|
||||
p(&format!("{k}Color"), "string", designcraft_doc::TEXT_COLOR)
|
||||
} else {
|
||||
|
||||
@@ -22,6 +22,13 @@ macro_rules! b {
|
||||
};
|
||||
}
|
||||
|
||||
/// The Overprint switches of a paragraph's shading, border and border gap.
|
||||
fn para_box_overprints(el: &mut El, a: &ParaAttrs) {
|
||||
b!(a, el, shading_overprint, "ParagraphShadingOverprint");
|
||||
b!(a, el, border_overprint, "ParagraphBorderOverprint");
|
||||
b!(a, el, border_gap_overprint, "ParagraphBorderGapOverprint");
|
||||
}
|
||||
|
||||
impl<'a> Ex<'a> {
|
||||
pub(super) fn char_attrs(&mut self, el: &mut El, props: &mut Vec<El>, a: &CharAttrs) {
|
||||
if let Some(v) = a.leading_aki {
|
||||
@@ -494,6 +501,7 @@ impl<'a> Ex<'a> {
|
||||
props.push(p("ParagraphShadingColor", "object", self.sw(c)));
|
||||
}
|
||||
n!(a, el, shading_tint, "ParagraphShadingTint", pct);
|
||||
para_box_overprints(el, a);
|
||||
const SIDES: [&str; 4] = ["Top", "Left", "Bottom", "Right"];
|
||||
if let Some(o) = a.shading_offsets {
|
||||
for (i, side) in SIDES.iter().enumerate() {
|
||||
|
||||
@@ -313,10 +313,12 @@ impl<'r> Importer<'r> {
|
||||
let story = top.iter().find(|e| e.local() == "Story" && e.get("AppliedTOCStyle") == Some(id))?;
|
||||
Some((ts, *self.story_ids.get(story.get("Self")?)?))
|
||||
})?;
|
||||
let known = |r: Option<&str>| r.map(|r| self.para_style_ref(r)).filter(|n| self.styles.para(n).is_some());
|
||||
// Property children are element text: formatting whitespace around a reference is dropped
|
||||
// unless the reference as written names a style.
|
||||
let known = |r: Option<&str>| r.map(|r| self.listed_para_style_ref(r)).filter(|n| self.styles.para(n).is_some());
|
||||
// Character styles the document has; [None] is stored as no style.
|
||||
let known_chars = |r: Option<&str>| {
|
||||
r.map(|r| self.char_style_ref(r)).filter(|n| n != designcraft_doc::NO_CHAR_STYLE && self.styles.char_style(n).is_some())
|
||||
r.map(|r| self.listed_char_style_ref(r)).filter(|n| n != designcraft_doc::NO_CHAR_STYLE && self.styles.char_style(n).is_some())
|
||||
};
|
||||
let entries: Vec<TocEntry> = ts
|
||||
.elements()
|
||||
@@ -333,9 +335,10 @@ impl<'r> Importer<'r> {
|
||||
_ => TocPageNumber::After,
|
||||
};
|
||||
let separator = e.get("Separator").map(crate::names::toc_separator_in).filter(|s| s != "\t" && s.chars().count() <= 64);
|
||||
let entry_style = known(e.get("FormatStyle")).filter(|n| *n != format!("TOC Level {level}"));
|
||||
let page_number_style = known_chars(e.get("PageNumberStyle"));
|
||||
let separator_style = known_chars(e.get("SeparatorStyle"));
|
||||
// InDesign writes these as `Properties` children; attributes are read too.
|
||||
let entry_style = known(e.prop("FormatStyle").as_deref()).filter(|n| *n != format!("TOC Level {level}"));
|
||||
let page_number_style = known_chars(e.prop("PageNumberStyle").as_deref());
|
||||
let separator_style = known_chars(e.prop("SeparatorStyle").as_deref());
|
||||
let sort = e.get("Sort").is_some_and(|v| v.trim() == "true");
|
||||
Some(TocEntry { style, level, entry_style, page_number, separator, page_number_style, separator_style, sort })
|
||||
})
|
||||
@@ -346,6 +349,7 @@ impl<'r> Importer<'r> {
|
||||
}
|
||||
let title: String = ts.get("Title").unwrap_or("").chars().filter(|c| !matches!(c, '\n' | '\r' | '\u{2029}')).take(1024).collect();
|
||||
let title_style = known(ts.get("TitleStyle")).filter(|n| n != "TOC Title");
|
||||
Some(designcraft_doc::Toc { story, title, entries, page_numbers: true, title_style })
|
||||
let create_bookmarks = ts.get("CreateBookmarks").or_else(|| ts.get("CreatePdfBookmarks")) == Some("true");
|
||||
Some(designcraft_doc::Toc { story, title, entries, page_numbers: true, title_style, create_bookmarks })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,9 @@ impl<'r> Importer<'r> {
|
||||
size: ds.num("Size").unwrap_or(def.size),
|
||||
spread: ds.num("Spread").unwrap_or(def.spread),
|
||||
global_light: false,
|
||||
// Without the attribute (earlier DesignCraft exports) the shadow stays Normal, as it
|
||||
// rendered then; a new shadow's default is Multiply.
|
||||
mode: ds.get("BlendMode").map_or(designcraft_color::BlendMode::Normal, names::blend_in),
|
||||
};
|
||||
}
|
||||
if let Some(f) = t.find("FeatherSetting")
|
||||
|
||||
@@ -70,7 +70,7 @@ impl<'r> Importer<'r> {
|
||||
}
|
||||
|
||||
/// A page's margins and columns, and whether they are local (differ from its parent page's).
|
||||
fn page_margins(&self, p: &El, r: &Rect, master: bool, parent: Option<SpreadId>, side: PageSide) -> (Margins, Columns, bool) {
|
||||
fn page_margins(&self, p: &El, r: &Rect, master: bool, parent: Option<SpreadId>, (side, index): (PageSide, usize)) -> (Margins, Columns, bool) {
|
||||
// A document page's margins are local when they differ from its parent page's; a
|
||||
// page without its own takes the parent page's.
|
||||
let from_parent = if master {
|
||||
@@ -78,7 +78,7 @@ impl<'r> Importer<'r> {
|
||||
} else {
|
||||
parent
|
||||
.and_then(|id| self.parents.iter().find(|sp| sp.id == id))
|
||||
.and_then(|sp| sp.pages.get(sp.parent_page_for_side(side)?))
|
||||
.and_then(|sp| sp.pages.get(sp.parent_page_at(side, index)?))
|
||||
.map(|q| (q.margins, q.columns.clone()))
|
||||
};
|
||||
let (margins, columns) = match (p.find("MarginPreference"), &from_parent) {
|
||||
@@ -126,7 +126,7 @@ impl<'r> Importer<'r> {
|
||||
PageSide::Right
|
||||
};
|
||||
let parent = p.get("AppliedMaster").and_then(|m| self.parent_ids.get(m)).copied();
|
||||
let (margins, columns, local_margins) = self.page_margins(p, r, master, parent, side);
|
||||
let (margins, columns, local_margins) = self.page_margins(p, r, master, parent, (side, i));
|
||||
out_pages.push(Page {
|
||||
id: pid,
|
||||
width: r.width(),
|
||||
|
||||
@@ -223,6 +223,11 @@ impl<'r> Importer<'r> {
|
||||
self.para_names.get(r).cloned().unwrap_or_else(|| names::style_name_in(PARA_BUILTINS, &unescape_id(r.trim_start_matches("ParagraphStyle/"))))
|
||||
}
|
||||
|
||||
/// A paragraph style reference written as an element's text: as written when it names a
|
||||
/// style, otherwise without surrounding whitespace (see [`Self::listed_char_style_ref`]).
|
||||
pub(super) fn listed_para_style_ref(&self, r: &str) -> String {
|
||||
if self.para_names.contains_key(r) { self.para_style_ref(r) } else { self.para_style_ref(r.trim()) }
|
||||
}
|
||||
/// A character style reference written as an element's text (nested, line and GREP styles).
|
||||
/// A style name may end in a space, so the reference is used as written when it names a
|
||||
/// style; only otherwise is surrounding whitespace dropped.
|
||||
@@ -238,7 +243,12 @@ impl<'r> Importer<'r> {
|
||||
// A style or range can set some of a rule's settings (such as only its colour); the others
|
||||
// come from its paragraph style chain, so the rule is whole.
|
||||
let fields = ["", "LineWeight", "Color", "Tint", "Width", "Offset", "LeftIndent", "RightIndent"];
|
||||
if !fields.iter().any(|f| e.prop(&format!("{k}{f}")).is_some()) {
|
||||
// Any of them, or of the type, gap and Keep In Frame settings, makes it an override (the
|
||||
// rest then come from the style chain, or the defaults: off, 1 pt).
|
||||
let more = ["Type", "GapColor", "GapTint"];
|
||||
let own = fields.iter().chain(&more).any(|f| e.prop(&format!("{k}{f}")).is_some())
|
||||
|| (k == "RuleAbove" && e.prop("KeepRuleAboveInFrame").is_some());
|
||||
if !own {
|
||||
return None;
|
||||
}
|
||||
let v: Vec<Option<String>> = fields.iter().map(|f| self.para_setting(e, &format!("{k}{f}"))).collect();
|
||||
@@ -246,7 +256,6 @@ impl<'r> Importer<'r> {
|
||||
let num = |i: usize| get(i).and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite());
|
||||
let on = get(0).map(|v| v.trim() == "true");
|
||||
let weight = num(1);
|
||||
on.or(weight.map(|_| false))?;
|
||||
let mut r = Rule { on: on.unwrap_or(false), ..Rule::default() };
|
||||
if let Some(w) = weight {
|
||||
r.weight = w;
|
||||
@@ -258,17 +267,38 @@ impl<'r> Importer<'r> {
|
||||
r.offset = num(5).unwrap_or(0.0);
|
||||
r.left_indent = num(6).unwrap_or(0.0);
|
||||
r.right_indent = num(7).unwrap_or(0.0);
|
||||
if let Some(c) = get(2) {
|
||||
let c = c.trim();
|
||||
if c == designcraft_doc::TEXT_COLOR {
|
||||
r.color = c.to_string();
|
||||
} else if c.contains('/') {
|
||||
r.color = self.swatch_ref(c);
|
||||
}
|
||||
// Type, gap colour and tint, Keep In Frame (rule above).
|
||||
if let Some(t) = self.para_setting(e, &format!("{k}Type")) {
|
||||
r.kind = names::stroke_type_in(t.trim());
|
||||
}
|
||||
if let Some(c) = self.para_setting(e, &format!("{k}GapColor"))
|
||||
&& let Some(c) = self.rule_color_ref(&c)
|
||||
{
|
||||
r.gap_color = c;
|
||||
}
|
||||
if let Some(t) = tint(self.para_setting(e, &format!("{k}GapTint")).and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite())) {
|
||||
r.gap_tint = t;
|
||||
}
|
||||
if k == "RuleAbove" {
|
||||
r.keep_in_frame = self.para_setting(e, "KeepRuleAboveInFrame").is_some_and(|v| v.trim() == "true");
|
||||
}
|
||||
if let Some(c) = get(2).and_then(|c| self.rule_color_ref(&c)) {
|
||||
r.color = c;
|
||||
}
|
||||
Some(r)
|
||||
}
|
||||
|
||||
/// A rule's colour or gap colour: Text Color (written `Text Color` or `$ID/Text Color`), or
|
||||
/// a swatch reference; anything else leaves the default.
|
||||
fn rule_color_ref(&mut self, c: &str) -> Option<String> {
|
||||
let c = c.trim();
|
||||
if c == designcraft_doc::TEXT_COLOR || c.strip_prefix("$ID/") == Some(designcraft_doc::TEXT_COLOR) {
|
||||
Some(designcraft_doc::TEXT_COLOR.to_string())
|
||||
} else {
|
||||
c.contains('/').then(|| self.swatch_ref(c))
|
||||
}
|
||||
}
|
||||
|
||||
/// Paragraph setting `k` of `e` (a paragraph style or a range): its own, else from its
|
||||
/// paragraph style chain (`BasedOn` of a style, the applied style of a range).
|
||||
fn para_setting(&self, e: &El, k: &str) -> Option<String> {
|
||||
|
||||
@@ -304,6 +304,7 @@ impl<'r> Importer<'r> {
|
||||
a.shading_color = Some(self.swatch_ref(c.trim()));
|
||||
}
|
||||
a.shading_tint = tint(e.num("ParagraphShadingTint"));
|
||||
a.shading_overprint = e.boolean("ParagraphShadingOverprint");
|
||||
let sides = |f: &dyn Fn(&str) -> String| -> Option<[f64; 4]> {
|
||||
let v = ["Top", "Left", "Bottom", "Right"].map(|s| e.num(&f(s)));
|
||||
v.iter().any(Option::is_some).then(|| v.map(|x| x.unwrap_or(0.0)))
|
||||
@@ -314,6 +315,7 @@ impl<'r> Importer<'r> {
|
||||
a.border_color = Some(self.swatch_ref(c.trim()));
|
||||
}
|
||||
a.border_tint = tint(e.num("ParagraphBorderTint"));
|
||||
a.border_overprint = e.boolean("ParagraphBorderOverprint");
|
||||
a.border_weights = sides(&|s| format!("ParagraphBorder{s}LineWeight"));
|
||||
a.border_offsets = sides(&|s| format!("ParagraphBorder{s}Offset"));
|
||||
a.border_type = e.prop("ParagraphBorderType").map(|v| names::stroke_type_in(v.trim()));
|
||||
@@ -329,6 +331,7 @@ impl<'r> Importer<'r> {
|
||||
a.border_gap_color = Some(self.swatch_ref(c.trim()));
|
||||
}
|
||||
a.border_gap_tint = tint(e.num("ParagraphBorderGapTint"));
|
||||
a.border_gap_overprint = e.boolean("ParagraphBorderGapOverprint");
|
||||
a.shading_corners = corners(e, "ParagraphShading");
|
||||
a.shading_width = e.prop("ParagraphShadingWidth").map(|v| names::box_width_in(v.trim()));
|
||||
a.shading_top = e.prop("ParagraphShadingTopOrigin").map(|v| names::box_top_in(v.trim()));
|
||||
|
||||
+17
-20
@@ -858,28 +858,25 @@ pub fn digits_in(s: &str) -> Option<designcraft_doc::Digits> {
|
||||
[Digits::Default, Digits::Arabic, Digits::Hindi, Digits::Farsi, Digits::Native].into_iter().find(|d| digits_out(*d) == s)
|
||||
}
|
||||
|
||||
/// A TOC separator in IDML's special-character notation: `^t` is a tab, `^^` a caret.
|
||||
/// A TOC separator in IDML's special-character notation.
|
||||
pub fn toc_separator_out(s: &str) -> String {
|
||||
s.replace('^', "^^").replace('\t', "^t")
|
||||
designcraft_doc::story::toc_separator_notation(s)
|
||||
}
|
||||
/// IDML TOC separator → text (`^t` and `^y`, the right indent tab, are tabs; other codes stay).
|
||||
/// IDML may prefix a separator with its localized-string marker.
|
||||
pub fn toc_separator_in(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut it = s.chars();
|
||||
while let Some(c) = it.next() {
|
||||
if c != '^' {
|
||||
out.push(c);
|
||||
continue;
|
||||
}
|
||||
match it.next() {
|
||||
Some('t' | 'y') => out.push('\t'),
|
||||
Some('^') => out.push('^'),
|
||||
Some(o) => {
|
||||
out.push('^');
|
||||
out.push(o);
|
||||
}
|
||||
None => out.push('^'),
|
||||
}
|
||||
designcraft_doc::story::toc_separator_text(s.strip_prefix("$ID/").unwrap_or(s))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod toc_separator_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn localized_right_indent_tab_is_not_an_ordinary_tab() {
|
||||
assert_eq!(toc_separator_in("$ID/^y"), "\u{E006}");
|
||||
assert_eq!(toc_separator_in("$ID/ ^y "), " \u{E006} ");
|
||||
assert_eq!(toc_separator_in("^t"), "\t");
|
||||
assert_eq!(toc_separator_out("\u{E006}"), "^y");
|
||||
assert_eq!(toc_separator_in("^^y ^q ^"), "^y ^q ^");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -330,12 +330,19 @@ fn indesign_text_defaults() -> Vec<AttrRow> {
|
||||
"RuleBelow": "false" => false, "true" => true, |r| r.0.rule_below.on;
|
||||
"RuleBelowColor": "Text Color" => designcraft_doc::TEXT_COLOR, "Color/Black" => "[Black]", |r| r.0.rule_below.color;
|
||||
"RuleBelowTint": "-1" => 1.0f32, "50" => 0.5f32, |r| r.0.rule_below.tint;
|
||||
"RuleBelowType": "StrokeStyle/$ID/Solid" => designcraft_doc::StrokeType::Solid, "StrokeStyle/$ID/Wavy" => designcraft_doc::StrokeType::Wavy, |r| r.0.rule_below.kind;
|
||||
"RuleBelowGapColor": "Swatch/None" => "[None]", "Color/Black" => "[Black]", |r| r.0.rule_below.gap_color;
|
||||
"RuleBelowGapTint": "-1" => 1.0f32, "50" => 0.5f32, |r| r.0.rule_below.gap_tint;
|
||||
"KeepRuleAboveInFrame": "false" => false, "true" => true, |r| r.0.rule_above.keep_in_frame;
|
||||
"ParagraphShadingOn": "false" => false, "true" => true, |r| r.0.shading_on;
|
||||
"ParagraphShadingColor": "Color/Black" => "[Black]", "Swatch/None" => "[None]", |r| r.0.shading_color;
|
||||
"ParagraphShadingTint": "20" => 0.2f32, "50" => 0.5f32, |r| r.0.shading_tint;
|
||||
"ParagraphBorderOn": "false" => false, "true" => true, |r| r.0.border_on;
|
||||
"ParagraphBorderColor": "Color/Black" => "[Black]", "Swatch/None" => "[None]", |r| r.0.border_color;
|
||||
"ParagraphBorderTint": "-1" => 1.0f32, "50" => 0.5f32, |r| r.0.border_tint;
|
||||
"ParagraphShadingOverprint": "false" => false, "true" => true, |r| r.0.shading_overprint;
|
||||
"ParagraphBorderOverprint": "false" => false, "true" => true, |r| r.0.border_overprint;
|
||||
"ParagraphBorderGapOverprint": "false" => false, "true" => true, |r| r.0.border_gap_overprint;
|
||||
"FontStyle": "Regular" => "Regular", "Bold" => "Bold", |r| r.1.font_style;
|
||||
"PointSize": "12" => 12.0, "9" => 9.0, |r| r.1.size;
|
||||
"Leading": "Auto" => Leading::Auto, "14" => Leading::Points(14.0), |r| r.1.leading;
|
||||
@@ -3849,6 +3856,7 @@ fn object_style_effects_import_by_category_and_based_on() {
|
||||
let base = fx("Base").unwrap();
|
||||
assert!(base.drop_shadow.on && base.drop_shadow.size == 9.0 && (base.drop_shadow.distance - 5.0).abs() < 1e-9);
|
||||
assert_eq!(base.feather, 0.0, "EnableFeather=false");
|
||||
assert_eq!(base.drop_shadow.mode, designcraft_color::BlendMode::Normal, "no BlendMode: Normal, as earlier exports rendered");
|
||||
let child = fx("Child").unwrap();
|
||||
assert_eq!((child.drop_shadow.size, child.feather), (9.0, 6.0), "settings from the BasedOn style");
|
||||
assert!(fx("Off").is_none());
|
||||
@@ -3934,6 +3942,45 @@ fn style_names_ending_in_a_space_and_partial_rule_overrides_import() {
|
||||
assert_ne!(black.color, white.color, "only the colour is overridden");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_style_setting_only_a_rules_type_gap_or_keep_in_frame_overrides_it() {
|
||||
let styles = STYLES.replace(
|
||||
r#" </RootParagraphStyleGroup>"#,
|
||||
r#" <ParagraphStyle Self="ParagraphStyle/Base" Name="Base" RuleBelow="true" RuleBelowLineWeight="3">
|
||||
<Properties><BasedOn type="string">$ID/[No paragraph style]</BasedOn></Properties>
|
||||
</ParagraphStyle>
|
||||
<ParagraphStyle Self="ParagraphStyle/Wavy" Name="Wavy" RuleBelowType="StrokeStyle/$ID/Wavy">
|
||||
<Properties><BasedOn type="object">ParagraphStyle/Base</BasedOn></Properties>
|
||||
</ParagraphStyle>
|
||||
<ParagraphStyle Self="ParagraphStyle/Gap" Name="Gap" RuleBelowGapColor="Color/Black">
|
||||
<Properties><BasedOn type="string">$ID/[No paragraph style]</BasedOn></Properties>
|
||||
</ParagraphStyle>
|
||||
<ParagraphStyle Self="ParagraphStyle/Kept" Name="Kept" KeepRuleAboveInFrame="true">
|
||||
<Properties><BasedOn type="string">$ID/[No paragraph style]</BasedOn></Properties>
|
||||
</ParagraphStyle>
|
||||
<ParagraphStyle Self="ParagraphStyle/TextGap" Name="TextGap" RuleBelowGapColor="$ID/Text Color" RuleAboveColor="$ID/Text Color">
|
||||
<Properties><BasedOn type="string">$ID/[No paragraph style]</BasedOn><RuleAboveGapColor type="string">Text Color</RuleAboveGapColor></Properties>
|
||||
</ParagraphStyle>
|
||||
</RootParagraphStyleGroup>"#,
|
||||
);
|
||||
let d = import_idml(&fixture_with_table_styles(&styles, STORY)).unwrap();
|
||||
let style = |n: &str| d.styles.paragraph.iter().find(|s| s.name == n).unwrap().para.clone();
|
||||
let wavy = style("Wavy").rule_below.expect("the type alone overrides the rule");
|
||||
assert_eq!((wavy.on, wavy.weight, wavy.kind), (true, 3.0, designcraft_doc::StrokeType::Wavy), "the rest from the based-on style");
|
||||
let gap = style("Gap").rule_below.expect("the gap colour alone, with no on or weight in the chain");
|
||||
assert!(!gap.on && gap.gap_color != designcraft_color::swatch::NONE, "{gap:?}");
|
||||
assert!(style("Kept").rule_above.expect("Keep In Frame alone").keep_in_frame);
|
||||
// Text Color, in either spelling, for a gap and a rule colour.
|
||||
let text = style("TextGap");
|
||||
let (below, above) = (text.rule_below.expect("rule below"), text.rule_above.expect("rule above"));
|
||||
assert_eq!(below.gap_color, designcraft_doc::TEXT_COLOR);
|
||||
assert_eq!((above.gap_color.as_str(), above.color.as_str()), (designcraft_doc::TEXT_COLOR, designcraft_doc::TEXT_COLOR));
|
||||
// And it is written back as Text Color, not as no swatch.
|
||||
let back = import_idml(&export_idml(&d)).unwrap();
|
||||
let below = back.styles.paragraph.iter().find(|s| s.name == "TextGap").unwrap().para.rule_below.clone().unwrap();
|
||||
assert_eq!(below.gap_color, designcraft_doc::TEXT_COLOR, "round-trips");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_linked_graphics_show_their_preview_and_keep_their_link() {
|
||||
// InDesign's preview of the missing photo (a PNG header is all the importer reads: 4 × 3).
|
||||
@@ -4590,6 +4637,98 @@ fn cell_first_baseline_comes_from_cell_styles_table_regions_and_cells_and_round_
|
||||
assert_eq!(s2.first_baseline, Some(FirstBaseline::CapHeight));
|
||||
}
|
||||
|
||||
/// A TOC style as InDesign writes it: `$ID/`-prefixed separator (`^y`, the right indent tab) and entry style.
|
||||
const TOC_STYLES: &str = r#"<idPkg:Styles xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging">
|
||||
<RootParagraphStyleGroup>
|
||||
<ParagraphStyle Self="ParagraphStyle/Heading 1" Name="Heading 1"/>
|
||||
<ParagraphStyleGroup Self="psg-toc" Name="TOC">
|
||||
<ParagraphStyle Self="ParagraphStyle/TOC%3aTOC body text" Name="TOC:TOC body text" Capitalization="AllCaps"/>
|
||||
<ParagraphStyle Self="ParagraphStyle/TOC%3aTOC Header" Name="TOC:TOC Header"/>
|
||||
</ParagraphStyleGroup>
|
||||
</RootParagraphStyleGroup>
|
||||
<RootCharacterStyleGroup><CharacterStyle Self="CharacterStyle/TOC Page Number" Name="TOC Page Number"/></RootCharacterStyleGroup>
|
||||
<TOCStyle Self="TOCStyle/$ID/DefaultTOCStyleName" TitleStyle="ParagraphStyle/TOC%3aTOC Header" Title="Table of Contents" Name="$ID/DefaultTOCStyleName">
|
||||
<TOCStyleEntry Self="e1" Name="Heading 1" Level="1" PageNumberPosition="AfterEntry" Separator="$ID/^y">
|
||||
<Properties><FormatStyle type="object">ParagraphStyle/$ID/TOC%3aTOC body text</FormatStyle>
|
||||
<PageNumberStyle type="object">CharacterStyle/TOC Page Number</PageNumberStyle>
|
||||
<SeparatorStyle type="string">$ID/[Same Style]</SeparatorStyle></Properties>
|
||||
</TOCStyleEntry>
|
||||
</TOCStyle></idPkg:Styles>"#;
|
||||
|
||||
const TOC_STORY: &str = r#"<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging">
|
||||
<Story Self="s1" AppliedTOCStyle="TOCStyle/$ID/DefaultTOCStyleName"><ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/TOC%3aTOC body text"><CharacterStyleRange><Content>Welcome</Content></CharacterStyleRange></ParagraphStyleRange></Story></idPkg:Story>"#;
|
||||
|
||||
#[test]
|
||||
fn toc_style_entries_read_id_prefixed_separators_and_styles() {
|
||||
let d = import_idml(&fixture_with_table_styles(TOC_STYLES, TOC_STORY)).unwrap();
|
||||
let toc = d.toc.as_ref().expect("the story's TOC style is imported");
|
||||
let entry = toc.entries.iter().find(|e| e.style == "Heading 1").unwrap();
|
||||
assert_eq!(entry.separator.as_deref(), Some("\u{E006}"), "right-indent tabs must not become ordinary tab stops");
|
||||
assert_eq!(entry.entry_style.as_deref(), Some("TOC/TOC body text"));
|
||||
assert_eq!(entry.page_number_style.as_deref(), Some("TOC Page Number"));
|
||||
assert_eq!(toc.title_style.as_deref(), Some("TOC/TOC Header"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toc_style_entries_fall_back_on_malformed_and_unsupported_values() {
|
||||
let long = "x".repeat(65);
|
||||
let entries = format!(
|
||||
r#"<TOCStyleEntry Self="e1" Name="Heading 1" Level="abc" PageNumberPosition="Sideways" Separator="$ID/^q">
|
||||
<Properties><FormatStyle type="object">ParagraphStyle/Missing</FormatStyle>
|
||||
<PageNumberStyle type="object">CharacterStyle/$ID/[None]</PageNumberStyle>
|
||||
<SeparatorStyle type="object">CharacterStyle/Missing</SeparatorStyle></Properties>
|
||||
</TOCStyleEntry>
|
||||
<TOCStyleEntry Self="e2" Name="TOC:TOC Header" Level="12" Separator="{long}"/>
|
||||
<TOCStyleEntry Self="e3" Name="Heading 1" Level="2" Separator="^t"/>
|
||||
<TOCStyleEntry Self="e4" Name="" Level="3"/>"#
|
||||
);
|
||||
let start = TOC_STYLES.find("<TOCStyleEntry").unwrap();
|
||||
let end = TOC_STYLES.find("</TOCStyle>").unwrap();
|
||||
let styles = format!("{}{entries}\n{}", &TOC_STYLES[..start], &TOC_STYLES[end..])
|
||||
.replace(r#"TitleStyle="ParagraphStyle/TOC%3aTOC Header""#, r#"TitleStyle="ParagraphStyle/Missing""#);
|
||||
let d = import_idml(&fixture_with_table_styles(&styles, TOC_STORY)).unwrap();
|
||||
d.check().unwrap();
|
||||
let toc = d.toc.as_ref().expect("the TOC style is still imported");
|
||||
assert_eq!(toc.title_style, None, "a title style the document lacks is dropped");
|
||||
assert_eq!(toc.entries.len(), 3, "an entry without a style name is skipped: {:?}", toc.entries);
|
||||
let e1 = &toc.entries[0];
|
||||
assert_eq!(e1.level, 1, "an unreadable level falls back to 1");
|
||||
assert_eq!(e1.page_number, designcraft_doc::TocPageNumber::After, "an unknown page-number position falls back to After");
|
||||
assert_eq!(e1.separator.as_deref(), Some("^q"), "an unknown special-character code is kept as written");
|
||||
assert_eq!(
|
||||
(e1.entry_style.as_deref(), e1.page_number_style.as_deref(), e1.separator_style.as_deref()),
|
||||
(None, None, None),
|
||||
"undefined and [None] styles are no style"
|
||||
);
|
||||
let e2 = &toc.entries[1];
|
||||
assert_eq!(e2.level, 9, "a level past 9 is clamped");
|
||||
assert_eq!(e2.separator, None, "an over-long separator is dropped");
|
||||
assert_eq!((toc.entries[2].level, toc.entries[2].separator.as_deref()), (2, None), "a plain tab is the default separator");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toc_style_references_in_indented_properties_resolve() {
|
||||
// Property children laid out on their own lines: the whitespace around a reference isn't
|
||||
// part of the style name.
|
||||
let styles = TOC_STYLES
|
||||
.replace(
|
||||
">ParagraphStyle/$ID/TOC%3aTOC body text<",
|
||||
">
|
||||
ParagraphStyle/$ID/TOC%3aTOC body text
|
||||
<",
|
||||
)
|
||||
.replace(
|
||||
">CharacterStyle/TOC Page Number<",
|
||||
">
|
||||
CharacterStyle/TOC Page Number
|
||||
<",
|
||||
);
|
||||
let d = import_idml(&fixture_with_table_styles(&styles, TOC_STORY)).unwrap();
|
||||
let entry = d.toc.as_ref().unwrap().entries.iter().find(|e| e.style == "Heading 1").unwrap();
|
||||
assert_eq!(entry.entry_style.as_deref(), Some("TOC/TOC body text"));
|
||||
assert_eq!(entry.page_number_style.as_deref(), Some("TOC Page Number"));
|
||||
}
|
||||
|
||||
/// A graphic placed in a table cell is written into the cell, not dropped.
|
||||
#[test]
|
||||
fn exports_graphic_in_table_cell() {
|
||||
|
||||
@@ -27,6 +27,11 @@ fn story(id: &str, text: &str) -> String {
|
||||
/// right page a guide at 300 and a "Right head". Three pages: 1 alone, then 2–3. Rulers measure
|
||||
/// from each page.
|
||||
fn left_to_right_fixture() -> Vec<u8> {
|
||||
fixture(true)
|
||||
}
|
||||
|
||||
/// [`left_to_right_fixture`], with facing pages on or off.
|
||||
fn fixture(facing: bool) -> Vec<u8> {
|
||||
let margins = r#"<MarginPreference ColumnCount="1" ColumnGutter="12" Top="36" Bottom="36" Left="36" Right="120"/>"#;
|
||||
let page = |id: &str, x: f64, applied: bool, guide: Option<f64>| {
|
||||
let applied = if applied { r#" AppliedMaster="m1""# } else { "" };
|
||||
@@ -48,7 +53,7 @@ fn left_to_right_fixture() -> Vec<u8> {
|
||||
r#"<Document {NS} Self="d" StoryList="sl sr"><idPkg:Preferences src="Resources/Preferences.xml"/><Layer Self="L1" Name="Layer 1"/><idPkg:MasterSpread src="MasterSpreads/m1.xml"/><idPkg:Spread src="Spreads/sp0.xml"/><idPkg:Spread src="Spreads/sp1.xml"/><idPkg:Story src="Stories/sl.xml"/><idPkg:Story src="Stories/sr.xml"/></Document>"#
|
||||
);
|
||||
let prefs = format!(
|
||||
r#"<idPkg:Preferences {NS}><DocumentPreference PageWidth="400" PageHeight="600" FacingPages="true" PageBinding="LeftToRight"/><ViewPreference RulerOrigin="PageOrigin"/></idPkg:Preferences>"#
|
||||
r#"<idPkg:Preferences {NS}><DocumentPreference PageWidth="400" PageHeight="600" FacingPages="{facing}" PageBinding="LeftToRight"/><ViewPreference RulerOrigin="PageOrigin"/></idPkg:Preferences>"#
|
||||
);
|
||||
zip_files(&[
|
||||
("designmap.xml", &map),
|
||||
@@ -109,3 +114,19 @@ fn left_to_right_right_pages_take_mirrored_margins_and_the_right_parent_page() {
|
||||
let again = import_idml(&export_idml(&d)).unwrap();
|
||||
assert_right_pages_mirror_left_pages(&again);
|
||||
}
|
||||
|
||||
/// Without facing pages, a page of a two-page spread shows the parent page in its place, as in
|
||||
/// InDesign: page 3, the second page of its spread, shows the parent's second page, not the
|
||||
/// first page's items again.
|
||||
#[test]
|
||||
fn single_sided_pages_show_the_parent_page_in_their_place() {
|
||||
let d = import_idml(&fixture(false)).unwrap();
|
||||
assert!(d.spreads.iter().flat_map(|s| &s.pages).all(|p| p.side == PageSide::Single));
|
||||
assert_eq!(shown_heads(&d, 0), ["Left head"], "a page alone shows the parent's first page");
|
||||
assert_eq!(shown_heads(&d, 1), ["Left head"]);
|
||||
assert_eq!(shown_heads(&d, 2), ["Right head"]);
|
||||
// What the renderer draws behind each page of the second spread.
|
||||
let (ppi, _) = d.parent_page_for(1).unwrap();
|
||||
assert_eq!(d.shown_parent_pages(designcraft_doc::SpreadRef::Doc(1), 0), [(ppi, 0)]);
|
||||
assert_eq!(d.shown_parent_pages(designcraft_doc::SpreadRef::Doc(1), 1), [(ppi, 1)]);
|
||||
}
|
||||
|
||||
@@ -324,10 +324,22 @@ pub fn pixel_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
.or_else(|| tiff_decoder(bytes)?.dimensions().ok())
|
||||
}
|
||||
|
||||
/// The largest decoded raster, in RGBA bytes: 256 Mpx, a 300 ppi scan of an A0 sheet fits.
|
||||
const MAX_RGBA_BYTES: u64 = 1 << 30;
|
||||
|
||||
/// Whether a raster's header gives it more pixels than [`decode_rgba`] decodes.
|
||||
pub fn too_large_to_decode(bytes: &[u8]) -> bool {
|
||||
// `image` limits only its own output, which can be a quarter of the RGBA copy or less.
|
||||
pixel_size(bytes).is_some_and(|(w, h)| u64::from(w).saturating_mul(u64::from(h)).saturating_mul(4) > MAX_RGBA_BYTES)
|
||||
}
|
||||
|
||||
/// Straight (non-premultiplied) RGBA8 of a raster in display sRGB: an embedded ICC profile is
|
||||
/// applied, and CMYK goes through its own profile or the working CMYK space ([`colour`]).
|
||||
pub fn decode_rgba(bytes: &[u8]) -> Option<image::RgbaImage> {
|
||||
use colour::{DisplayColour, Pixels, to_display_rgba};
|
||||
if too_large_to_decode(bytes) {
|
||||
return None;
|
||||
}
|
||||
if is_psd(bytes) {
|
||||
return psd::decode(bytes);
|
||||
}
|
||||
@@ -520,6 +532,9 @@ pub fn is_cmyk_tiff(bytes: &[u8]) -> bool {
|
||||
/// over the decoder's 256 MiB limit; [`is_cmyk_tiff`] tells those apart from RGB files.
|
||||
pub fn decode_cmyk_tiff(bytes: &[u8]) -> Option<CmykRaster> {
|
||||
use tiff::tags::Tag;
|
||||
if too_large_to_decode(bytes) {
|
||||
return None;
|
||||
}
|
||||
let mut d = tiff_decoder(bytes)?;
|
||||
let has_alpha = match d.colortype().ok()? {
|
||||
tiff::ColorType::CMYK(8 | 16) => false,
|
||||
|
||||
@@ -401,3 +401,97 @@ fn bmp_decodes() {
|
||||
assert_eq!(pixel_size(&bmp), Some((3, 2)));
|
||||
assert_eq!(decode_rgba(&bmp).unwrap().get_pixel(1, 1).0, [0, 0, 255, 255]);
|
||||
}
|
||||
|
||||
/// A 1-bit gray PNG of `side` x `side` blank pixels, written row by row so the test stays small.
|
||||
fn blank_png(side: u32) -> Vec<u8> {
|
||||
use std::io::Write;
|
||||
let chunk = |out: &mut Vec<u8>, kind: &[u8; 4], data: &[u8]| {
|
||||
out.extend((data.len() as u32).to_be_bytes());
|
||||
let mut crc = flate2::Crc::new();
|
||||
crc.update(kind);
|
||||
crc.update(data);
|
||||
out.extend(kind);
|
||||
out.extend(data);
|
||||
out.extend(crc.sum().to_be_bytes());
|
||||
};
|
||||
let row = vec![0u8; 1 + side.div_ceil(8) as usize];
|
||||
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast());
|
||||
for _ in 0..side {
|
||||
z.write_all(&row).unwrap();
|
||||
}
|
||||
let mut ihdr = [side.to_be_bytes(), side.to_be_bytes()].concat();
|
||||
ihdr.extend([1, 0, 0, 0, 0]);
|
||||
let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||
chunk(&mut png, b"IHDR", &ihdr);
|
||||
chunk(&mut png, b"IDAT", &z.finish().unwrap());
|
||||
chunk(&mut png, b"IEND", &[]);
|
||||
png
|
||||
}
|
||||
|
||||
/// A small file that expands past 1 GiB of RGBA is refused from its header (#1586).
|
||||
#[test]
|
||||
fn rasters_past_the_rgba_budget_are_refused_before_decoding() {
|
||||
let png = blank_png(16_385);
|
||||
assert!(png.len() < 1 << 20, "{} bytes", png.len());
|
||||
assert_eq!(pixel_size(&png), Some((16_385, 16_385)));
|
||||
assert_eq!(natural_size(&png), Some((16_385.0, 16_385.0)));
|
||||
assert!(too_large_to_decode(&png));
|
||||
assert!(decode_rgba(&png).is_none());
|
||||
assert!(!too_large_to_decode(&blank_png(16_384)));
|
||||
let small = decode_rgba(&blank_png(9)).expect("decodes");
|
||||
assert_eq!(small.get_pixel(8, 8).0, [0, 0, 0, 255]);
|
||||
}
|
||||
|
||||
/// A GIF whose logical screen is `side` x `side`, holding one black 1x1 frame.
|
||||
fn gif_screen(side: u16) -> Vec<u8> {
|
||||
let mut gif = b"GIF89a".to_vec();
|
||||
gif.extend(side.to_le_bytes());
|
||||
gif.extend(side.to_le_bytes());
|
||||
// Two-colour global table: black, white.
|
||||
gif.extend([0x80, 0, 0, 0, 0, 0, 255, 255, 255]);
|
||||
// Frame at the origin; LZW minimum code size 2, codes clear, 0, end; trailer.
|
||||
gif.extend([0x2C, 0, 0, 0, 0, 1, 0, 1, 0, 0, 2, 2, 0x44, 0x01, 0, 0x3B]);
|
||||
gif
|
||||
}
|
||||
|
||||
/// The header of an uncompressed 8-bit CMYK TIFF of `side` x `side` pixels, without the pixels.
|
||||
fn cmyk_tiff_header(side: u32) -> Vec<u8> {
|
||||
let entries: [(u16, u16, u32, u32); 9] = [
|
||||
(256, 4, 1, side), // width
|
||||
(257, 4, 1, side), // height
|
||||
(258, 3, 4, 122), // bits per sample, stored after the IFD
|
||||
(259, 3, 1, 1), // no compression
|
||||
(262, 3, 1, 5), // separated (CMYK)
|
||||
(273, 4, 1, 130), // strip offset
|
||||
(277, 3, 1, 4), // samples per pixel
|
||||
(278, 4, 1, side), // rows per strip
|
||||
(279, 4, 1, side * side * 4), // strip byte count
|
||||
];
|
||||
let mut t = b"II*\0".to_vec();
|
||||
t.extend(8u32.to_le_bytes());
|
||||
t.extend(9u16.to_le_bytes());
|
||||
for (tag, kind, count, value) in entries {
|
||||
t.extend(tag.to_le_bytes());
|
||||
t.extend(kind.to_le_bytes());
|
||||
t.extend(count.to_le_bytes());
|
||||
t.extend(value.to_le_bytes());
|
||||
}
|
||||
t.extend(0u32.to_le_bytes());
|
||||
assert_eq!(t.len(), 122);
|
||||
t.extend([8u16; 4].iter().flat_map(|b| b.to_le_bytes()));
|
||||
t
|
||||
}
|
||||
|
||||
/// GIF tones and CMYK inks check the RGBA budget too (#1586).
|
||||
#[test]
|
||||
fn gif_tone_and_cmyk_inks_check_the_rgba_budget() {
|
||||
let gif = gif_screen(16_385);
|
||||
assert!(too_large_to_decode(&gif));
|
||||
assert_eq!(tone(&gif), None);
|
||||
assert_eq!(tone(&gif_screen(2)), Some(Tone::Bitmap));
|
||||
let tiff = cmyk_tiff_header(16_385);
|
||||
assert!(is_cmyk_tiff(&tiff));
|
||||
assert_eq!(pixel_size(&tiff), Some((16_385, 16_385)));
|
||||
assert!(too_large_to_decode(&tiff));
|
||||
assert_eq!(decode_cmyk_tiff(&tiff), None);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,9 @@ pub fn tone(bytes: &[u8]) -> Option<Tone> {
|
||||
},
|
||||
// Indexed: what the palette holds decides. Transparent pixels are left out of a bitmap.
|
||||
F::Gif => {
|
||||
if crate::too_large_to_decode(bytes) {
|
||||
return None;
|
||||
}
|
||||
let img = image::load_from_memory_with_format(bytes, F::Gif).ok()?.into_rgba8();
|
||||
let mut opaque = true;
|
||||
let mut two_level = true;
|
||||
|
||||
@@ -31,4 +31,5 @@ hayro-interpret = { version = "0.7.0", default-features = false, features = ["em
|
||||
hayro-cmap = "0.1"
|
||||
kurbo = { workspace = true }
|
||||
tiff = { workspace = true }
|
||||
flate2 = "1"
|
||||
designcraft-fonts = { workspace = true, features = ["testing"] }
|
||||
|
||||
@@ -161,9 +161,22 @@ pub(super) fn cmyk_tiff(data: &[u8], mode: RasterMode) -> Option<Image> {
|
||||
Image::from_custom(CmykImage(Arc::new(r)), mode.interpolate).ok()
|
||||
}
|
||||
|
||||
/// Opaque raster → JPEG (quality 90). `None` when the image has transparency or can't be decoded.
|
||||
/// With `tag_rgb`, the JPEG keeps the source's RGB profile, or gets sRGB.
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Rasters `recompress` has decoded on this thread.
|
||||
static RECOMPRESS_DECODES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Opaque raster → JPEG (quality 90). `None` when the image has transparency, is too large or
|
||||
/// can't be decoded. With `tag_rgb`, the JPEG keeps the source's RGB profile, or gets sRGB.
|
||||
pub(super) fn recompress(data: &[u8], mode: RasterMode) -> Option<Image> {
|
||||
if designcraft_images::too_large_to_decode(data) {
|
||||
return None;
|
||||
}
|
||||
#[cfg(test)]
|
||||
{
|
||||
RECOMPRESS_DECODES.with(|n| n.set(n.get() + 1));
|
||||
}
|
||||
let img = image::load_from_memory(data).ok()?;
|
||||
if img.color().has_alpha() && img.to_rgba8().pixels().any(|p| p[3] < 255) {
|
||||
return None;
|
||||
@@ -536,3 +549,20 @@ mod eps_tests {
|
||||
assert_eq!(civil(i64::MIN), (-292277022657, 1, 27, 8, 29, 52));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod recompress_tests {
|
||||
use super::*;
|
||||
|
||||
/// A raster past the RGBA budget is refused before `recompress` decodes it (#1586).
|
||||
#[test]
|
||||
fn a_raster_past_the_budget_is_not_decoded() {
|
||||
let mode = RasterMode { interpolate: false, tag_rgb: false };
|
||||
let decodes = || RECOMPRESS_DECODES.with(std::cell::Cell::get);
|
||||
let start = decodes();
|
||||
assert!(recompress(&crate::tests::blank_png(16_385), mode).is_none());
|
||||
assert_eq!(decodes(), start);
|
||||
assert!(recompress(&crate::tests::blank_png(9), mode).is_some());
|
||||
assert_eq!(decodes(), start + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,6 +279,32 @@ fn cmyk_tiff() -> (Vec<u8>, Vec<u8>) {
|
||||
(b, px)
|
||||
}
|
||||
|
||||
/// A black `side`² 1-bit grey PNG: well under 1 MB, it decodes to `side`² bytes.
|
||||
pub(crate) fn blank_png(side: u32) -> Vec<u8> {
|
||||
use std::io::Write;
|
||||
let chunk = |out: &mut Vec<u8>, kind: &[u8; 4], data: &[u8]| {
|
||||
out.extend((data.len() as u32).to_be_bytes());
|
||||
let mut crc = flate2::Crc::new();
|
||||
crc.update(kind);
|
||||
crc.update(data);
|
||||
out.extend(kind);
|
||||
out.extend(data);
|
||||
out.extend(crc.sum().to_be_bytes());
|
||||
};
|
||||
let row = vec![0u8; 1 + side.div_ceil(8) as usize];
|
||||
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast());
|
||||
for _ in 0..side {
|
||||
z.write_all(&row).unwrap();
|
||||
}
|
||||
let mut ihdr = [side.to_be_bytes(), side.to_be_bytes()].concat();
|
||||
ihdr.extend([1, 0, 0, 0, 0]);
|
||||
let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||
chunk(&mut png, b"IHDR", &ihdr);
|
||||
chunk(&mut png, b"IDAT", &z.finish().unwrap());
|
||||
chunk(&mut png, b"IEND", &[]);
|
||||
png
|
||||
}
|
||||
|
||||
fn place(d: &mut Document, data: Vec<u8>, px: (u32, u32)) {
|
||||
let asset = designcraft_doc::AssetId(d.alloc());
|
||||
let mime = designcraft_images::mime(&data).into();
|
||||
@@ -350,6 +376,19 @@ fn a_cut_off_image_is_skipped_not_fatal() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A raster past the RGBA budget is skipped with a warning, also when images are recompressed (#1586).
|
||||
#[test]
|
||||
fn an_image_past_the_decode_budget_is_skipped_with_a_warning() {
|
||||
let side = 16_385;
|
||||
let mut d = doc_with_text("still here");
|
||||
place(&mut d, blank_png(side), (side, side));
|
||||
for compress_images in [false, true] {
|
||||
let r = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { compress_images, ..Default::default() }).unwrap();
|
||||
assert!(r.warnings.iter().any(|w| w.contains("could not be decoded and was skipped")), "compress_images {compress_images}: {:?}", r.warnings);
|
||||
assert!(image_xobjects(&r.bytes).is_empty(), "compress_images {compress_images}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Every image XObject in a PDF: its /ColorSpace name (`?` when not a name) and decoded samples.
|
||||
fn image_xobjects(bytes: &[u8]) -> Vec<(String, Vec<u8>)> {
|
||||
use hayro_syntax::object::Name;
|
||||
|
||||
@@ -276,6 +276,9 @@ impl Exporter<'_> {
|
||||
// Paragraph shading and rules under the text.
|
||||
self.as_artifact(s, |me, s| {
|
||||
for d in ft.decos.iter().filter(|d| !d.nonprinting) {
|
||||
if d.overprint {
|
||||
me.warn("overprint is not exported yet");
|
||||
}
|
||||
if let Some(c) = me.swatch_color(&d.color, d.tint) {
|
||||
match &d.path {
|
||||
Some(p) => me.fill_path(s, p, c),
|
||||
|
||||
@@ -202,7 +202,11 @@ pub(crate) fn overprints(f: &Frame, swatch: &str, tint: f32, flag: bool) -> bool
|
||||
pub(crate) fn spread_blends(doc: &Document, sp: &designcraft_doc::Spread) -> bool {
|
||||
let blends = |it: &Item| {
|
||||
let mut any = false;
|
||||
it.walk(&mut |i| any |= i.blend != Default::default());
|
||||
// A screened glow or a shadow with a blend mode blends with the objects below too, not the paper.
|
||||
let fx_blends = |e: &designcraft_doc::Effects| {
|
||||
(e.outer_glow.on && e.outer_glow.mode != Default::default()) || (e.drop_shadow.on && e.drop_shadow.mode != Default::default())
|
||||
};
|
||||
it.walk(&mut |i| any |= i.blend != Default::default() || fx_blends(&i.effects));
|
||||
any
|
||||
};
|
||||
sp.items.iter().any(|i| blends(i)) || doc.parents.iter().any(|p| p.items.iter().any(|i| blends(i)))
|
||||
|
||||
+25
-4
@@ -102,19 +102,25 @@ impl Renderer {
|
||||
let mut below = vec![];
|
||||
if e.outer_glow.on {
|
||||
let g = &e.outer_glow;
|
||||
below.push((Vec2::ZERO, g.size, g.spread, g.color.clone(), g.opacity));
|
||||
below.push((Vec2::ZERO, g.size, g.spread, g.color.clone(), g.opacity, g.mode));
|
||||
}
|
||||
if e.drop_shadow.on {
|
||||
let d = &e.drop_shadow;
|
||||
below.push((offset(doc.light_angle(d.angle, d.global_light), d.distance), d.size, d.spread, d.color.clone(), d.opacity));
|
||||
below.push((offset(doc.light_angle(d.angle, d.global_light), d.distance), d.size, d.spread, d.color.clone(), d.opacity, d.mode));
|
||||
}
|
||||
for (off, size, pct, color, opacity) in below {
|
||||
for (off, size, pct, color, opacity, mode) in below {
|
||||
let Some(c) = doc.resolve_color(&color, 1.0) else { continue };
|
||||
let (sigma, grow) = split(size, pct);
|
||||
let filter = shadow(sigma, color_of(&c, opacity.clamp(0.0, 1.0)));
|
||||
// The offset moves the geometry, not the filter: vello drops layer content that lies
|
||||
// entirely outside the viewport before filtering.
|
||||
let r = (reach + off).inflate(grow, grow);
|
||||
// The effect's blend sees what's behind the object: a layer around the filtered one.
|
||||
let mix = crate::blend_mode(mode).mix;
|
||||
if mix != Mix::Normal {
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.push_layer(None, Some(BlendMode::new(mix, Compose::SrcOver)), None, None, None);
|
||||
}
|
||||
self.with_filters(ctx, f, r, sigma, None, |me, c, fr| {
|
||||
let moved = Frame { view: fr.view * Affine::translate(off), ..*fr };
|
||||
c.set_transform(moved.view);
|
||||
@@ -128,6 +134,9 @@ impl Renderer {
|
||||
}
|
||||
c.pop_layer();
|
||||
});
|
||||
if mix != Mix::Normal {
|
||||
ctx.pop_layer();
|
||||
}
|
||||
}
|
||||
// Gradient feather: the object (feathered or not) drawn in its own layer, then kept as
|
||||
// much as the opacity gradient says.
|
||||
@@ -221,7 +230,19 @@ impl Renderer {
|
||||
let s = &e.inner_shadow;
|
||||
let (sigma, choke) = split(s.size, s.choke);
|
||||
let off = offset(doc.light_angle(s.angle, s.global_light), s.distance);
|
||||
self.inner_edge(ctx, f, reach, bp, xf, off, sigma, choke, color_of(&c, s.opacity.clamp(0.0, 1.0)), Mix::Normal, painted);
|
||||
self.inner_edge(
|
||||
ctx,
|
||||
f,
|
||||
reach,
|
||||
bp,
|
||||
xf,
|
||||
off,
|
||||
sigma,
|
||||
choke,
|
||||
color_of(&c, s.opacity.clamp(0.0, 1.0)),
|
||||
crate::blend_mode(s.mode).mix,
|
||||
painted,
|
||||
);
|
||||
}
|
||||
// Inner glow: from the edges inwards (or out from the centre), screened over the object.
|
||||
if e.inner_glow.on
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
@@ -13,7 +13,7 @@ use designcraft_geom::{Affine, Rect, shapes};
|
||||
use designcraft_render::{RenderOptions, Renderer, color_of};
|
||||
use image::{ExtendedColorType, ImageEncoder};
|
||||
|
||||
const RED: &str = "C=0 M=100 Y=100 K=0";
|
||||
const RED: &str = "C=15 M=100 Y=100 K=0";
|
||||
const CYAN: &str = "C=100 M=0 Y=0 K=0";
|
||||
|
||||
fn encode(px: &[u8], w: u32, ct: ExtendedColorType, gif: bool) -> Vec<u8> {
|
||||
|
||||
@@ -141,6 +141,8 @@ fn outer_glow_surrounds_the_object() {
|
||||
g.size = 12.0;
|
||||
g.color = "C=0 M=100 Y=0 K=0".into();
|
||||
g.opacity = 1.0;
|
||||
// Normal, so the glow shows on the white page (screened, magenta on white is white).
|
||||
g.mode = designcraft_color::BlendMode::Normal;
|
||||
let img = render(&d);
|
||||
golden("outer-glow", &img);
|
||||
for (x, y) in [(46, 80), (114, 80), (80, 46), (80, 114)] {
|
||||
@@ -150,6 +152,49 @@ fn outer_glow_surrounds_the_object() {
|
||||
assert_eq!(lum(img.pixel(10, 10)), WHITE);
|
||||
}
|
||||
|
||||
/// An effect's blend mode works on what's behind it: a glow screened onto a dark backdrop
|
||||
/// lightens it in the glow's colour, where Normal paints the glow's colour over it.
|
||||
#[test]
|
||||
fn outer_glow_blends_with_its_mode() {
|
||||
let glow = |mode| {
|
||||
let mut d = doc();
|
||||
rect_item(&mut d, Rect::new(0.0, 0.0, 160.0, 160.0), "C=100 M=90 Y=10 K=0");
|
||||
let id = rect_item(&mut d, Rect::new(50.0, 50.0, 110.0, 110.0), "[Black]");
|
||||
let g = &mut d.item_mut(id).unwrap().effects.outer_glow;
|
||||
g.on = true;
|
||||
g.size = 12.0;
|
||||
g.color = "C=0 M=0 Y=100 K=0".into();
|
||||
g.opacity = 1.0;
|
||||
g.mode = mode;
|
||||
render(&d)
|
||||
};
|
||||
let screen = glow(designcraft_color::BlendMode::Screen);
|
||||
let normal = glow(designcraft_color::BlendMode::Normal);
|
||||
let backdrop = screen.pixel(10, 10);
|
||||
let (s, n) = (screen.pixel(46, 80), normal.pixel(46, 80));
|
||||
// Screen only lightens: every channel at least the backdrop's, and the navy shows through
|
||||
// (blue stays high), where Normal paints yellow (blue drops).
|
||||
assert!(s.iter().zip(backdrop).take(3).all(|(a, b)| *a + 2 >= b), "screen lightens: {s:?} over {backdrop:?}");
|
||||
assert!(s[2] > n[2] + 25, "screen keeps the backdrop's blue: {s:?} vs normal {n:?}");
|
||||
}
|
||||
|
||||
/// A screened glow over blank paper shows in its colour: it blends with the objects below, and
|
||||
/// the paper is not one of them.
|
||||
#[test]
|
||||
fn screened_outer_glow_shows_on_blank_paper() {
|
||||
let mut d = doc();
|
||||
let id = rect_item(&mut d, Rect::new(50.0, 50.0, 110.0, 110.0), "[Black]");
|
||||
let g = &mut d.item_mut(id).unwrap().effects.outer_glow;
|
||||
g.on = true;
|
||||
g.size = 12.0;
|
||||
g.color = "C=0 M=0 Y=100 K=0".into();
|
||||
g.opacity = 1.0;
|
||||
g.mode = designcraft_color::BlendMode::Screen;
|
||||
let img = render(&d);
|
||||
let p = img.pixel(46, 80);
|
||||
assert!(p[2] < 200 && p[0] > p[2] + 40, "yellow glow beside the item: {p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inner_shadow_darkens_inside_the_lit_edge() {
|
||||
let mut d = doc();
|
||||
|
||||
@@ -405,6 +405,9 @@ impl Tool for FrameTool {
|
||||
snap_free_line(cx, sr, sa, b, &mut self.guides)
|
||||
};
|
||||
}
|
||||
// The readout measures the line as drawn (Shift and snapping included), in
|
||||
// spread space.
|
||||
self.last = Some((spread_json(sr), Rect::from_points(sa, b)));
|
||||
out.push(Action::Preview("line.create".into(), json!({"spread": spread_json(sr), "a": [sa.x, sa.y], "b": [b.x, b.y]})));
|
||||
} else {
|
||||
let end = cx.layout.to_spread(sr, ev.pos);
|
||||
@@ -458,11 +461,18 @@ impl Tool for FrameTool {
|
||||
}
|
||||
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
if let (true, Some(a)) = (self.active, self.start) {
|
||||
let r = Rect::from_points(a, self.cur);
|
||||
let _ = cx;
|
||||
// The frame or line being drawn (Shift, Alt and snapping included, in spread space), in the
|
||||
// document's units to two decimals, on two lines: InDesign's readout ("W: 3.13 cm",
|
||||
// "H: 3.13 cm"). Nothing before the first drag step.
|
||||
if self.active
|
||||
&& self.start.is_some()
|
||||
&& let Some((_, r)) = self.last.as_ref()
|
||||
{
|
||||
let units = &cx.doc.settings;
|
||||
let w = designcraft_geom::units::format_measure_prec(r.width().abs(), units.horizontal_units, 2);
|
||||
let h = designcraft_geom::units::format_measure_prec(r.height().abs(), units.vertical_units, 2);
|
||||
let mut v = self.guides.clone();
|
||||
v.push(Overlay::Measure { p: self.cur, text: format!("W: {:.0} pt H: {:.0} pt", r.width(), r.height()) });
|
||||
v.push(Overlay::Measure { p: self.cur, text: format!("W: {w}\nH: {h}") });
|
||||
return v;
|
||||
}
|
||||
vec![]
|
||||
|
||||
@@ -16,7 +16,7 @@ mod frame;
|
||||
mod gradient;
|
||||
pub mod layout;
|
||||
mod nav;
|
||||
pub use nav::{LOCK_OFFSET_PX, PIN_OFFSET_PX};
|
||||
pub use nav::{LOCK_OFFSET_PX, PIN_OFFSET_PX, PLACE_DRAG_MIN};
|
||||
mod pen;
|
||||
mod pencil;
|
||||
pub mod select;
|
||||
@@ -232,6 +232,8 @@ pub struct ToolContext<'a> {
|
||||
pub unit: Unit,
|
||||
/// The toolbox's Stroke box is in front: sampled colours go to strokes (the Eyedropper).
|
||||
pub stroke_in_front: bool,
|
||||
/// Natural size, in points, of the graphic loaded in the place cursor.
|
||||
pub loaded: Option<(f64, f64)>,
|
||||
}
|
||||
|
||||
impl ToolContext<'_> {
|
||||
|
||||
+64
-8
@@ -133,7 +133,8 @@ impl Tool for PlaceGun {
|
||||
let Some(a) = self.start.take() else { return vec![] };
|
||||
let Some((sr, sa)) = cx.layout.spread_at(a) else { return vec![] };
|
||||
let b = cx.layout.to_spread(sr, ev.pos);
|
||||
if (ev.pos - a).hypot() < cx.tol(3.0)
|
||||
let click = (ev.pos - a).hypot() < cx.tol(3.0);
|
||||
if click
|
||||
&& let Some((_, id)) = cx.hit(a)
|
||||
&& cx
|
||||
.doc
|
||||
@@ -142,7 +143,12 @@ impl Tool for PlaceGun {
|
||||
{
|
||||
return vec![Action::Exec("place.drop".into(), json!({"frame": id.0}))];
|
||||
}
|
||||
let r = designcraft_geom::Rect::from_points(sa, b);
|
||||
// Under the drag threshold it's a click, as the overlay shows: actual size there.
|
||||
if click {
|
||||
return vec![Action::Exec("place.drop".into(), json!({"spread": crate::spread_json(sr), "x": sa.x, "y": sa.y}))];
|
||||
}
|
||||
// The frame the drag drew: at the graphic's proportions from where it started.
|
||||
let r = cx.loaded.map_or_else(|| designcraft_geom::Rect::from_points(sa, b), |size| proportional(sa, b, size).0);
|
||||
vec![Action::Exec(
|
||||
"place.drop".into(),
|
||||
json!({"spread": crate::spread_json(sr), "x": sa.x, "y": sa.y, "rect": [r.x0, r.y0, r.x1, r.y1]}),
|
||||
@@ -151,11 +157,29 @@ impl Tool for PlaceGun {
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
fn overlays(&self, _cx: &ToolContext) -> Vec<crate::Overlay> {
|
||||
match self.start {
|
||||
Some(a) => vec![crate::Overlay::Marquee(designcraft_geom::Rect::from_points(a, self.cur))],
|
||||
None => vec![],
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<crate::Overlay> {
|
||||
let Some(a) = self.start else { return vec![] };
|
||||
let Some(size) = cx.loaded else {
|
||||
return vec![crate::Overlay::Marquee(designcraft_geom::Rect::from_points(a, self.cur))];
|
||||
};
|
||||
// Under the drag threshold the release is a click (actual size where it was pressed):
|
||||
// no frame is drawn yet, the loaded cursor shows what lands.
|
||||
if (self.cur - a).hypot() < cx.tol(3.0) {
|
||||
return vec![];
|
||||
}
|
||||
// As InDesign: the frame the graphic will fill, and its scale beside the pointer.
|
||||
let (r, k) = drawn_frame(a, self.cur, size);
|
||||
let pct = format!("{:.2}%", k * 100.0);
|
||||
vec![
|
||||
crate::Overlay::Marquee(r),
|
||||
crate::Overlay::Measure {
|
||||
p: self.cur,
|
||||
text: format!(
|
||||
"X: {pct}
|
||||
Y: {pct}"
|
||||
),
|
||||
},
|
||||
]
|
||||
}
|
||||
fn key(&mut self, _cx: &ToolContext, key: crate::ToolKey, _mods: Mods) -> Vec<Action> {
|
||||
match key {
|
||||
@@ -164,11 +188,43 @@ impl Tool for PlaceGun {
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
|
||||
Cursor::LoadedGraphic
|
||||
fn cursor(&self, cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
|
||||
// Drawing the frame: the crosshair (the loaded graphic's thumbnail would hide the frame).
|
||||
// Under the drag threshold no frame is drawn yet (the release is a click), so the
|
||||
// thumbnail stays, as the overlay does. With no graphic loaded there's no thumbnail to
|
||||
// show: the crosshair throughout.
|
||||
let drawing = cx.loaded.is_none() || self.start.is_some_and(|a| (self.cur - a).hypot() >= cx.tol(3.0));
|
||||
if drawing { Cursor::Crosshair } else { Cursor::LoadedGraphic }
|
||||
}
|
||||
}
|
||||
|
||||
/// A dragged frame `place.drop` takes only when it is wider and taller than this, in points;
|
||||
/// a thinner one places the graphic at its natural size instead.
|
||||
pub const PLACE_DRAG_MIN: f64 = 2.0;
|
||||
|
||||
/// The frame `place.drop` makes for a place-cursor drag from `a` to `b`: the proportional frame
|
||||
/// (see [`proportional`]), or the graphic at natural size from `a` when that frame is at most
|
||||
/// [`PLACE_DRAG_MIN`] in either direction. With its scale.
|
||||
fn drawn_frame(a: Point, b: Point, size: (f64, f64)) -> (designcraft_geom::Rect, f64) {
|
||||
let (r, k) = proportional(a, b, size);
|
||||
if r.width() > PLACE_DRAG_MIN && r.height() > PLACE_DRAG_MIN {
|
||||
(r, k)
|
||||
} else {
|
||||
(designcraft_geom::Rect::new(a.x, a.y, a.x + size.0, a.y + size.1), 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame a place-cursor drag from `a` to `b` draws for a graphic of natural `size`: the
|
||||
/// graphic's proportions, from `a` towards `b`, as large as fits the drag. With its scale.
|
||||
fn proportional(a: Point, b: Point, size: (f64, f64)) -> (designcraft_geom::Rect, f64) {
|
||||
let (w, h) = (size.0.max(1e-6), size.1.max(1e-6));
|
||||
let (dx, dy) = (b.x - a.x, b.y - a.y);
|
||||
let k = (dx.abs() / w).min(dy.abs() / h);
|
||||
let k = if k.is_finite() { k } else { 0.0 };
|
||||
let c = Point::new(a.x + (w * k).copysign(dx), a.y + (h * k).copysign(dy));
|
||||
(designcraft_geom::Rect::from_points(a, c), k)
|
||||
}
|
||||
|
||||
/// Page tool (Shift+P): click a page to select it; drag one of its handles to preview its liquid
|
||||
/// page rule (the page springs back on release), Alt-drag to resize it, Shift to keep its
|
||||
/// proportions; double-click a page for exact sizes.
|
||||
|
||||
+242
-17
@@ -92,6 +92,8 @@ enum Drag {
|
||||
/// None when that point could not be read. The live document during a drag is the
|
||||
/// previous preview, so the snap must not read the anchor from it again.
|
||||
at: Option<(SpreadRef, Point)>,
|
||||
/// The other selected anchor points, which move with it: (item, subpath, anchor).
|
||||
others: Vec<(u64, usize, usize)>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -144,21 +146,42 @@ pub fn anchor_at(cx: &ToolContext, p: Point) -> Option<(u64, usize, usize, Optio
|
||||
anchor_at_in(cx, &cx.selection.items, p)
|
||||
}
|
||||
|
||||
/// [`anchor_at`] over the given items.
|
||||
/// Whether the Direct Selection tool shows (and lets you drag) a direction handle of anchor `ai`
|
||||
/// of a sub-path of `n` anchors, as InDesign: both handles of a selected anchor, and on its
|
||||
/// neighbours the handle that shapes the segment to it (`out`: the outgoing handle). With no
|
||||
/// anchor selected no handle shows.
|
||||
pub fn handle_shown(n: usize, closed: bool, ai: usize, out: bool, chosen: impl Fn(usize) -> bool) -> bool {
|
||||
if chosen(ai) {
|
||||
return true;
|
||||
}
|
||||
let neighbour = if out {
|
||||
if ai + 1 < n { Some(ai + 1) } else { closed.then_some(0) }
|
||||
} else if ai > 0 {
|
||||
Some(ai - 1)
|
||||
} else {
|
||||
closed.then(|| n.saturating_sub(1))
|
||||
};
|
||||
neighbour.is_some_and(chosen)
|
||||
}
|
||||
|
||||
/// [`anchor_at`] over the given items. Of the direction handles, only those [`handle_shown`]
|
||||
/// shows are found.
|
||||
pub fn anchor_at_in(cx: &ToolContext, ids: &[designcraft_doc::ItemId], p: Point) -> Option<(u64, usize, usize, Option<&'static str>)> {
|
||||
let tol = cx.tol(5.0);
|
||||
for id in ids {
|
||||
let (Some(it), Some(xf)) = (cx.doc.item(*id), cx.item_canvas_xf(*id)) else { continue };
|
||||
let m = xf * it.xf;
|
||||
for (si, sp) in it.path.subpaths.iter().enumerate() {
|
||||
let chosen = |a: usize| cx.selection.anchors.contains(&(*id, si, a));
|
||||
for (ai, a) in sp.anchors.iter().enumerate() {
|
||||
if ((m * a.p) - p).hypot() <= tol {
|
||||
return Some((id.0, si, ai, None));
|
||||
}
|
||||
if a.has_in() && ((m * a.h_in) - p).hypot() <= tol {
|
||||
let shown = |out: bool| handle_shown(sp.anchors.len(), sp.closed, ai, out, chosen);
|
||||
if a.has_in() && shown(false) && ((m * a.h_in) - p).hypot() <= tol {
|
||||
return Some((id.0, si, ai, Some("in")));
|
||||
}
|
||||
if a.has_out() && ((m * a.h_out) - p).hypot() <= tol {
|
||||
if a.has_out() && shown(true) && ((m * a.h_out) - p).hypot() <= tol {
|
||||
return Some((id.0, si, ai, Some("out")));
|
||||
}
|
||||
}
|
||||
@@ -181,6 +204,37 @@ fn spread_delta_to_canvas(xf: Affine, d: Vec2) -> Vec2 {
|
||||
(xf * Point::new(d.x, d.y)) - (xf * Point::ORIGIN)
|
||||
}
|
||||
|
||||
/// The anchor points inside `r` (canvas) of the unlocked, visible paths, groups' members included.
|
||||
fn anchors_in(cx: &ToolContext, r: Rect) -> Vec<(ItemId, usize, usize)> {
|
||||
fn walk(cx: &ToolContext, items: &[std::sync::Arc<Item>], r: Rect, out: &mut Vec<(ItemId, usize, usize)>) {
|
||||
for it in items {
|
||||
let locked = it.locked || cx.doc.layer(it.layer).is_some_and(|l| l.locked || !l.visible);
|
||||
if locked || it.hidden {
|
||||
continue;
|
||||
}
|
||||
if let Content::Group { items } = &it.content {
|
||||
walk(cx, items, r, out);
|
||||
continue;
|
||||
}
|
||||
let Some(xf) = cx.item_canvas_xf(it.id) else { continue };
|
||||
let m = xf * it.xf;
|
||||
for (si, ai, a) in it.path.anchors() {
|
||||
let q = m * a.p;
|
||||
if q.x >= r.x0 && q.x <= r.x1 && q.y >= r.y0 && q.y <= r.y1 {
|
||||
out.push((it.id, si, ai));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for slot in &cx.layout.slots {
|
||||
if let Some(sp) = cx.doc.spread(slot.spread) {
|
||||
walk(cx, &sp.items, r, &mut out);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The ruler guides a marquee `r` (canvas) crosses that can be selected, as `guide.select`
|
||||
/// takes them: none while guides are hidden or locked.
|
||||
fn guides_crossing(cx: &ToolContext, r: Rect) -> Vec<Value> {
|
||||
@@ -217,6 +271,43 @@ fn guides_crossing(cx: &ToolContext, r: Rect) -> Vec<Value> {
|
||||
out
|
||||
}
|
||||
|
||||
/// `selection.set` params selecting `points`: their paths, and those points.
|
||||
/// `keep` (Shift): the objects already selected stay selected; otherwise only the paths of
|
||||
/// `points` are.
|
||||
fn anchor_selection(cx: &ToolContext, points: &[(ItemId, usize, usize)], keep: bool) -> Value {
|
||||
let mut ids: Vec<u64> = if keep { cx.selection.items.iter().map(|i| i.0).collect() } else { Vec::new() };
|
||||
for (i, _, _) in points {
|
||||
if !ids.contains(&i.0) {
|
||||
ids.push(i.0);
|
||||
}
|
||||
}
|
||||
let anchors: Vec<Value> = points.iter().map(|(i, s, a)| json!([i.0, s, a])).collect();
|
||||
json!({"ids": ids, "anchors": anchors})
|
||||
}
|
||||
|
||||
/// `path.moveAnchors` points for `points` moved `d` on the canvas: each with its own movement in
|
||||
/// its spread (a rotated spread view turns it).
|
||||
fn moved_points(cx: &ToolContext, points: impl IntoIterator<Item = (ItemId, usize, usize)>, d: Vec2) -> Vec<Value> {
|
||||
points
|
||||
.into_iter()
|
||||
.map(|(i, s, a)| {
|
||||
let v = cx.doc.find(i).map_or(d, |loc| cx.layout.delta_to_spread(loc.spread, d));
|
||||
json!([i.0, s, a, v.x, v.y])
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `d` held to the nearest multiple of 45°, as Shift does.
|
||||
fn constrain_45(d: Vec2) -> Vec2 {
|
||||
if d.hypot() < 1e-9 {
|
||||
return d;
|
||||
}
|
||||
let step = std::f64::consts::FRAC_PI_4;
|
||||
let a = (d.y.atan2(d.x) / step).round() * step;
|
||||
let proj = d.x * a.cos() + d.y * a.sin();
|
||||
Vec2::new(proj * a.cos(), proj * a.sin())
|
||||
}
|
||||
|
||||
pub struct SelectionTool {
|
||||
direct: bool,
|
||||
drag: Drag,
|
||||
@@ -224,6 +315,8 @@ pub struct SelectionTool {
|
||||
guides: Vec<Overlay>,
|
||||
/// Selected item to toggle if this Shift press never becomes a drag.
|
||||
shift_release: Option<u64>,
|
||||
/// Selected anchor point to take away if this Shift press never becomes a drag.
|
||||
anchor_release: Option<(ItemId, usize, usize)>,
|
||||
/// One of several selected items to make (or stop being) the key object for Align if this
|
||||
/// press never becomes a drag.
|
||||
key_release: Option<u64>,
|
||||
@@ -235,7 +328,17 @@ pub struct SelectionTool {
|
||||
|
||||
impl SelectionTool {
|
||||
pub fn new(direct: bool) -> Self {
|
||||
Self { direct, drag: Drag::None, hover_handle: None, guides: vec![], shift_release: None, key_release: None, loaded: None, live: None }
|
||||
Self {
|
||||
direct,
|
||||
drag: Drag::None,
|
||||
hover_handle: None,
|
||||
guides: vec![],
|
||||
shift_release: None,
|
||||
key_release: None,
|
||||
anchor_release: None,
|
||||
loaded: None,
|
||||
live: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// [`bracket_at`] for this tool. With Direct Selection an anchor under the pointer wins: the
|
||||
@@ -689,6 +792,7 @@ impl SelectionTool {
|
||||
|
||||
self.shift_release = None;
|
||||
self.key_release = None;
|
||||
self.anchor_release = None;
|
||||
// Type on a path: a bracket moves where the text starts or ends.
|
||||
if !ev.mods.shift
|
||||
&& let Some((id, end)) = self.bracket_at(cx, p)
|
||||
@@ -700,8 +804,51 @@ impl SelectionTool {
|
||||
&& let Some((id, si, ai, handle)) = anchor_at(cx, p)
|
||||
{
|
||||
let at = anchor_spread_point(cx, id, si, ai);
|
||||
self.drag = Drag::Anchor { id, si, ai, handle, start: p, at };
|
||||
return vec![Action::Begin(if handle.is_some() { "Move Direction Handle".into() } else { "Move Anchor".into() })];
|
||||
let mut out = vec![];
|
||||
let mut others = vec![];
|
||||
if handle.is_none() {
|
||||
// InDesign's anchor point selection: a click selects the point, Shift+click
|
||||
// adds it or takes it away, and dragging a selected point moves all of them.
|
||||
let me = (ItemId(id), si, ai);
|
||||
let mut points = cx.selection.anchors.clone();
|
||||
let was = points.contains(&me);
|
||||
if ev.mods.shift && was {
|
||||
// Kept selected so a Shift-drag can constrain it; a click still takes it
|
||||
// away, on release, as for objects.
|
||||
self.anchor_release = Some(me);
|
||||
} else if ev.mods.shift {
|
||||
points.push(me);
|
||||
} else if !was {
|
||||
points = vec![me];
|
||||
}
|
||||
if points != cx.selection.anchors {
|
||||
out.push(Action::Exec("selection.set".into(), anchor_selection(cx, &points, ev.mods.shift)));
|
||||
}
|
||||
others = points.iter().filter(|a| **a != me).map(|(i, s, a)| (i.0, *s, *a)).collect();
|
||||
}
|
||||
let label = if handle.is_some() {
|
||||
"Move Direction Handle"
|
||||
} else if others.is_empty() {
|
||||
"Move Anchor"
|
||||
} else {
|
||||
"Move Anchors"
|
||||
};
|
||||
self.drag = Drag::Anchor { id, si, ai, handle, start: p, at, others };
|
||||
out.push(Action::Begin(label.into()));
|
||||
return out;
|
||||
}
|
||||
// As in InDesign, the Direct Selection tool pressed on a corner or anchor of an object
|
||||
// that isn't selected selects it and moves just that point (Chris Nielsen's "The
|
||||
// InDesign Toolbox", 10:10: click outside, then pull a frame's corner).
|
||||
if self.direct
|
||||
&& !ev.mods.shift
|
||||
&& let Some((_, hit)) = cx.hit(p)
|
||||
&& !cx.selection.contains(hit)
|
||||
&& let Some((id, si, ai, None)) = anchor_at_in(cx, &[hit], p)
|
||||
{
|
||||
let at = anchor_spread_point(cx, id, si, ai);
|
||||
self.drag = Drag::Anchor { id, si, ai, handle: None, start: p, at, others: vec![] };
|
||||
return vec![Action::Exec("selection.set".into(), json!({"ids": [id], "content": false})), Action::Begin("Move Anchor".into())];
|
||||
}
|
||||
// A selected placed graphic's own handles resize it inside its frame (the
|
||||
// frame's anchors, under Direct Selection, come first).
|
||||
@@ -776,7 +923,11 @@ impl SelectionTool {
|
||||
} else {
|
||||
out.push(Action::Exec("selection.toggle".into(), json!({"id": id.0})));
|
||||
}
|
||||
} else if !cx.selection.contains(id) || (self.direct && !cx.selection.content) {
|
||||
} else if !cx.selection.contains(id)
|
||||
|| (self.direct && !cx.selection.content && (cx.selection.items.len() > 1 || cx.doc.item(id).is_some_and(holds_content)))
|
||||
{
|
||||
// Direct Selection on a selected frame selects its content (a graphic or pasted-in items),
|
||||
// and on one of several selected objects just that object.
|
||||
out.push(Action::Exec("selection.set".into(), json!({"ids": [id.0], "content": self.direct})));
|
||||
} else if !self.direct && cx.selection.items.len() > 1 {
|
||||
// InDesign: a click on one of several selected objects makes it the
|
||||
@@ -1040,8 +1191,18 @@ impl SelectionTool {
|
||||
// Screen y points down: a positive screen angle is clockwise.
|
||||
vec![Action::Preview("transform.rotate".into(), json!({"angle": -a}))]
|
||||
}
|
||||
Drag::Anchor { id, si, ai, handle, start, at } => {
|
||||
let d = p - start;
|
||||
Drag::Anchor { id, si, ai, handle, start, at, others } => {
|
||||
let mut d = p - start;
|
||||
if d.hypot() >= cx.tol(3.0) {
|
||||
self.anchor_release = None;
|
||||
} else if self.anchor_release.is_some() {
|
||||
// A Shift-press on a selected point is still a click: nothing moves yet.
|
||||
return vec![];
|
||||
}
|
||||
// Shift: horizontal, vertical or at 45°.
|
||||
if ev.mods.shift {
|
||||
d = constrain_45(d);
|
||||
}
|
||||
let mut dx = d.x;
|
||||
let mut dy = d.y;
|
||||
self.guides.clear();
|
||||
@@ -1069,18 +1230,25 @@ impl SelectionTool {
|
||||
},
|
||||
);
|
||||
// dx and dy stay in canvas space. Snap.delta is spread space.
|
||||
let corr = spread_delta_to_canvas(cx.layout.xf(spread), hit.delta);
|
||||
if corr.x.is_finite() {
|
||||
dx += corr.x;
|
||||
}
|
||||
if corr.y.is_finite() {
|
||||
dy += corr.y;
|
||||
let mut corr = spread_delta_to_canvas(cx.layout.xf(spread), hit.delta);
|
||||
corr = Vec2::new(if corr.x.is_finite() { corr.x } else { 0.0 }, if corr.y.is_finite() { corr.y } else { 0.0 });
|
||||
// Shift: the snap moves the points along the constrained direction only.
|
||||
if ev.mods.shift && d.hypot() > 1e-9 {
|
||||
let dir = d / d.hypot();
|
||||
corr = dir * corr.dot(dir);
|
||||
}
|
||||
dx += corr.x;
|
||||
dy += corr.y;
|
||||
self.guides = hit.guides;
|
||||
}
|
||||
let mut params = json!({"id": id, "anchors": [[si, ai]], "dx": dx, "dy": dy});
|
||||
// The command moves in spread space: each point by the drag in its own spread.
|
||||
let own = at.map_or(Vec2::new(dx, dy), |(spread, _)| cx.layout.delta_to_spread(spread, Vec2::new(dx, dy)));
|
||||
let mut params = json!({"id": id, "anchors": [[si, ai]], "dx": own.x, "dy": own.y});
|
||||
if let Some(h) = handle {
|
||||
params["handle"] = json!(h);
|
||||
} else if !others.is_empty() {
|
||||
let points = std::iter::once((id, si, ai)).chain(others).map(|(i, s, a)| (ItemId(i), s, a));
|
||||
params = json!({"points": moved_points(cx, points, Vec2::new(dx, dy))});
|
||||
}
|
||||
vec![Action::Preview("path.moveAnchors".into(), params)]
|
||||
}
|
||||
@@ -1147,9 +1315,15 @@ impl Tool for SelectionTool {
|
||||
PointerKind::Up => {
|
||||
self.guides.clear();
|
||||
let release = self.shift_release.take();
|
||||
let anchor_release = self.anchor_release.take();
|
||||
let key = self.key_release.take();
|
||||
let d = std::mem::replace(&mut self.drag, Drag::None);
|
||||
match d {
|
||||
// A Shift-click on a selected point that never became a drag takes it away.
|
||||
Drag::Anchor { .. } if anchor_release.is_some() => {
|
||||
let points: Vec<_> = cx.selection.anchors.iter().copied().filter(|a| Some(*a) != anchor_release).collect();
|
||||
vec![Action::Commit, Action::Exec("selection.set".into(), anchor_selection(cx, &points, true))]
|
||||
}
|
||||
Drag::Move { .. }
|
||||
| Drag::Resize { .. }
|
||||
| Drag::ContentResize { .. }
|
||||
@@ -1166,6 +1340,21 @@ impl Tool for SelectionTool {
|
||||
},
|
||||
Drag::Marquee { start, cur } => {
|
||||
let r = Rect::from_points(start, cur);
|
||||
// Direct Selection: the anchor points inside the marquee, on every path it
|
||||
// reaches (InDesign's way to select points of several frames at once).
|
||||
if self.direct {
|
||||
let mut points = anchors_in(cx, r);
|
||||
if !points.is_empty() {
|
||||
if ev.mods.shift {
|
||||
for a in &cx.selection.anchors {
|
||||
if !points.contains(a) {
|
||||
points.push(*a);
|
||||
}
|
||||
}
|
||||
}
|
||||
return vec![Action::Exec("selection.set".into(), anchor_selection(cx, &points, ev.mods.shift))];
|
||||
}
|
||||
}
|
||||
// Items whose bounds intersect the marquee.
|
||||
let mut ids: Vec<Value> = Vec::new();
|
||||
for slot in &cx.layout.slots {
|
||||
@@ -1202,7 +1391,20 @@ impl Tool for SelectionTool {
|
||||
if key == ToolKey::Escape && self.live.take().is_some_and(|id| cx.selection.items == [id]) {
|
||||
return vec![Action::Handled];
|
||||
}
|
||||
let mv = |dx: f64, dy: f64| vec![Action::Exec("transform.move".into(), json!({"dx": dx, "dy": dy, "copy": mods.alt}))];
|
||||
let direct_points = self.direct && !cx.selection.anchors.is_empty();
|
||||
let mv = |dx: f64, dy: f64| {
|
||||
// Direct Selection with anchor points selected: the arrow keys move the points, each
|
||||
// the way the arrow points on screen.
|
||||
if direct_points {
|
||||
let points = moved_points(cx, cx.selection.anchors.iter().copied(), Vec2::new(dx, dy));
|
||||
return vec![Action::Exec("path.moveAnchors".into(), json!({"points": points}))];
|
||||
}
|
||||
vec![Action::Exec("transform.move".into(), json!({"dx": dx, "dy": dy, "copy": mods.alt}))]
|
||||
};
|
||||
// Selected points: Delete and Backspace delete them, not their paths.
|
||||
if direct_points && matches!(key, ToolKey::Delete | ToolKey::Backspace) {
|
||||
return vec![Action::Exec("path.deletePoints".into(), json!({}))];
|
||||
}
|
||||
if cx.selection.items.is_empty() {
|
||||
// Selected ruler guides: the arrows nudge them, Delete deletes them.
|
||||
if cx.selection.guides.is_empty() {
|
||||
@@ -1347,3 +1549,26 @@ fn edit_text_at(cx: &ToolContext, p: Point) -> Option<Vec<Action>> {
|
||||
let (_, sp) = cx.layout.spread_at(p).unwrap_or((SpreadRef::Doc(0), p));
|
||||
Some(vec![Action::SwitchTool("type".into()), Action::Exec("text.placeCaret".into(), json!({"frame": id.0, "point": [sp.x, sp.y]}))])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod handle_tests {
|
||||
use super::handle_shown;
|
||||
|
||||
/// InDesign's Direct Selection shows a selected anchor's two handles, and on its neighbours
|
||||
/// the handle shaping the segment to it; nothing with no anchor selected.
|
||||
#[test]
|
||||
fn handles_show_around_the_selected_anchors() {
|
||||
let shown = |closed: bool, chosen: &[usize]| {
|
||||
(0..4)
|
||||
.flat_map(|ai| [false, true].map(|out| (ai, out)))
|
||||
.filter(|(ai, out)| handle_shown(4, closed, *ai, *out, |a| chosen.contains(&a)))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(shown(true, &[]), vec![], "no anchor selected");
|
||||
assert_eq!(shown(true, &[2]), vec![(1, true), (2, false), (2, true), (3, false)]);
|
||||
// Closed, the first anchor's neighbours wrap round.
|
||||
assert_eq!(shown(true, &[0]), vec![(0, false), (0, true), (1, false), (3, true)]);
|
||||
// Open, the end has no neighbour beyond it.
|
||||
assert_eq!(shown(false, &[0]), vec![(0, false), (0, true), (1, false)]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ fn ctx_on<'a>(doc: &'a Document, sel: &'a Selection, cache: &'a Cache, layout: &
|
||||
snap: SnapView::FACTORY,
|
||||
unit: Unit::Points,
|
||||
stroke_in_front: false,
|
||||
loaded: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+250
-1
@@ -19,6 +19,7 @@ fn ctx<'a>(d: &'a Document, s: &'a Selection, c: &'a Cache, l: &'a CanvasLayout)
|
||||
snap: SnapView::OFF,
|
||||
unit: Unit::Points,
|
||||
stroke_in_front: false,
|
||||
loaded: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +71,56 @@ fn frame_tool_drag_emits_begin_preview_commit() {
|
||||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 110.0, 60.0)), vec![Action::Commit]);
|
||||
}
|
||||
|
||||
/// While a frame is drawn, its size shows in the document's units, as InDesign's readout does
|
||||
/// ("W: 6.08 cm" over "H: 6.08 cm" in Zero2Graphic's page layout tutorial, 0:43).
|
||||
#[test]
|
||||
fn frame_tool_readout_shows_the_size_in_the_document_units() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
d.settings.horizontal_units = Unit::Centimeters;
|
||||
d.settings.vertical_units = Unit::Centimeters;
|
||||
let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false));
|
||||
let cx = ctx(&d, &s, &c, &l);
|
||||
let mut t = create("rectangleFrame");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0));
|
||||
// 172.35 pt square: 6.08 cm.
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 182.35, 182.35));
|
||||
let texts: Vec<String> = t
|
||||
.overlays(&cx)
|
||||
.into_iter()
|
||||
.filter_map(|o| match o {
|
||||
Overlay::Measure { text, .. } => Some(text),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(texts, vec!["W: 6.08 cm\nH: 6.08 cm".to_string()]);
|
||||
}
|
||||
|
||||
/// The Line tool's readout measures the line as drawn: Shift makes it level, so it has no
|
||||
/// height, wherever the pointer is.
|
||||
#[test]
|
||||
fn line_tool_readout_measures_the_line_as_drawn() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
d.settings.horizontal_units = Unit::Centimeters;
|
||||
d.settings.vertical_units = Unit::Centimeters;
|
||||
let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false));
|
||||
let cx = ctx(&d, &s, &c, &l);
|
||||
let mut t = create("line");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0));
|
||||
let mut drag = PointerEvent::new(PointerKind::Drag, 182.35, 15.0);
|
||||
drag.mods.shift = true;
|
||||
t.pointer(&cx, &drag);
|
||||
let texts: Vec<String> = t
|
||||
.overlays(&cx)
|
||||
.into_iter()
|
||||
.filter_map(|o| match o {
|
||||
Overlay::Measure { text, .. } => Some(text),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(texts.len(), 1, "{texts:?}");
|
||||
assert_eq!(texts[0], "W: 6.08 cm\nH: 0 cm", "level, as drawn, not 5 pt down to the pointer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_click_and_marquee() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
@@ -1085,6 +1136,33 @@ fn double_click_on_a_port_breaks_its_link() {
|
||||
}
|
||||
}
|
||||
|
||||
/// With the Type tool, a drag that starts in an empty rectangle draws a new text frame over it
|
||||
/// (the rectangle stays as it is), as InDesign's does; a click still turns the rectangle into a
|
||||
/// text frame with the caret in it.
|
||||
#[test]
|
||||
fn type_tool_drag_over_an_empty_frame_draws_a_new_text_frame() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let id = add_rect(&mut d, Rect::new(100.0, 100.0, 300.0, 300.0));
|
||||
let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false));
|
||||
let cx = ctx(&d, &s, &c, &l);
|
||||
let off = l.xf(SpreadRef::Doc(0)).translation();
|
||||
let at = |x: f64, y: f64| (x + off.x, y + off.y);
|
||||
let mut t = create("type");
|
||||
let (x, y) = at(120.0, 120.0);
|
||||
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y)).is_empty());
|
||||
let (x1, y1) = at(280.0, 200.0);
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x1, y1));
|
||||
assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(cmd, p)] if cmd == "frame.create" && p["content"] == "text"), "{a:?}");
|
||||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x1, y1)), vec![Action::Commit]);
|
||||
// A click: the caret goes into the rectangle, which becomes a text frame.
|
||||
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y)).is_empty());
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x, y));
|
||||
assert!(matches!(&a[..], [Action::Exec(cmd, p)] if cmd == "text.placeCaret" && p["frame"] == id.0), "{a:?}");
|
||||
// A release away from the press with no drag in between isn't a click: no caret, no conversion.
|
||||
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y)).is_empty());
|
||||
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x1, y1)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicks_in_a_row_edit_and_select_text() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
@@ -1480,6 +1558,32 @@ fn table_frame(degrees: f64) -> (Document, ItemId) {
|
||||
(d, id)
|
||||
}
|
||||
|
||||
/// In a table cell Shift+Tab goes back a cell and Esc toggles cell selection, as InDesign; out of
|
||||
/// a table Tab types a tab and Esc leaves the text.
|
||||
#[test]
|
||||
fn type_tool_keys_in_a_table_cell() {
|
||||
let (d, id) = table_frame(0.0);
|
||||
let sid = d.item(id).unwrap().text_frame().unwrap().story;
|
||||
let table = *d.story(sid).unwrap().tables.keys().next().unwrap();
|
||||
let (c, l) = (Cache::new(), CanvasLayout::new(&d, false));
|
||||
let cell = designcraft_doc::CellAddr { table, row: 1, col: 1 };
|
||||
let s = Selection::text(designcraft_doc::TextSel { cell: Some(cell), ..designcraft_doc::TextSel::caret(sid, 0) });
|
||||
let cx = ctx(&d, &s, &c, &l);
|
||||
let mut t = create("type");
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
let exec = |c: &str, p: serde_json::Value| vec![Action::Exec(c.into(), p)];
|
||||
assert_eq!(t.key(&cx, ToolKey::Tab, shift), exec("table.prevCell", serde_json::json!({})));
|
||||
assert_eq!(t.key(&cx, ToolKey::Tab, Mods::default()), exec("text.insert", serde_json::json!({"text": "\t"})));
|
||||
assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), exec("table.toggleSelection", serde_json::json!({})));
|
||||
// Out of a table, and in footnote text (addressed as a cell too).
|
||||
for cell in [None, Some(designcraft_doc::CellAddr::footnote(1))] {
|
||||
let s = Selection::text(designcraft_doc::TextSel { cell, ..designcraft_doc::TextSel::caret(sid, 0) });
|
||||
let cx = ctx(&d, &s, &c, &l);
|
||||
assert_eq!(t.key(&cx, ToolKey::Tab, shift), exec("text.insert", serde_json::json!({"text": "\t"})), "{cell:?}");
|
||||
assert!(matches!(t.key(&cx, ToolKey::Escape, Mods::default()).first(), Some(Action::SwitchTool(_))), "{cell:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_tool_drags_a_table_column_border() {
|
||||
for degrees in [0.0, 90.0] {
|
||||
@@ -1821,7 +1925,13 @@ fn direct_selection_drags_the_end_anchors_of_type_on_a_path() {
|
||||
let p = Point::new(x + off.x, 300.0 + off.y);
|
||||
let mut direct = create("directSelection");
|
||||
assert_ne!(direct.cursor(&cx, p, Mods::default()), Cursor::PathBracket, "anchor at {x}");
|
||||
assert_eq!(direct.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y)), vec![Action::Begin("Move Anchor".into())]);
|
||||
// The press selects that anchor point and starts moving it.
|
||||
let a = direct.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y));
|
||||
assert!(
|
||||
matches!(a.first(), Some(Action::Exec(cmd, p)) if cmd == "selection.set" && p["anchors"].as_array().map(Vec::len) == Some(1)),
|
||||
"{a:?}"
|
||||
);
|
||||
assert_eq!(a.last(), Some(&Action::Begin("Move Anchor".into())));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1966,3 +2076,142 @@ fn loaded_text_cursor_threads_into_a_frame_inside_a_group() {
|
||||
assert_eq!(cx2.hit_text_frame(q).map(|(_, i)| i), None, "no text frame in that group");
|
||||
assert_eq!(t2.cursor(&cx2, q, Mods::default()), Cursor::NotAllowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_selection_click_on_one_of_several_selected_frames_selects_only_it() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let a = add_rect(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0));
|
||||
let b = add_rect(&mut d, Rect::new(300.0, 100.0, 400.0, 200.0));
|
||||
let (s, c, l) = (Selection::items(vec![a, b]), Cache::new(), CanvasLayout::new(&d, false));
|
||||
let cx = ctx(&d, &s, &c, &l);
|
||||
let p = spread_pt(&l, 150.0, 150.0);
|
||||
let mut t = create("directSelection");
|
||||
let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y));
|
||||
assert_eq!(acts, vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": [a.0], "content": true}))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pulling_a_corner_of_an_unselected_frame_selects_the_frame_not_its_content() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let a = add_rect(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0));
|
||||
let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false));
|
||||
let cx = ctx(&d, &s, &c, &l);
|
||||
let p = spread_pt(&l, 200.0, 100.0);
|
||||
let mut t = create("directSelection");
|
||||
let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y));
|
||||
assert_eq!(
|
||||
acts,
|
||||
vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": [a.0], "content": false})), Action::Begin("Move Anchor".into())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchor_drag_on_a_rotated_spread_sends_a_spread_space_delta() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let a = add_rect(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0));
|
||||
Arc::make_mut(&mut d.spreads[0]).pages[0].view_rotation = 1;
|
||||
let (s, c, l) = (Selection::items(vec![a]), Cache::new(), CanvasLayout::new(&d, false));
|
||||
let cx = ctx(&d, &s, &c, &l);
|
||||
let p = l.to_canvas(SpreadRef::Doc(0), Point::new(200.0, 100.0));
|
||||
let mut t = create("directSelection");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y));
|
||||
let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, p.x + 50.0, p.y));
|
||||
let [Action::Preview(cmd, params)] = acts.as_slice() else { panic!("{acts:?}") };
|
||||
assert_eq!(cmd, "path.moveAnchors");
|
||||
let want = l.delta_to_spread(SpreadRef::Doc(0), Vec2::new(50.0, 0.0));
|
||||
assert!(want.y.abs() > 1.0, "the view rotation turns a canvas x move into a spread y move: {want:?}");
|
||||
let (dx, dy) = (params["dx"].as_f64().unwrap(), params["dy"].as_f64().unwrap());
|
||||
assert!((dx - want.x).abs() < 1e-6 && (dy - want.y).abs() < 1e-6, "{params} want {want:?}");
|
||||
}
|
||||
|
||||
/// Direct Selection selects anchor points with a marquee across several frames, and dragging one of
|
||||
/// them moves them all, as InDesign does when the top points of a row of frames are dragged to slant
|
||||
/// them (Zero2Graphic's page layout tutorial, 1:15).
|
||||
#[test]
|
||||
fn direct_selection_marquee_selects_points_of_several_frames_and_drags_them_together() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let a = add_rect(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0));
|
||||
let b = add_rect(&mut d, Rect::new(200.0, 100.0, 300.0, 200.0));
|
||||
let (c, l) = (Cache::new(), CanvasLayout::new(&d, false));
|
||||
let none = Selection::default();
|
||||
let mut t = create("directSelection");
|
||||
// A marquee around the top edges: the four top points, not the frames' bottom ones.
|
||||
let (p0, p1) = (spread_pt(&l, 80.0, 90.0), spread_pt(&l, 320.0, 110.0));
|
||||
t.pointer(&ctx(&d, &none, &c, &l), &PointerEvent::new(PointerKind::Down, p0.x, p0.y));
|
||||
t.pointer(&ctx(&d, &none, &c, &l), &PointerEvent::new(PointerKind::Drag, p1.x, p1.y));
|
||||
let up = t.pointer(&ctx(&d, &none, &c, &l), &PointerEvent::new(PointerKind::Up, p1.x, p1.y));
|
||||
let Some(Action::Exec(cmd, p)) = up.last() else { panic!("{up:?}") };
|
||||
assert_eq!(cmd, "selection.set");
|
||||
assert_eq!(p["ids"], serde_json::json!([a.0, b.0]));
|
||||
let mut got: Vec<(u64, u64, u64)> =
|
||||
p["anchors"].as_array().unwrap().iter().map(|v| (v[0].as_u64().unwrap(), v[1].as_u64().unwrap(), v[2].as_u64().unwrap())).collect();
|
||||
got.sort();
|
||||
assert_eq!(got, vec![(a.0, 0, 0), (a.0, 0, 1), (b.0, 0, 0), (b.0, 0, 1)]);
|
||||
// With another frame selected before, a marquee without Shift selects only the paths whose
|
||||
// points it takes; with Shift the other frame stays selected.
|
||||
let other = add_rect(&mut d, Rect::new(400.0, 400.0, 450.0, 450.0));
|
||||
let l = CanvasLayout::new(&d, false);
|
||||
let was = Selection { items: vec![other], ..Default::default() };
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
for (mods, want) in [(Mods::default(), serde_json::json!([a.0, b.0])), (shift, serde_json::json!([other.0, a.0, b.0]))] {
|
||||
t.pointer(&ctx(&d, &was, &c, &l), &PointerEvent::new(PointerKind::Down, p0.x, p0.y).with_mods(mods));
|
||||
t.pointer(&ctx(&d, &was, &c, &l), &PointerEvent::new(PointerKind::Drag, p1.x, p1.y).with_mods(mods));
|
||||
let up = t.pointer(&ctx(&d, &was, &c, &l), &PointerEvent::new(PointerKind::Up, p1.x, p1.y).with_mods(mods));
|
||||
let Some(Action::Exec(_, p)) = up.last() else { panic!("{up:?}") };
|
||||
assert_eq!(p["ids"], want, "{mods:?}");
|
||||
}
|
||||
// Those points selected: dragging one moves all four, in one preview.
|
||||
let sel = Selection { items: vec![a, b], anchors: vec![(a, 0, 0), (a, 0, 1), (b, 0, 0), (b, 0, 1)], ..Default::default() };
|
||||
let cx = ctx(&d, &sel, &c, &l);
|
||||
let at = spread_pt(&l, 200.0, 100.0);
|
||||
let down = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y));
|
||||
assert_eq!(down, vec![Action::Begin("Move Anchors".into())]);
|
||||
let drag = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, at.x + 36.0, at.y + 3.0).with_mods(shift));
|
||||
let Some(Action::Preview(cmd, p)) = drag.last() else { panic!("{drag:?}") };
|
||||
assert_eq!(cmd, "path.moveAnchors");
|
||||
assert_eq!(p["points"].as_array().map(Vec::len), Some(4), "{p}");
|
||||
// Shift keeps the move horizontal.
|
||||
// Each point carries its own movement (in its spread): here all the same.
|
||||
let moves = |p: &serde_json::Value| -> Vec<(f64, f64)> {
|
||||
p["points"].as_array().unwrap().iter().map(|v| (v[3].as_f64().unwrap(), v[4].as_f64().unwrap())).collect()
|
||||
};
|
||||
assert!(moves(p).iter().all(|&(x, y)| (x - 36.0).abs() < 1e-9 && y.abs() < 1e-9), "{p}");
|
||||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x + 36.0, at.y)), vec![Action::Commit]);
|
||||
// A Shift-drag that starts on a selected point keeps it: all four move, constrained.
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y).with_mods(shift));
|
||||
let drag = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, at.x + 3.0, at.y + 30.0).with_mods(shift));
|
||||
let Some(Action::Preview(_, p)) = drag.last() else { panic!("{drag:?}") };
|
||||
assert_eq!(p["points"].as_array().map(Vec::len), Some(4), "{p}");
|
||||
assert!(moves(p).iter().all(|&(x, y)| x.abs() < 1e-9 && (y - 30.0).abs() < 1e-9), "vertical: {p}");
|
||||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x, at.y + 30.0).with_mods(shift)), vec![Action::Commit]);
|
||||
// Shift+click on a selected point takes it out of the selection, on release; the arrow keys
|
||||
// move the points.
|
||||
let out = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y).with_mods(shift));
|
||||
assert!(!out.iter().any(|a| matches!(a, Action::Exec(..))), "kept while it may become a drag: {out:?}");
|
||||
// A jiggle inside the drag tolerance moves nothing: it is still a click.
|
||||
let jiggle = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, at.x + 1.0, at.y + 1.0).with_mods(shift));
|
||||
assert!(jiggle.is_empty(), "{jiggle:?}");
|
||||
let out = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x + 1.0, at.y + 1.0).with_mods(shift));
|
||||
let Some(Action::Exec(cmd, p)) = out.last() else { panic!("{out:?}") };
|
||||
assert_eq!(cmd, "selection.set");
|
||||
assert_eq!(p["anchors"].as_array().map(Vec::len), Some(3), "{p}");
|
||||
let keys = t.key(&cx, ToolKey::Right, Mods::default());
|
||||
assert!(
|
||||
matches!(keys.as_slice(), [Action::Exec(cmd, p)] if cmd == "path.moveAnchors" && p["points"].as_array().map(Vec::len) == Some(4)),
|
||||
"{keys:?}"
|
||||
);
|
||||
// Delete deletes the selected points, not their paths.
|
||||
assert_eq!(t.key(&cx, ToolKey::Delete, Mods::default()), vec![Action::Exec("path.deletePoints".into(), serde_json::json!({}))]);
|
||||
// On a spread turned a quarter on screen, → moves the points right on screen: up or down in
|
||||
// the spread, not across it.
|
||||
let mut turned = d.clone();
|
||||
std::sync::Arc::make_mut(&mut turned.spreads[0]).pages[0].view_rotation = 1;
|
||||
let lt = CanvasLayout::new(&turned, false);
|
||||
let cxt = ctx(&turned, &sel, &c, <);
|
||||
let keys = t.key(&cxt, ToolKey::Right, Mods::default());
|
||||
let [Action::Exec(_, p)] = keys.as_slice() else { panic!("{keys:?}") };
|
||||
let inc = turned.settings.keyboard_increment;
|
||||
let want = lt.delta_to_spread(SpreadRef::Doc(0), designcraft_geom::Vec2::new(inc, 0.0));
|
||||
assert!(want.x.abs() < 1e-9 && (want.y.abs() - inc).abs() < 1e-9, "a quarter turn: {want:?}");
|
||||
assert!(moves(p).iter().all(|&(x, y)| (x - want.x).abs() < 1e-9 && (y - want.y).abs() < 1e-9), "{p}");
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ pub struct TypeTool {
|
||||
start: Option<Point>,
|
||||
selecting: Option<u64>,
|
||||
drawing: bool,
|
||||
/// A press on an empty frame: (frame, press point in spread space). A click turns it into a
|
||||
/// text frame with the caret in it; a drag draws a new text frame over it, as in InDesign.
|
||||
convert: Option<(u64, Point)>,
|
||||
/// A press on a table whose whole rows or columns are selected: (frame, press point in
|
||||
/// spread space) — a drag moves them, a click places the caret.
|
||||
cell_drag: Option<(u64, Point)>,
|
||||
@@ -228,6 +231,7 @@ impl TypeTool {
|
||||
self.cell_drag = None;
|
||||
self.unit = None;
|
||||
self.border = None;
|
||||
self.convert = None;
|
||||
if ev.kind == PointerKind::Down
|
||||
&& let Some(b) = border_at(cx, ev.pos)
|
||||
{
|
||||
@@ -248,6 +252,11 @@ impl TypeTool {
|
||||
self.cell_drag = Some((id.0, sp));
|
||||
return vec![];
|
||||
}
|
||||
// An empty frame: wait to see whether this is a click or a drag.
|
||||
if clicks == 1 && !ev.mods.shift && !it.is_text_frame() {
|
||||
self.convert = Some((id.0, sp));
|
||||
return vec![];
|
||||
}
|
||||
self.selecting = Some(id.0);
|
||||
// Two presses select a word (or a run of spaces), three a line, four a
|
||||
// paragraph, five the story.
|
||||
@@ -299,6 +308,7 @@ impl TypeTool {
|
||||
let mut out = vec![];
|
||||
if !self.drawing {
|
||||
self.drawing = true;
|
||||
self.convert = None;
|
||||
out.push(Action::Begin("Create Text Frame".into()));
|
||||
}
|
||||
out.push(Action::Preview(
|
||||
@@ -327,6 +337,14 @@ impl TypeTool {
|
||||
self.selecting = None;
|
||||
return vec![Action::Commit];
|
||||
}
|
||||
// A click in an empty frame: it becomes a text frame with the caret in it. A release
|
||||
// away from the press (with no drag in between) isn't a click: nothing changes.
|
||||
if let Some((fid, at)) = self.convert.take() {
|
||||
if start.is_some_and(|a| (ev.pos - a).hypot() >= cx.tol(3.0)) {
|
||||
return vec![];
|
||||
}
|
||||
return vec![Action::Exec("text.placeCaret".into(), json!({"frame": fid, "point": [at.x, at.y]}))];
|
||||
}
|
||||
// Ends a press in text: drops dragged text, or is ignored by the engine.
|
||||
if let Some(fid) = self.selecting.take() {
|
||||
let sp = cx.layout.spread_at(ev.pos).map(|(_, p)| p).unwrap_or(ev.pos);
|
||||
@@ -372,6 +390,8 @@ impl Tool for TypeTool {
|
||||
let steps = if mods.cmd || mods.ctrl { 5.0 } else { 1.0 };
|
||||
vec![Action::Exec("type.kernStep".into(), json!({"steps": sign * steps}))]
|
||||
};
|
||||
// A table cell's text, not a footnote's (which is addressed as a cell too).
|
||||
let in_cell = cx.selection.text.is_some_and(|t| t.cell.is_some_and(|c| c.footnote_id().is_none()));
|
||||
match key {
|
||||
ToolKey::Left if mods.alt && !mods.shift => kern(-1.0),
|
||||
ToolKey::Right if mods.alt && !mods.shift => kern(1.0),
|
||||
@@ -384,7 +404,11 @@ impl Tool for TypeTool {
|
||||
ToolKey::Backspace => vec![Action::Exec("text.delete".into(), json!({"forward": false, "word": mods.alt}))],
|
||||
ToolKey::Delete => vec![Action::Exec("text.delete".into(), json!({"forward": true, "word": mods.alt}))],
|
||||
ToolKey::Enter => vec![Action::Exec("text.insert".into(), json!({"text": if mods.shift { "\u{2028}" } else { "\n" }}))],
|
||||
// In a table cell Shift+Tab goes back a cell (Tab goes on through `text.insert`).
|
||||
ToolKey::Tab if mods.shift && in_cell => vec![Action::Exec("table.prevCell".into(), json!({}))],
|
||||
ToolKey::Tab => vec![Action::Exec("text.insert".into(), json!({"text": "\t"}))],
|
||||
// In a table Esc toggles between the cell's text and the cell, as InDesign.
|
||||
ToolKey::Escape if in_cell => vec![Action::Exec("table.toggleSelection".into(), json!({}))],
|
||||
ToolKey::Escape => vec![Action::SwitchTool("selection".into()), Action::Exec("text.exitToFrame".into(), json!({}))],
|
||||
_ => vec![],
|
||||
}
|
||||
|
||||
+102
-42
@@ -44,8 +44,8 @@ use input::{press_count, release_clicks};
|
||||
#[cfg(test)]
|
||||
use page_decor::{MAX_BASELINES, baseline_grid_lines};
|
||||
use page_decor::{dashed, draw_flattener_preview, draw_guides, shown_area};
|
||||
pub use render::DisplayKey;
|
||||
use render::render_texture;
|
||||
pub use render::{DisplayKey, color_at};
|
||||
#[cfg(test)]
|
||||
use render::{c32, invalidate_on_display_change, screen_options};
|
||||
pub(crate) use rulers::fmt_tick;
|
||||
@@ -56,13 +56,13 @@ use scroll::{mods, navigation_space, scrollbars, spring_zoom_mode};
|
||||
pub use text_overlay::selected_text;
|
||||
use text_overlay::{draw_dynamic_spelling, draw_hidden_characters};
|
||||
#[cfg(test)]
|
||||
use text_overlay::{draw_text_selection, hidden_marks};
|
||||
use text_overlay::{draw_text_selection, frame_hidden_marks, hidden_marks};
|
||||
use tool_overlays::draw_tool_overlays;
|
||||
pub(crate) use view::page_in_view;
|
||||
use view::present_fit;
|
||||
pub use view::{
|
||||
apply_view_request, can_go_through_history, current_page, current_parent_page, current_slot, current_spread, current_spread_page, drop_point,
|
||||
fit, go_through_history, go_to_page, set_zoom, show_spread, step_spread, view_centre, zoom_at,
|
||||
PageBox, apply_view_request, can_go_through_history, current_page, current_parent_page, current_slot, current_spread, current_spread_page,
|
||||
drop_point, fit, go_through_history, go_to_page, page_box, page_box_go, set_zoom, show_spread, step_spread, view_centre, zoom_at,
|
||||
};
|
||||
|
||||
pub const RULER: f32 = 15.0;
|
||||
@@ -327,47 +327,13 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
// Cursor.
|
||||
if let Some(p) = resp.hover_pos().filter(|p| screen.contains(*p) && !elsewhere) {
|
||||
let wants_text = app.session.wants_text();
|
||||
let space = navigation_space(app, ui.ctx());
|
||||
let zoom = spring_zoom_mode(app, ui.ctx(), space);
|
||||
let middle = ui.input(|i| i.pointer.middle_down());
|
||||
let alt_hand: bool = ui.data(|d| d.get_temp(egui::Id::unique(("canvas_alt_hand", app.pane)))).unwrap_or(false);
|
||||
let tool_drag: bool = ui.data(|d| d.get_temp(egui::Id::unique(("canvas_pointer_down", app.pane)))).unwrap_or(false);
|
||||
let alt_text = wants_text && !tool_drag && ui.input(|i| i.modifiers.alt);
|
||||
let c = if middle || alt_hand {
|
||||
Cursor::HandGrab
|
||||
} else if zoom {
|
||||
if ui.input(|i| i.modifiers.alt) { Cursor::ZoomOut } else { Cursor::ZoomIn }
|
||||
} else if (space && !ui.input(|i| i.modifiers.command)) || alt_text {
|
||||
Cursor::Hand
|
||||
} else {
|
||||
app.session.cursor(xf.to_canvas(p), ui.input(|i| mods(i, false)), app.view_info())
|
||||
};
|
||||
// A column guide that a press would drag, or one being dragged: a resize cursor across it.
|
||||
let dragging = ui.data(|d| d.get_temp::<ColumnDrag>(egui::Id::unique((COLUMN_DRAG, app.pane)))).map(|g| g.spread);
|
||||
let column = dragging.or_else(|| {
|
||||
let menu_open = context_menu_open(ui.ctx());
|
||||
(!space && !zoom && !middle && !alt_hand && !tool_drag && !menu_open && xf.rot == 0)
|
||||
.then(|| column_guide_at(app, &xf, p).map(|h| h.0))
|
||||
.flatten()
|
||||
});
|
||||
let c = match column {
|
||||
Some(r) if spread_x_across(app, r) => Cursor::ResizeH,
|
||||
Some(_) => Cursor::ResizeV,
|
||||
None => c,
|
||||
};
|
||||
// A ruler guide being dragged keeps the cursor across it until it is dropped, over the
|
||||
// canvas too (the tool's cursor took over as soon as the pointer left the ruler). Only
|
||||
// while the button is down: a drag state left behind by a skipped release doesn't stick.
|
||||
let dragging = ui.input(|i| i.pointer.primary_down());
|
||||
let c = match ui.data(|d| d.get_temp::<GuideDrag>(egui::Id::unique(GUIDE_DRAG))).filter(|_| dragging).map(|g| g.orientation) {
|
||||
Some(designcraft_doc::Orientation::Horizontal) => Cursor::ResizeV,
|
||||
Some(designcraft_doc::Orientation::Vertical) => Cursor::ResizeH,
|
||||
None => c,
|
||||
};
|
||||
let c = pointer_cursor(app, ui, &xf, p);
|
||||
let icon = crate::cursors::show(ui.ctx(), &mut app.cursors, c, p);
|
||||
ui.ctx().set_cursor_icon(icon);
|
||||
draw_cursor_badge(&painter, p, c, &t);
|
||||
if c == Cursor::LoadedGraphic {
|
||||
draw_loaded_thumbnail(app, &painter, p, &t);
|
||||
}
|
||||
}
|
||||
// A second viewport has no panels, dialogs or Quick Apply after its canvas: observe here,
|
||||
// once, as the end of its pass. The root viewport observes at the end of `DesignApp::ui`.
|
||||
@@ -376,6 +342,100 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The cursor under the pointer at `p` over the canvas: the navigation hands, then the guide
|
||||
/// resize cursors, then the active tool's.
|
||||
fn pointer_cursor(app: &mut DesignApp, ui: &egui::Ui, xf: &Xf, p: Pos2) -> Cursor {
|
||||
let wants_text = app.session.wants_text();
|
||||
let space = navigation_space(app, ui.ctx());
|
||||
let zoom = spring_zoom_mode(app, ui.ctx(), space);
|
||||
let middle = ui.input(|i| i.pointer.middle_down());
|
||||
let alt_hand: bool = ui.data(|d| d.get_temp(egui::Id::unique(("canvas_alt_hand", app.pane)))).unwrap_or(false);
|
||||
let tool_drag: bool = ui.data(|d| d.get_temp(egui::Id::unique(("canvas_pointer_down", app.pane)))).unwrap_or(false);
|
||||
let alt_text = wants_text && !tool_drag && ui.input(|i| i.modifiers.alt);
|
||||
let c = if middle || alt_hand {
|
||||
Cursor::HandGrab
|
||||
} else if zoom {
|
||||
if ui.input(|i| i.modifiers.alt) { Cursor::ZoomOut } else { Cursor::ZoomIn }
|
||||
} else if (space && !ui.input(|i| i.modifiers.command)) || alt_text {
|
||||
Cursor::Hand
|
||||
} else {
|
||||
app.session.cursor(xf.to_canvas(p), ui.input(|i| mods(i, false)), app.view_info())
|
||||
};
|
||||
// A column guide that a press would drag, or one being dragged: a resize cursor across it.
|
||||
let dragging = ui.data(|d| d.get_temp::<ColumnDrag>(egui::Id::unique((COLUMN_DRAG, app.pane)))).map(|g| g.spread);
|
||||
let column = dragging.or_else(|| {
|
||||
let menu_open = context_menu_open(ui.ctx());
|
||||
(!space && !zoom && !middle && !alt_hand && !tool_drag && !menu_open && xf.rot == 0)
|
||||
.then(|| column_guide_at(app, xf, p).map(|h| h.0))
|
||||
.flatten()
|
||||
});
|
||||
let c = match column {
|
||||
Some(r) if spread_x_across(app, r) => Cursor::ResizeH,
|
||||
Some(_) => Cursor::ResizeV,
|
||||
None => c,
|
||||
};
|
||||
// A ruler guide being dragged keeps the cursor across it until it is dropped, over the
|
||||
// canvas too (the tool's cursor took over as soon as the pointer left the ruler). Only
|
||||
// while the button is down: a drag state left behind by a skipped release doesn't stick.
|
||||
let dragging = ui.input(|i| i.pointer.primary_down());
|
||||
match ui.data(|d| d.get_temp::<GuideDrag>(egui::Id::unique(GUIDE_DRAG))).filter(|_| dragging).map(|g| g.orientation) {
|
||||
Some(designcraft_doc::Orientation::Horizontal) => Cursor::ResizeV,
|
||||
Some(designcraft_doc::Orientation::Vertical) => Cursor::ResizeH,
|
||||
None => c,
|
||||
}
|
||||
}
|
||||
|
||||
/// Longest side of the loaded place cursor's thumbnail, in points.
|
||||
const LOADED_THUMB: f32 = 128.0;
|
||||
|
||||
/// The loaded place cursor's thumbnail, as InDesign shows it: the graphic, at most 128 pt on its
|
||||
/// longer side, hanging from the pointer, with an image badge in its corner.
|
||||
fn draw_loaded_thumbnail(app: &DesignApp, painter: &egui::Painter, p: Pos2, t: &Tokens) {
|
||||
let (Some(l), Ok(st)) = (app.session.loaded.as_ref(), app.session.doc()) else { return };
|
||||
let (w, h) = (l.size.0 as f32, l.size.1 as f32);
|
||||
if !(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0) {
|
||||
return;
|
||||
}
|
||||
let k = LOADED_THUMB / w.max(h);
|
||||
let r = Rect::from_min_size(p + vec2(2.0, 2.0), vec2(w * k, h * k));
|
||||
if let Some(tex) = loaded_thumb_texture(painter.ctx(), &st.doc, l.asset, l.size) {
|
||||
painter.image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::from_white_alpha(225));
|
||||
} else {
|
||||
// Not drawable here (an unreadable file): its frame only.
|
||||
painter.rect_stroke(r, 0.0, Stroke::new(1.0, t.measure_text), StrokeKind::Inside);
|
||||
}
|
||||
let badge = Rect::from_min_size(r.min + vec2(6.0, 6.0), vec2(16.0, 12.0));
|
||||
painter.rect_filled(badge, 2.0, t.measure_bg);
|
||||
let ink = Stroke::new(1.2, t.measure_text);
|
||||
let inner = badge.shrink2(vec2(3.0, 2.5));
|
||||
painter.rect_stroke(inner, 0.5, ink, StrokeKind::Middle);
|
||||
painter.add(egui::Shape::line(
|
||||
vec![pos2(inner.min.x, inner.max.y), pos2(inner.min.x + 3.5, inner.min.y + 2.5), pos2(inner.max.x, inner.max.y)],
|
||||
ink,
|
||||
));
|
||||
}
|
||||
|
||||
/// The loaded graphic's thumbnail texture, cached per image data.
|
||||
fn loaded_thumb_texture(ctx: &egui::Context, doc: &Document, asset: designcraft_doc::AssetId, size: (f64, f64)) -> Option<egui::TextureHandle> {
|
||||
let a = doc.assets.get(&asset)?;
|
||||
let key = egui::Id::unique(("loaded_thumb", std::sync::Arc::as_ptr(&a.data) as usize, a.data.len(), a.page));
|
||||
if let Some(t) = ctx.data(|d| d.get_temp::<egui::TextureHandle>(key)) {
|
||||
return Some(t);
|
||||
}
|
||||
// Twice the thumbnail's size in pixels, for high-density screens.
|
||||
let scale = (2.0 * f64::from(LOADED_THUMB) / size.0.max(size.1)).min(1.0);
|
||||
let pm = designcraft_render::images::mip(&a.data, a.page, size.0, scale)?;
|
||||
let (pw, ph) = (pm.width() as usize, pm.height() as usize);
|
||||
if pw == 0 || ph == 0 {
|
||||
return None;
|
||||
}
|
||||
let ci = egui::ColorImage::from_rgba_premultiplied([pw, ph], pm.data_as_u8_slice());
|
||||
let max = ctx.input(|i| i.max_texture_side).min(1024);
|
||||
let t = ctx.load_texture("loaded_thumb", crate::widgets::fit_texture(ci, max), egui::TextureOptions::LINEAR);
|
||||
ctx.data_mut(|d| d.insert_temp(key, t.clone()));
|
||||
Some(t)
|
||||
}
|
||||
|
||||
/// Presses in a row on the canvas: the last press's count, and where and when it was released
|
||||
/// (`at` is −∞ while the button is down).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
|
||||
@@ -106,6 +106,7 @@ fn snap_gutter(app: &DesignApp, g: &ColumnDrag, want: f64, shift: bool, zoom: f6
|
||||
snap: designcraft_tools::SnapView { smart_dimensions: false, smart_spacing: false, ..app.ui.snap_view() },
|
||||
unit: app.ui.units,
|
||||
stroke_in_front: false,
|
||||
loaded: None,
|
||||
};
|
||||
let rect = DRect::new(want, m.y0, want + width, m.y1);
|
||||
let s = designcraft_tools::snap::snap(
|
||||
|
||||
@@ -167,14 +167,16 @@ pub(super) fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf,
|
||||
}
|
||||
let mut union: Option<Rect> = None;
|
||||
let mut color = Color32::from_rgb(79, 153, 255);
|
||||
let direct = matches!(app.session.tool_id(), "directSelection" | "pen" | "addAnchor" | "deleteAnchor" | "convertDirection");
|
||||
for id in &sel.items {
|
||||
let (Some(it), Some((a, _))) = (doc.item(*id), item_canvas_xf(doc, layout, *id)) else { continue };
|
||||
color = layer_color(doc, it);
|
||||
for poly in path_screen(it, xf, a) {
|
||||
painter.add(egui::Shape::line(poly, Stroke::new(1.0, color)));
|
||||
}
|
||||
if sel.items.len() == 1 && sel.text.is_none() {
|
||||
// InDesign draws a 1 pt bounding box around the selection.
|
||||
// InDesign draws a 1 pt bounding box around the selection; with the Direct Selection and
|
||||
// pen tools, only the path and its points.
|
||||
if sel.items.len() == 1 && sel.text.is_none() && !direct {
|
||||
painter.rect_stroke(xf.rect(a.transform_rect_bbox(it.bounds())), 0.0, Stroke::new(1.0, color), StrokeKind::Middle);
|
||||
} else if sel.key == Some(*id) {
|
||||
// The key object for Align: a thicker bounding box, as in InDesign.
|
||||
@@ -205,16 +207,26 @@ pub(super) fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf,
|
||||
}
|
||||
}
|
||||
}
|
||||
let direct = matches!(app.session.tool_id(), "directSelection" | "pen" | "addAnchor" | "deleteAnchor" | "convertDirection");
|
||||
// Direct Selection shows only the handles around the selected anchors, as InDesign; the pen
|
||||
// and the anchor tools show every handle they work on.
|
||||
let some_handles = app.session.tool_id() == "directSelection";
|
||||
if direct {
|
||||
// Looked up once per anchor drawn: a large marquee selection stays linear.
|
||||
let chosen_set: std::collections::HashSet<(designcraft_doc::ItemId, usize, usize)> = sel.anchors.iter().copied().collect();
|
||||
for id in &sel.items {
|
||||
let (Some(it), Some((a, _))) = (doc.item(*id), item_canvas_xf(doc, layout, *id)) else { continue };
|
||||
let m = a * it.xf;
|
||||
let col = layer_color(doc, it);
|
||||
for sp in &it.path.subpaths {
|
||||
for an in &sp.anchors {
|
||||
for (si, sp) in it.path.subpaths.iter().enumerate() {
|
||||
for (ai, an) in sp.anchors.iter().enumerate() {
|
||||
let p = xf.to_screen(m * an.p);
|
||||
for (h, has) in [(an.h_in, an.has_in()), (an.h_out, an.has_out())] {
|
||||
// A selected anchor point is solid, the others hollow (InDesign).
|
||||
let chosen = chosen_set.contains(&(*id, si, ai));
|
||||
let shown = |out: bool| {
|
||||
!some_handles
|
||||
|| designcraft_tools::select::handle_shown(sp.anchors.len(), sp.closed, ai, out, |a| chosen_set.contains(&(*id, si, a)))
|
||||
};
|
||||
for (h, has) in [(an.h_in, an.has_in() && shown(false)), (an.h_out, an.has_out() && shown(true))] {
|
||||
if has {
|
||||
let hp = xf.to_screen(m * h);
|
||||
painter.line_segment([p, hp], Stroke::new(1.0, col));
|
||||
@@ -222,7 +234,7 @@ pub(super) fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf,
|
||||
}
|
||||
}
|
||||
let r = Rect::from_center_size(p, vec2(5.0, 5.0));
|
||||
painter.rect_filled(r, 0.0, Color32::WHITE);
|
||||
painter.rect_filled(r, 0.0, if chosen { col } else { Color32::WHITE });
|
||||
painter.rect_stroke(r, 0.0, Stroke::new(1.0, col), StrokeKind::Inside);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +143,7 @@ pub(super) fn guide_spot(app: &DesignApp, xf: &Xf, g: &GuideDrag) -> Option<Guid
|
||||
snap: app.ui.snap_view(),
|
||||
unit: app.ui.units,
|
||||
stroke_in_front: false,
|
||||
loaded: None,
|
||||
};
|
||||
let s = designcraft_tools::snap::snap(
|
||||
&cx,
|
||||
@@ -407,6 +408,7 @@ pub(super) fn over_item(app: &DesignApp, xf: &Xf, p: Pos2) -> bool {
|
||||
snap: app.ui.snap_view(),
|
||||
unit: app.ui.units,
|
||||
stroke_in_front: false,
|
||||
loaded: None,
|
||||
};
|
||||
cx.hit(xf.to_canvas(p)).is_some()
|
||||
}
|
||||
|
||||
@@ -337,9 +337,12 @@ pub(super) fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::
|
||||
events.push(PointerEvent { kind: PointerKind::Down, pos: pos(o), mods: m, clicks: n });
|
||||
down = true;
|
||||
}
|
||||
// The place cursor draws a frame from 3 points (its own threshold), before egui starts a
|
||||
// drag (at `max_click_dist`): follow the pointer from there, so a release between the two
|
||||
// places the frame that was previewed.
|
||||
let place_drag = app.session.tool_id() == "placeGun" && origin.zip(latest).is_some_and(|(o, p)| (o - p).length() >= 3.0);
|
||||
if down
|
||||
&& resp.dragged()
|
||||
&& resp.drag_delta() != egui::Vec2::ZERO
|
||||
&& ((resp.dragged() && resp.drag_delta() != egui::Vec2::ZERO) || place_drag)
|
||||
&& let Some(p) = latest
|
||||
{
|
||||
events.push(PointerEvent { kind: PointerKind::Drag, pos: pos(p), mods: m, clicks: 1 });
|
||||
|
||||
@@ -1,9 +1,40 @@
|
||||
use super::{Affine, CanvasLayout, Color32, DRect, DesignApp, Point, Rect, Xf};
|
||||
use super::{Affine, CanvasLayout, Color32, DRect, DesignApp, Point, Pos2, Rect, ScreenMode, Xf};
|
||||
|
||||
pub(super) fn c32(c: [u8; 3]) -> Color32 {
|
||||
Color32::from_rgb(c[0], c[1], c[2])
|
||||
}
|
||||
|
||||
/// The colour the canvas shows at screen point `pos` (the Color Picker's eyedropper): one device
|
||||
/// pixel rendered as the page is, over its paper. None off the pages.
|
||||
pub fn color_at(app: &mut DesignApp, ctx: &egui::Context, pos: Pos2) -> Option<[u8; 3]> {
|
||||
let rect = app.canvas_rect.filter(|r| r.contains(pos))?;
|
||||
let v = *app.view()?;
|
||||
let xf = Xf::new(rect, &v);
|
||||
let at = xf.to_canvas(pos);
|
||||
let st = app.session.active()?;
|
||||
let doc = st.doc.clone();
|
||||
let layout = CanvasLayout::new(&doc, st.editing_parents);
|
||||
let on_page = layout
|
||||
.slots
|
||||
.iter()
|
||||
.any(|slot| doc.spread(slot.spread).is_some_and(|sp| sp.pages.iter().any(|page| slot.xf.transform_rect_bbox(page.bounds()).contains(at))));
|
||||
if !on_page {
|
||||
return None;
|
||||
}
|
||||
let placed: Vec<designcraft_render::Placed> = layout.slots.iter().map(|s| designcraft_render::Placed { spread: s.spread, xf: s.xf }).collect();
|
||||
let ppp = ctx.pixels_per_point() as f64;
|
||||
let view = Affine::scale(ppp) * Affine::scale(xf.zoom) * Affine::translate((-at.x, -at.y));
|
||||
let opts = screen_options(app, &layout, app.ui.screen_mode != ScreenMode::Normal, ppp);
|
||||
let mut img = app.canvas.patcher.render(&doc, &app.session.cache, &placed, (1, 1), view, &opts);
|
||||
// The colour as the canvas shows it (proofed).
|
||||
screen_post(app, &mut img);
|
||||
let [r, g, b, a] = <[u8; 4]>::try_from(img.pixels.get(..4)?).ok()?;
|
||||
// Premultiplied over the paper.
|
||||
let paper = doc.resolve_color(designcraft_color::swatch::PAPER, 1.0).unwrap_or(designcraft_color::Color::WHITE).to_rgba8(1.0);
|
||||
let over = |c: u8, p: u8| (u32::from(c) + u32::from(p) * (255 - u32::from(a)) / 255).min(255) as u8;
|
||||
Some([over(r, paper[0]), over(g, paper[1]), over(b, paper[2])])
|
||||
}
|
||||
|
||||
/// Render options for the canvas texture.
|
||||
pub(super) fn screen_options(app: &DesignApp, layout: &CanvasLayout, preview: bool, ppp: f64) -> designcraft_render::RenderOptions {
|
||||
designcraft_render::RenderOptions {
|
||||
@@ -268,10 +299,16 @@ fn render_worker_spawn(ctx: &egui::Context) -> Option<crate::render_worker::Work
|
||||
crate::render_worker::Worker::spawn(ctx.clone())
|
||||
}
|
||||
|
||||
fn upload(app: &mut DesignApp, ctx: &egui::Context, mut img: designcraft_render::Rendered) {
|
||||
/// What the canvas does to a render before showing it: Proof Colors. The texture and the
|
||||
/// eyedropper's sample both go through it.
|
||||
fn screen_post(app: &DesignApp, img: &mut designcraft_render::Rendered) {
|
||||
if app.ui.proof_colors {
|
||||
designcraft_render::proof_view(&mut img, &app.ui.proof_setup);
|
||||
designcraft_render::proof_view(img, &app.ui.proof_setup);
|
||||
}
|
||||
}
|
||||
|
||||
fn upload(app: &mut DesignApp, ctx: &egui::Context, mut img: designcraft_render::Rendered) {
|
||||
screen_post(app, &mut img);
|
||||
let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
|
||||
let ci = crate::widgets::fit_texture(ci, ctx.input(|i| i.max_texture_side));
|
||||
match &mut app.canvas.texture {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user