Merge remote-tracking branch 'origin/main' into effects-angle-dial

# Conflicts:
#	crates/ui-egui/src/effects_dialog.rs
This commit is contained in:
moppeeer committed 2026-10-25 17:08:55 +01:00
commit acfbd48dab
112 files changed
+7815 -426

No files matched your search

Generated
+21 -21
View File
@@ -942,7 +942,7 @@ dependencies = [
[[package]]
name = "designcraft"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"arboard",
"designcraft-engine",
@@ -964,7 +964,7 @@ dependencies = [
[[package]]
name = "designcraft-cli"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -980,7 +980,7 @@ dependencies = [
[[package]]
name = "designcraft-color"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"craft-cms",
"kurbo",
@@ -992,7 +992,7 @@ dependencies = [
[[package]]
name = "designcraft-compose"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"designcraft-color",
"designcraft-doc",
@@ -1012,7 +1012,7 @@ dependencies = [
[[package]]
name = "designcraft-doc"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"chrono",
"designcraft-color",
@@ -1025,7 +1025,7 @@ dependencies = [
[[package]]
name = "designcraft-engine"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"caseless",
"craft-atomic",
@@ -1064,7 +1064,7 @@ dependencies = [
[[package]]
name = "designcraft-epub"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"chrono",
"designcraft-color",
@@ -1074,7 +1074,7 @@ dependencies = [
[[package]]
name = "designcraft-fonts"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"core-text",
"harfrust 0.13.3",
@@ -1087,7 +1087,7 @@ dependencies = [
[[package]]
name = "designcraft-format"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"designcraft-doc",
"designcraft-images",
@@ -1099,7 +1099,7 @@ dependencies = [
[[package]]
name = "designcraft-geom"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"flo_curves",
"kurbo",
@@ -1112,7 +1112,7 @@ dependencies = [
[[package]]
name = "designcraft-idml"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1127,7 +1127,7 @@ dependencies = [
[[package]]
name = "designcraft-images"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"craft-cms",
"designcraft-color",
@@ -1142,7 +1142,7 @@ dependencies = [
[[package]]
name = "designcraft-mcp"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"designcraft-engine",
"designcraft-geom",
@@ -1155,7 +1155,7 @@ dependencies = [
[[package]]
name = "designcraft-pdf"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"chrono",
"craft-pdf",
@@ -1178,7 +1178,7 @@ dependencies = [
[[package]]
name = "designcraft-pptx"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"chrono",
"designcraft-color",
@@ -1196,7 +1196,7 @@ dependencies = [
[[package]]
name = "designcraft-render"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"craft-pdf",
"designcraft-color",
@@ -1215,7 +1215,7 @@ dependencies = [
[[package]]
name = "designcraft-textimport"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"designcraft-doc",
"quick-xml",
@@ -1224,7 +1224,7 @@ dependencies = [
[[package]]
name = "designcraft-tools"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1236,7 +1236,7 @@ dependencies = [
[[package]]
name = "designcraft-ui-egui"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"craft-ui",
"designcraft-color",
@@ -1263,7 +1263,7 @@ dependencies = [
[[package]]
name = "designcraft-web"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"designcraft-engine",
"designcraft-format",
@@ -5813,7 +5813,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"serde_json",
]
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"]
[workspace.package]
version = "0.12.0"
version = "0.13.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+42 -2
View File
@@ -1,9 +1,10 @@
# DesignCraft roadmap
**Stage: pre-alpha** · next: alpha, ~5–7 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%)
- 2026-10-23: placed-artwork layer options survive IDML and native round trips per placement, independently of shared assets. Unsupported visibility overrides are explicit import/preflight/PDF warnings; format 4 prevents older native readers from silently discarding them. Rendering parity is still open; see [graphic-layer-options.md](docs/graphic-layer-options.md).
- FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Linear/radial, midpoint, archival and print policies have synthetic regression coverage.
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (placed graphic layer-option preservation and explicit rendering warnings; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026
- AR1.1: Kashida justification tests verify space fallback without Tatweel fonts using an isolated font database, while retaining elongation assertions when fonts support it.
- AR1: Arabic IDML controls now retain character direction, kashida switches, diacritic offsets/forms, paragraph policy values and independent story/table directions. Shaping preserves joining context and script/language selection; paragraph-level bidi, contextual digit conversion, mark-safe elongation, RTL columns/tables and physical column insertion have regression coverage. Vendor-specific justification and diacritic presets remain explicit preflight warnings; see docs/arabic-typography.md.
@@ -11,6 +12,21 @@
DesignCraft aims at full Adobe InDesign parity — and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web.
- **2026-10-24** · Embedded IDML keeps independent placed-PDF layer choices in table-cell and
footnote text. The variant rewrite reaches endnote stories too, but IDML endnote structures and
artwork remain unsupported. External-only export continues to reject active nested choices.
Shared placement ids with different sources or choices fail closed; no readiness percentage change.
- **2026-10-24** · PDF/A-2b preparation applies placed-PDF layer choices, including explicit all-on,
to anchors inside table-cell text, footnotes and endnotes in an export-only copy. The nested
IDML variant write pass remains separate work; no readiness percentage change.
- **2026-10-24** · Explicit PDF layer choices, including all-on, are checked with one actual
rewrite per distinct source before editing; reset to file defaults bypasses the check. Native
format 4 covers both explicit all-on PDF layers and retained graphic-layer options. No readiness percentage change.
- **2026-10-23** · Placed PDF layers can explicitly all be shown, separately from Use File Defaults.
Legacy commands keep their reset behavior; native saves require a capable reader only for
the new all-on state, and PDF/embedded IDML exports preserve the choice. No readiness percentage change.
- **2026-10-23** · IDML graphic-layer export rejects parents in already closed subtrees,
preserving native-edited layer order through round trips. Synthetic regression added.
- **2026-10-23** · PDF layer choices now validate before editing all targets; rejected rewrites
leave document/undo state intact. Null trailer entries remain unencrypted, and unsupported
all-content crops retain the whole page rather than silently clipping hidden layers.
@@ -19,6 +35,10 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
regression tests cover metadata, output-intent references, nonzero generations and
forward Prev links; repeated cross-reference offsets are rejected explicitly. No readiness percentage change.
- **2026-10-23** · PDF layer commands reject overrides on layerless files atomically;
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)
**Working today**
@@ -132,7 +152,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- View ▸ Rotate Spread (`view.rotateSpread`, `layout.rotateSpreadView`): the spread in view turns in quarter turns on screen (stored with the document; output is unaffected). The canvas maps each spread through its own transform, so tools, snapping, resize handles, guides, overlays and rendering all follow the turned spread.
- Vertical type (Vertical Type Tool, Type ▸ Story Direction `type.storyDirection`, IDML StoryOrientation): the direction belongs to the story, so every frame of a thread is vertical or horizontal together and a frame threaded onto a story takes its direction (`object.textFrameOptions {vertical}` sets the frames' stories); lines run top to bottom and follow each other right to left; the arrow keys follow them (Down and Up along the line, Left and Right to the next and previous line, keeping the position along it); CJK characters stay upright and other text turns, on screen and in PDF; CJK line breaking with basic kinsoku (also in horizontal text); Korean breaks at spaces, word by word (Hangul in any language, hanja in Korean text), or between syllables when the paragraph asks for it (`type.para {koreanCharBreaks}`, Paragraph Style Options ▸ Justification; not written to IDML). Upright text is shaped top to bottom: punctuation and brackets take their vertical forms (OpenType `vert`; `vkrn` and `vpal` through OpenType features), glyphs advance by their vertical metrics (`vmtx`) and hang from their vertical origins (`VORG`) on the centre of the ideographic em box (`BASE`, else OS/2), which the first line's ascent follows; turned text keeps its horizontal forms. Tate-chu-yoko (Type ▸ Tate-Chu-Yoko, `type.tateChuYoko`, IDML `Tatechuyoko`) sets a run such as "12" side by side within one em of the line. Ruby (Type ▸ Ruby…, `type.ruby`, IDML RubyString; group ruby spread 1-2-1 or centred) and kenten emphasis dots (`type.kenten`, IDML KentenKind) are set over their text — to its right in vertical frames — without changing line breaks. Warichu and ruby options (per-character ruby, positions, overhang) aren't there yet.
- Math expressions (Type ▸ Insert Math Expression…, `math.insert`, `math.svg`): a LaTeX subset (fractions, scripts, roots, big operators with limits, Greek, relations and operators with math spacing, `\left…\right`, `\text`) typeset by our own engine into SVG, placed inline at the cursor or as a frame; the LaTeX is kept as alt text and can be edited.
- Object Layer Options for placed PDFs (`object.pdfLayers`, `object.layerOptions`, Object ▸ Object Layer Options…): the file's optional-content layers listed and shown or hidden per frame on screen; in exported PDFs such graphics go out as 300 ppi images of their visible layers.
- Object Layer Options for placed PDFs (`object.pdfLayers`, `object.layerOptions`, Object ▸ Object Layer Options…): the file's optional-content layers listed and shown or hidden per frame on screen; plain and PDF/X-4 exports preserve their visible layers as vectors.
- Effects: Inner Glow (edge or centre), Bevel and Emboss (inner bevel lit from the light angle), Satin (`object.innerGlow`, `object.bevel`, `object.satin`); PDF export now keeps soft effects: objects with shadows, glows, feathers, bevels or satin go out as 300 ppi transparent images of their appearance (threaded text frames excepted).
- Directional Feather (`object.directionalFeather {widths: [top, left, bottom, right]}`): each side fades over its own width.
- View ▸ Flattener Preview (`view.flattenerPreview`): objects involving transparency highlighted in red; knockout groups export as images (PDF output has no knockout groups).
@@ -358,6 +378,25 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-24** · Story Options rejects malformed or nonexistent explicit story/frame IDs without changing the selected story, including an invalid frame alongside a valid story. Null targets remain absent; omitted targets still use the selection. No readiness percentage change.
- **2026-10-24** · Text Frame Options explains that a custom baseline-grid increment must be a measure greater than zero when zero, a negative value or a non-measure is entered. Validation is unchanged. No readiness percentage change.
- **2026-10-24** · Renaming a paragraph or character style, or moving it into a style group, keeps its Export Tagging under the new name, and undo puts it back under the old one. Deleting a tagged style drops its tagging and leaves the replacement style's own tagging as it was. No readiness percentage change.
- **2026-10-24** · Quick Apply and the application bar's search leave out the interface commands that failed or reset a setting when chosen there: those that need a workspace, a command and shortcut, a menu item, a panel, a window edge, an app page, a recent file or a swatch they can't give them, a Contextual Task Bar place, and Interface Color Theme, UI Scaling, Reset Shortcuts, the flattener preset, Screen Mode and Zoom To, which without a value switched to the dark theme, set 100%, cleared every custom shortcut or the preset, or went to Normal mode or 100% zoom. Each stays in the menu, panel or dialog that gives it the value. No readiness percentage change.
- **2026-10-24** · EPUB Export Options › Split Document, a dialog before File › Export EPUB… saves (`document.epubSplit`, stored with the document): off, a single paragraph style, or based on paragraph style export tags, with Paragraph Style Options › Export Tagging's Split Document flag marking those styles. A reflowable EPUB is one content file unless Split Document is on; the split before each top-level h1 from #791 is gone. A split before a list item or preformatted line no longer leaves an unclosed list or block in a content file. IDML import and export keep the setting and the flag, and Load Styles the flag. No readiness percentage change.
- **2026-10-18** · Repeated placed-PDF layer variants are prepared once per asset and hidden-name set, with recovery warnings retained. All-visible named layers keep their original source bytes, including PDFs whose cross-reference tables the reader repairs, in plain, PDF/X-4, booklet and book-merge output. Layer changes that require writing still reject an unusable cross-reference table with a file-named damage error; the first placement error is retained (#906). No readiness percentage change.
- **2026-10-18** · Placed PDF optional-content defaults and per-frame overrides survive plain and PDF/X-4 vector export, including repeated placements, booklet output and book merging (#906). Source usage-application arrays are accepted; groups without a catalog configuration stay visible. Missing or unreadable default configurations and unsupported base states use all-ON defaults with a file-named export warning, retaining explicit frame overrides. Readable groups omitted from the catalog list are added with a distinct file-named warning, preserving their default ON/OFF states. Concurrent recovery warnings are combined. Malformed ON data is recoverable when a known ON base and empty OFF list prove it cannot hide artwork; missing display names are generated without changing known visibility when no override needs name matching. Contradictory states, unresolved references and ambiguous name overrides still fail export. Print-usage adjustments and IDML layer overrides remain follow-ups. No readiness percentage change.
- **2026-10-24** · Optical Margin Alignment and hanging punctuation apply to lines with a tab or a right indent tab, as both composers already measured them: the edge glyphs hang, and tab stops stay where they are. Such a line was set without the hang: a justified line that fit only because its period or closing quote hangs had its word spaces squeezed to make up for it, and text after a right indent tab stopped at the measure instead of hanging. No readiness percentage change.
- **2026-10-23** · `designcraft-cli mcp` takes `--automation-read-root` and `--automation-write-root` more than once, as the suite's other apps do (#1552): a path inside any root of its kind passes, and the roots' common parent, `..` or a link out of them and the other kind's roots stay refused. No readiness percentage change.
- **2026-10-23** · Layout › Go Back and Go Forward (Cmd+Page Up / Cmd+Page Down, Ctrl on Windows and Linux) retrace the jumps between pages (Go to Page, the page and spread commands, the Pages panel) as InDesign's do, returning to where the view was on each page at the current zoom; a new jump after going back drops the pages ahead, scrolling adds no entry, and pages deleted since are skipped. No readiness percentage change.
- **2026-10-24** · Type › Notes › Previous Note and Next Note (`note.previous`, `note.next`): as in InDesign, they move the insertion point from note to note in the story, putting the caret after the note's anchor. Each item is unavailable when no note lies in its direction in the story, or when the insertion point is in a table cell or a footnote. No readiness percentage change.
- **2026-10-24** · Type › Notes › Convert to Note (`note.convertFromText {author?}`): as in InDesign, the selected text leaves the story and becomes the text of a new note, whose anchor takes its place; Convert Note to Text puts it back. DesignCraft refuses a selection in a table cell, or one that holds a note, footnote, endnote, anchored object, index marker, text anchor, cross-reference or table. No readiness percentage change.
- **2026-10-23** · Effects dialog: with Use Global Light on, the Angle field (and the Bevel and Emboss one) stays editable and shows the document's global light angle; a change there sets the global light for every effect that uses it, applied with the effects in one undo step (`object.effects` takes `globalLight`). Before, the field was greyed out and the angle couldn't be changed in the dialog. No readiness percentage change.
- **2026-10-24** · Story panel (Window › Type & Tables › Story): Optical Margin Alignment and its size for the stories of the text selection or the selected text frames, disabled without either; `type.selectionAttrs` reports them as `storyOptions`, null where the stories differ. The Paragraph panel menu has Ignore Optical Margin in place of the per-paragraph Optical Margin Alignment switch, which stays in `type.para`, and Paragraph Style Options › Indents and Spacing has Ignore Optical Margin (#947). No readiness percentage change.
- **2026-10-24** · Optical Margin Alignment per story with a size (`story.options {opticalMargin, opticalMarginSize}`, off by default): when it is on, the story's paragraphs hang punctuation and letter edges by amounts set for that type size, except paragraphs with Ignore Optical Margin (`ignoreOpticalMargin` in `type.para` and paragraph styles). Table cells and footnotes don't take the story setting yet. When it is off, the per-paragraph switch still hangs at the text's own size. IDML imports and exports both (`StoryPreference/@OpticalMarginAlignment`, `@OpticalMarginSize`, `IgnoreEdgeAlignment`) (#947). No readiness percentage change.
- **2026-10-24** · Text frames: a custom baseline grid's Relative To (Text Frame Options › Baseline Options, `object.textFrameOptions {baselineGrid: {relativeTo}}`, IDML `BaselineFrameGridRelativeOption`) measures the grid's start from the top of the page, the top margin, the top of the frame or its top inset. Text Frame Options had no custom baseline grid controls, imports dropped the setting and every frame grid started from the page top, so grid-aligned text in a frame measured from its own top sat on the wrong lines. No readiness percentage change.
- **2026-10-24** · Paragraph Style Options › Indents and Spacing has an Align to Grid menu: None, All Lines or First Line Only. It shows the style's current setting. A First Line Only style could be set only in the CJK Grid Settings section, which shows only with CJK features on, or through commands. No readiness percentage change.
- **2026-10-24** · Anchored text frames, such as marginal notes, compose where their host story places them: they align to the baseline grid at that position, and page-number markers and spine-relative alignment inside them read the host's page. They composed against a grid measured from their stored position and on no page, so a note set to Only Align First Line to Grid could sit a fraction of a line off the body text's grid line. No readiness percentage change.
- **2026-10-24** · New documents start with Hyphenate Capitalized Words, Hyphenate Last Word and Hyphenate Across Column off in [Basic Paragraph], as in InDesign. Opened and imported files keep the values they carry. No readiness percentage change.
- **2026-10-24** · The baseline grid's Relative To setting applies: with Top Margin, the grid starts at each page's own top margin in text composition, on the canvas and for snapping. It started at the page top, so grid-aligned text sat on the wrong lines and a page could fit one line fewer. No readiness percentage change.
- **2026-10-24** · Custom-positioned anchored objects honour Relative to Spine (`anchored.options {spineRelative}`, IDML `SpineRelative`): the settings describe a left-hand page and mirror on a right-hand page, so marginal notes stay in the outside margin on both pages of a spread. On right-hand pages they took the left-hand placement and landed in the inside margin, across the spine or over the body text. No readiness percentage change.
@@ -371,6 +410,7 @@ Newest first.
- **2026-10-22** · The Paragraph panel is laid out as InDesign's: the nine alignment buttons in groups; indent, spacing and drop cap fields by their icons in two columns, with Space Between Paragraphs Using Same Style (Ignore or a space); Shading and Border with the Paragraph Borders and Shading button and colour; Hyphenate with Do Not Align / Align to Baseline Grid. Its panel menu follows InDesign's order: Paragraph and Single-line Composer, Only Align First Line to Grid, Balance Ragged Lines, Optical Margin Alignment, Justification…, Keep Options…, Span Columns…, Hyphenation…, Drop Caps and Nested Styles…, GREP Styles… (these three now open for the selected paragraphs), Paragraph Rules…, Paragraph Borders and Shading…, Bullets and Numbering…, Restart/Continue Numbering, Define Lists… and the paragraph direction. Convert Bullets and Numbering to Text is listed but not available yet. No readiness percentage change.
- **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change.
- **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change.
- **2026-10-23** · Type › Text Variables › Convert Variable to Text (`variables.convertToText`): the variable instances in the selected text, in a story, a table cell or a footnote, become plain text of the value each shows on its own page (a running header on page 3 becomes page 3's heading). `variables.convertAllToText {name}` does the same for every instance of one variable in the document, as the Text Variables dialog's Convert to Text button. The variable stays defined; one undo step. No readiness percentage change.
- **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change.
- **2026-10-22** · Paragraph rules: Rule Above's offset is measured from the first line's baseline to the rule's bottom edge, as in InDesign. Rules sat one ascent too high and crossed the previous paragraph's text (reported from an imported INDD document). No readiness percentage change.
- **2026-10-21** · Ruler guides are selected like objects (#1282): a click with the Selection tools selects a guide (also where it crosses an object), Shift-click adds or removes one, a new guide from the rulers is selected, a marquee that touches no object selects the guides it crosses, and Edit › Select All Guides (⌥⌘G, `guide.selectAll`) selects every unlocked guide on the spread. Selected guides draw thicker in their layer's color, drag together (all of them shown moving), nudge with the arrow keys and go with Delete, each as one undo step (`guide.select`, `guide.deselect`, `guide.moveBy`, `guide.delete {}`). With one guide selected the Control bar's X (vertical guide) or Y (horizontal guide) field and Properties › Transform show where it is, as the rulers measure, and typing a value moves it there. No readiness percentage change.
+25 -31
View File
@@ -3,30 +3,22 @@
use std::path::PathBuf;
#[derive(Default)]
pub struct AutomationArgs {
pub read_root: Option<PathBuf>,
pub write_root: Option<PathBuf>,
read_given: bool,
write_given: bool,
pub read_roots: Vec<PathBuf>,
pub write_roots: Vec<PathBuf>,
}
impl AutomationArgs {
pub fn new(read_root: Option<PathBuf>, write_root: Option<PathBuf>) -> Self {
Self { read_root, write_root, read_given: false, write_given: false }
}
/// Returns whether this argument is a root flag. A second occurrence of a flag is an error,
/// including when the two spellings (`--flag DIR`, `--flag=DIR`) are mixed.
/// Returns whether this argument is a root flag. Each flag may be given more than once (#1552),
/// in either spelling (`--flag DIR`, `--flag=DIR`): every use adds a folder, none replaces one.
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value)));
let (root, given) = match flag {
"--automation-read-root" => (&mut self.read_root, &mut self.read_given),
"--automation-write-root" => (&mut self.write_root, &mut self.write_given),
let roots = match flag {
"--automation-read-root" => &mut self.read_roots,
"--automation-write-root" => &mut self.write_roots,
_ => return Ok(false),
};
if *given {
return Err(format!("{flag} given twice"));
}
let value = match inline {
Some(value) => value.to_owned(),
None => {
@@ -40,8 +32,7 @@ impl AutomationArgs {
if value.is_empty() {
return Err(format!("{flag} needs a non-empty folder"));
}
*root = Some(value.into());
*given = true;
roots.push(value.into());
Ok(true)
}
}
@@ -51,40 +42,43 @@ mod tests {
use super::*;
#[test]
fn roots_are_taken_once_and_other_arguments_are_left() {
let mut roots = AutomationArgs::new(None, None);
fn roots_add_up_and_other_arguments_are_left() {
let mut roots = AutomationArgs::default();
let mut args = ["a folder".to_owned(), "doc.designcraft".to_owned()].into_iter();
assert!(roots.consume("--automation-read-root", &mut args).unwrap());
assert!(roots.consume("--automation-write-root=out", &mut args).unwrap());
assert!(!roots.consume("--sample", &mut args).unwrap());
assert_eq!(roots.read_root, Some("a folder".into()));
assert_eq!(roots.write_root, Some("out".into()));
assert_eq!(roots.read_roots, [PathBuf::from("a folder")]);
assert_eq!(roots.write_roots, [PathBuf::from("out")]);
assert_eq!(args.next().as_deref(), Some("doc.designcraft"));
assert!(roots.consume("--automation-read-root=other", &mut args).is_err());
assert_eq!(roots.read_root, Some("a folder".into()));
// #1552: a flag given again adds a folder, in either spelling, and keeps the first.
assert!(roots.consume("--automation-read-root=other", &mut args).unwrap());
assert!(roots.consume("--automation-write-root", &mut ["out2".to_owned()].into_iter()).unwrap());
assert_eq!(roots.read_roots, [PathBuf::from("a folder"), PathBuf::from("other")]);
assert_eq!(roots.write_roots, [PathBuf::from("out"), PathBuf::from("out2")]);
}
#[test]
fn invalid_operands_leave_roots_unchanged() {
for flag in ["--automation-read-root", "--automation-write-root"] {
for operand in [None, Some(""), Some("--version"), Some("--connect"), Some("-folder")] {
let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into()));
let mut roots = AutomationArgs { read_roots: vec!["read".into()], write_roots: vec!["write".into()] };
let mut args = operand.map(str::to_owned).into_iter();
assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}");
assert_eq!(roots.read_root, Some("read".into()));
assert_eq!(roots.write_root, Some("write".into()));
assert_eq!(roots.read_roots, [PathBuf::from("read")]);
assert_eq!(roots.write_roots, [PathBuf::from("write")]);
}
let mut roots = AutomationArgs::new(None, None);
let mut roots = AutomationArgs::default();
assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err());
}
}
#[test]
fn explicit_dash_folder_and_relative_dash_folder_are_valid() {
let mut roots = AutomationArgs::new(None, None);
let mut roots = AutomationArgs::default();
assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap());
assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap());
assert_eq!(roots.read_root, Some("-folder".into()));
assert_eq!(roots.write_root, Some("./-folder".into()));
assert_eq!(roots.read_roots, [PathBuf::from("-folder")]);
assert_eq!(roots.write_roots, [PathBuf::from("./-folder")]);
}
}
+8 -7
View File
@@ -10,7 +10,7 @@
//! # run a command script (crates/engine/src/script.rs): `$N.path` references
//! designcraft-cli app [--port PORT] COMMAND [JSON] # run a command in the running app (designcraft --control PORT)
//! designcraft-cli app [--port PORT] --method METHOD [JSON] # any control-channel method (ui.screenshot, ui.render, …)
//! designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR]
//! designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR]... [--automation-write-root DIR]...
//! # MCP server over stdio (docs/mcp.md); the roots confine the files it reads and writes
//! designcraft-cli perf [--pages N] [--frames N] [--chars N] [--images N] [--runs N] [--strict] # budgets on a synthetic stress document
//! designcraft-cli bench FILE [--runs N] # the same measurements on one document
@@ -39,7 +39,7 @@ use designcraft_engine::Session;
use serde_json::{Value, json};
/// The usage block. Shared by `--help` (stdout, success) and an unknown command (stderr, failure).
const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR]\n the two roots confine every file the agent's commands read (open, placed images, data sources, libraries) and\n write (save, export, package) to those folders, links followed. A root left out grants none of its access.\n They run the in-process server, so they conflict with --connect (a running app is not confined by them).\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli validate-pdf FILE.pdf # built-in PDF/X-4 checks (including visible unembedded fonts)\n designcraft-cli links\n designcraft-cli --version";
const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR]... [--automation-write-root DIR]...\n the roots confine every file the agent's commands read (open, placed images, data sources, libraries) and\n write (save, export, package) to those folders, links followed. Each may be given more than once; a kind\n left out grants none of its access.\n They run the in-process server, so they conflict with --connect (a running app is not confined by them).\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli validate-pdf FILE.pdf # built-in PDF/X-4 checks (including visible unembedded fonts)\n designcraft-cli links\n designcraft-cli --version";
/// `script --help`: the script syntax, otherwise only in `describe script.run` and docs/agents.md.
const SCRIPT_HELP: &str = r#"usage: designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]
@@ -170,7 +170,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
use designcraft_mcp::{Backend, Headless, Remote, Server, control_addr};
let mut connect: Option<String> = None;
let mut sample = false;
let mut roots_args = automation_args::AutomationArgs::new(None, None);
let mut roots_args = automation_args::AutomationArgs::default();
let mut it = args.iter().cloned();
while let Some(a) = it.next() {
if roots_args.consume(&a, &mut it)? {
@@ -181,12 +181,14 @@ fn mcp(args: &[String]) -> Result<(), String> {
"--sample" => sample = true,
other => {
return Err(format!(
"unknown mcp option `{other}` (usage: designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR])"
"unknown mcp option `{other}` (usage: designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR]... [--automation-write-root DIR]...)"
));
}
}
}
let roots = AutomationRoots::new(roots_args.read_root.as_deref(), roots_args.write_root.as_deref())?;
let read: Vec<&std::path::Path> = roots_args.read_roots.iter().map(std::path::PathBuf::as_path).collect();
let write: Vec<&std::path::Path> = roots_args.write_roots.iter().map(std::path::PathBuf::as_path).collect();
let roots = AutomationRoots::new(&read, &write)?;
if roots.is_some() && connect.is_some() {
return Err("--automation-read-root and --automation-write-root confine the in-process server; a running app is not confined by them, \
so drop --connect (or use --connect without the roots)"
@@ -209,8 +211,7 @@ so drop --connect (or use --connect without the roots)"
if let Some(roots) = roots {
// Every thread of this process, not only the server's: nothing reaches past the roots.
file_access::confine_process(roots.clone());
let show = |root: Option<&std::path::Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
eprintln!("designcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root()));
eprintln!("designcraft-cli: files confined: {}", roots.describe());
h = h.with_automation_roots(Some(roots));
}
Box::new(h)
+1
View File
@@ -239,6 +239,7 @@ pub fn synthetic(spec: &Spec) -> Result<Document, String> {
let (nw, nh) = (aw as f64, ah as f64);
let s = (r.width() / nw).max(r.height() / nh);
it.content = Content::Graphic(Graphic {
layer_options: None,
asset,
size: (nw, nh),
xf: Affine::translate((r.x0 + (r.width() - nw * s) / 2.0, r.y0 + (r.height() - nh * s) / 2.0)) * Affine::scale(s),
+75 -3
View File
@@ -401,6 +401,76 @@ fn mcp_automation_roots_confine_the_server() {
let _ = std::fs::remove_dir_all(base);
}
/// #1552: each root flag may be given more than once. A path inside any root of its kind passes;
/// the roots' common parent, a sibling whose name starts like a root's, `..` out of a root, the
/// other kind's roots and a link out of a root stay refused.
#[test]
fn mcp_automation_roots_take_several_folders() {
let base = std::env::temp_dir().join(format!("dc-cli-roots-several-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
for d in ["in", "shared", "out", "scratch", "outside", "in2", "scratch2"] {
std::fs::create_dir_all(base.join(d)).unwrap();
}
// Resolved, as the server shows the roots (`/var` is `/private/var` on macOS).
let base = std::fs::canonicalize(base).unwrap();
for f in ["in/a.designcraft", "shared/b.designcraft", "outside/secret.designcraft", "parent.designcraft", "in2/c.designcraft"] {
make_document(&base.join(f));
}
let p = |f: &str| base.join(f).to_str().unwrap().to_string();
let sep = std::path::MAIN_SEPARATOR;
let up = |root: &str, rest: &str| format!("{}{sep}..{sep}{rest}", p(root));
let calls = [
("open_document", serde_json::json!({"path": p("in/a.designcraft")})),
("open_document", serde_json::json!({"path": p("shared/b.designcraft")})),
// The roots' common parent is not a root.
("open_document", serde_json::json!({"path": p("parent.designcraft")})),
("open_document", serde_json::json!({"path": up("shared", &format!("outside{sep}secret.designcraft"))})),
// `in2` only starts like `in`.
("open_document", serde_json::json!({"path": p("in2/c.designcraft")})),
// `..` from one read root into another stays inside.
("open_document", serde_json::json!({"path": up("shared", &format!("in{sep}a.designcraft"))})),
("save_document", serde_json::json!({"path": p("out/x.designcraft")})),
("save_document", serde_json::json!({"path": p("scratch/y.designcraft")})),
("save_document", serde_json::json!({"path": p("z.designcraft")})),
("save_document", serde_json::json!({"path": p("scratch2/z.designcraft")})),
// Read and write stay separate: a read root is not a write root, nor the other way round.
("save_document", serde_json::json!({"path": p("shared/w.designcraft")})),
("open_document", serde_json::json!({"path": p("out/x.designcraft")})),
("export_png", serde_json::json!({"path": up("scratch", &format!("outside{sep}p.png"))})),
];
let read2 = format!("--automation-read-root={}", p("shared"));
let args = ["--automation-read-root", &p("in"), &read2, "--automation-write-root", &p("out"), "--automation-write-root", &p("scratch")];
let (success, stderr, replies) = mcp_session(&args, &calls);
assert!(success, "{stderr}");
let confined = format!("files confined: read {}, {}; write {}, {}", p("in"), p("shared"), p("out"), p("scratch"));
assert!(stderr.contains(&confined), "{stderr}");
let errors: Vec<bool> = replies.iter().map(|r| r["result"]["isError"] == true).collect();
assert_eq!(errors, [false, false, true, true, true, false, false, false, true, true, true, true, true], "{replies:?}");
for (i, kind) in [(2, "read"), (3, "read"), (4, "read"), (8, "write"), (9, "write"), (10, "write"), (11, "read"), (12, "write")] {
assert!(text(&replies[i]).contains(&format!("outside the {kind} roots ")), "{i}: {}", text(&replies[i]));
}
assert!(base.join("out/x.designcraft").is_file() && base.join("scratch/y.designcraft").is_file());
for f in ["z.designcraft", "scratch2/z.designcraft", "shared/w.designcraft", "outside/p.png"] {
assert!(!base.join(f).exists(), "{f} was written outside the write roots");
}
// A link in one root that leads out of all of them is outside, for reading and for writing.
#[cfg(unix)]
{
std::os::unix::fs::symlink(base.join("outside/secret.designcraft"), base.join("shared/leak.designcraft")).unwrap();
std::os::unix::fs::symlink(base.join("outside"), base.join("scratch/away")).unwrap();
let calls = [
("open_document", serde_json::json!({"path": p("shared/leak.designcraft")})),
("save_document", serde_json::json!({"path": p("scratch/away/x.designcraft")})),
];
let (_, _, replies) = mcp_session(&args, &calls);
assert!(text(&replies[0]).contains("outside the read roots"), "{}", text(&replies[0]));
assert!(text(&replies[1]).contains("outside the write roots"), "{}", text(&replies[1]));
assert!(!base.join("outside/x.designcraft").exists());
}
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn mcp_automation_root_flags_are_checked_up_front() {
let base = std::env::temp_dir().join(format!("dc-cli-roots-args-{}", std::process::id()));
@@ -419,9 +489,11 @@ fn mcp_automation_root_flags_are_checked_up_front() {
// The flags confine the in-process server, so they cannot be combined with --connect.
fails(&["--connect", "7979", "--automation-read-root", dir], "--connect");
fails(&["--automation-write-root", dir, "--connect", "7979"], "--connect");
fails(&["--automation-read-root", dir, "--automation-read-root", dir], "given twice");
fails(&["--automation-write-root", dir, "--automation-write-root", dir], "given twice");
fails(&["--automation-read-root", dir, "--automation-read-root=x"], "given twice");
// #1552: a flag may be given again, but every folder it names must exist and be given.
fails(&["--automation-read-root", dir, "--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root");
fails(&["--automation-write-root", missing.to_str().unwrap(), "--automation-write-root", dir], "cannot open the automation write root");
fails(&["--automation-write-root", dir, "--automation-write-root"], "needs a folder");
fails(&["--automation-read-root", dir, "--automation-read-root="], "needs a non-empty folder");
fails(&["--automation-read-root"], "needs a folder");
fails(&["--automation-write-root", "--sample"], "needs a folder");
fails(&["--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root");
+3
View File
@@ -68,3 +68,6 @@ sync_github_name = false
[people.4waymedia]
real_name = "Paul Gemignani"
[people.skylartaylor]
display_name = "skylartaylor"
+31 -14
View File
@@ -48,6 +48,9 @@ pub struct Spacing {
pub hyph_zone: f64,
/// Optical margin alignment: punctuation hangs outside the measure (counted as extra width).
pub optical: bool,
/// The type size the hang amounts are set for (Story ▸ Optical Margin Alignment's size);
/// `None`: each glyph's own size, so the hang is [`hang`]'s proportion of its advance.
pub optical_size: Option<f64>,
/// Korean text breaks between syllables instead of at spaces.
pub korean_char_breaks: bool,
/// 0: minimum adjustment; 1: push in; 2: push out first; 3: push out only.
@@ -72,6 +75,7 @@ impl Default for Spacing {
ragged_stretch: 24.0,
hyph_zone: 0.0,
optical: false,
optical_size: None,
korean_char_breaks: false,
kinsoku_priority: 0,
}
@@ -318,16 +322,32 @@ pub fn hang(c: char) -> (f64, f64) {
}
}
fn hang_left(g: &Glyph) -> f64 {
hang(g.ch).0 * g.adv
/// How much more than [`hang`]'s proportions `g` hangs: the hang is set for
/// [`Spacing::optical_size`] and grows with it, relative to the glyph's own size.
pub(crate) fn optical_scale(g: &Glyph, sp: &Spacing) -> f64 {
match sp.optical_size {
Some(s) if s.is_finite() && g.size.is_finite() && g.size > 0.0 => s / g.size,
_ => 1.0,
}
}
/// Optical hang of `g` at the start of a line (0 without optical margin alignment).
pub(crate) fn hang_left(g: &Glyph, sp: &Spacing) -> f64 {
if sp.optical { hang(g.ch).0 * g.adv * optical_scale(g, sp) } else { 0.0 }
}
/// Optical hang of `g` at the end of a line (0 without optical margin alignment).
pub(crate) fn hang_right(g: &Glyph, sp: &Spacing) -> f64 {
if sp.optical { hang(g.ch).1 * g.adv * optical_scale(g, sp) } else { 0.0 }
}
fn right_hang(g: &Glyph, sp: &Spacing) -> f64 {
g.cjk_hang.max(if sp.optical { hang_right(g) } else { 0.0 }) - g.moji.end
g.cjk_hang.max(hang_right(g, sp)) - g.moji.end
}
fn hang_right(g: &Glyph) -> f64 {
hang(g.ch).1 * g.adv
/// The part of a hyphen after `g` (of width `hw`) that hangs past the line's end.
fn hyphen_hang(g: &Glyph, hw: f64, sp: &Spacing) -> f64 {
if sp.optical { hw * hang('-').1 * optical_scale(g, sp) } else { 0.0 }
}
/// Advance of the hyphen glyph that would follow `g` (cached per face).
@@ -428,15 +448,13 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], emergency_after: &[bool], sp: &S
let mut p = Item::penalty(hw, sp.hyphen_penalty, true);
p.auto = true;
p.zone_from = last_space;
if sp.optical {
p.hang = hw * hang('-').1;
}
p.hang = hyphen_hang(g, hw, sp);
push_inword(&mut it, &mut ig, p, i + 1, sp);
continue;
}
let (st, sh) = sp.box_elastic(g);
let mut bx = Item::boxed(g.adv, st, sh);
bx.hang = (if sp.optical { hang_left(g) } else { 0.0 }) - g.moji.start;
bx.hang = hang_left(g, sp) - g.moji.start;
bx.start_elastic = crate::mojikumi::start_elastic(g, sp.justify, sp.kinsoku_priority != 3);
it.push(bx);
ig.push(i);
@@ -457,9 +475,7 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], emergency_after: &[bool], sp: &S
let mut p = Item::penalty(hw, sp.hyphen_penalty, true);
p.auto = true;
p.zone_from = last_space;
if sp.optical {
p.hang = hw * hang('-').1;
}
p.hang = hyphen_hang(g, hw, sp);
push_inword(&mut it, &mut ig, p, i + 1, sp);
} else if emergency_after.get(i) == Some(&true) {
let mut p = Item::penalty(0.0, EMERGENCY_PENALTY, false);
@@ -876,7 +892,7 @@ pub fn greedy(
let may_hyphenate = |hyphens: u32| sp.hyphen_limit == 0 || hyphens < sp.hyphen_limit;
while start < n {
let w = width(line).max(1.0);
let mut x = (if sp.optical { -hang_left(&glyphs[start]) } else { 0.0 }) + glyphs[start].moji.start;
let mut x = -hang_left(&glyphs[start], sp) + glyphs[start].moji.start;
let mut shrink = crate::mojikumi::start_elastic(&glyphs[start], sp.justify, sp.kinsoku_priority != 3)[1];
let mut last_natural: Option<(usize, bool)> = None;
let mut last_ok: Option<(usize, bool)> = None; // (break position = end glyph, hyphen)
@@ -939,7 +955,8 @@ pub fn greedy(
last_natural = last_ok;
}
} else if hyph_after[i] && may_hyphenate(hyphens) {
let hy = hyphen_width(g, &mut hy_cache) * if sp.optical { 1.0 - hang('-').1 } else { 1.0 };
let hw = hyphen_width(g, &mut hy_cache);
let hy = (hw - hyphen_hang(g, hw, sp)).max(0.0);
if x + hy <= w + shrink + FIT_EPS {
last_ok = Some((i + 1, true));
}
+28 -8
View File
@@ -3,7 +3,8 @@
//! An entry is valid while the inputs it was composed from are the *same allocations*: the story
//! `Arc` (and its revision), the styles `Arc`, the frame items' `Arc`s and the `Arc`s of every
//! wrapping item on the frames' spreads, and the same for every story whose anchored objects wrap
//! its text ([`crate::wrap::neighbours`]). Entries hold clones of those `Arc`s, so the pointers
//! its text ([`crate::wrap::neighbours`]). An anchored frame's entry also records where its host
//! story's composition places it. Entries hold clones of those `Arc`s, so the pointers
//! can't be reused by other allocations while the entry lives.
use std::collections::HashMap;
@@ -99,11 +100,12 @@ impl Cache {
/// [`Cache::get`] for any [`ShownOn`].
pub fn get_on(&self, doc: &Document, sid: StoryId, on: ShownOn) -> Arc<ComposedStory> {
self.get_with(doc, sid, on, true)
self.get_with(doc, sid, on, true, 0)
}
/// [`Cache::get_on`], wrapped by other stories' anchored objects only when `cross` is set.
fn get_with(&self, doc: &Document, sid: StoryId, on: ShownOn, cross: bool) -> Arc<ComposedStory> {
/// [`Cache::get_on`], wrapped by other stories' anchored objects only when `cross` is set, for
/// a story `depth` hosts down from the story asked for.
fn get_with(&self, doc: &Document, sid: StoryId, on: ShownOn, cross: bool, depth: usize) -> Arc<ComposedStory> {
let Some(story) = doc.stories.get(&sid) else { return Arc::new(ComposedStory { story: sid, ..Default::default() }) };
let (mut sig, mut keep_items) = signature(doc, story);
let neighbours = if cross { crate::wrap::neighbours(doc, sid) } else { Vec::new() };
@@ -117,6 +119,24 @@ impl Cache {
keep_stories.push(ns.clone());
}
}
// Anchored frames compose where their host story places them, so the host is composed
// first and the placements are part of the signature.
let placed = if depth < crate::placed::MAX_DEPTH {
crate::placed::placements(doc, sid, &mut |host| self.get_with(doc, host, ShownOn::Own, true, depth + 1))
} else {
Vec::new()
};
for (id, p) in &placed {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
(id, p.spread, p.xf.as_coeffs().map(f64::to_bits)).hash(&mut h);
// The placed spread's pages give the frame its page, side and page rect.
for pg in doc.spread(p.spread).map_or(&[][..], |sp| &sp.pages[..]) {
let m = &pg.margins;
(pg.side as u8, [pg.x, pg.width, pg.height, m.top, m.bottom, m.inside, m.outside].map(f64::to_bits)).hash(&mut h);
}
sig.push(h.finish() as usize);
}
let key = (sid, on, !neighbours.is_empty());
// The key holds the page's index, which a renumbering doesn't change: its name (section
// start number, style, prefix) is part of the signature.
@@ -209,11 +229,11 @@ impl Cache {
// Parent-page items are composed per page: markers show its name, variables need its index.
let page = page.filter(|_| with_vars);
let running = running.then(|| self.running_index(doc));
let others: Vec<Arc<ComposedStory>> = neighbours.iter().map(|n| self.neighbour(doc, *n)).collect();
let others: Vec<Arc<ComposedStory>> = neighbours.iter().map(|n| self.neighbour(doc, *n, depth)).collect();
let out = Arc::new(crate::wrap::compose_among(
doc,
sid,
&ComposeOptions { page_name, section_marker, page, running, label: None, xrefs: with_xrefs.then(|| self.xref_index(doc)) },
&ComposeOptions { page_name, section_marker, page, running, label: None, xrefs: with_xrefs.then(|| self.xref_index(doc)), placed },
&others.iter().map(|o| &**o).collect::<Vec<_>>(),
));
let mut g = self.map.lock().unwrap_or_else(PoisonError::into_inner);
@@ -295,13 +315,13 @@ impl Cache {
/// Story `sid` composed for the wrap of its anchored objects in other stories: without other
/// stories' anchored objects (no cycles), and outside the cache when it needs the document-wide
/// indexes (they compose every story).
fn neighbour(&self, doc: &Document, sid: StoryId) -> Arc<ComposedStory> {
fn neighbour(&self, doc: &Document, sid: StoryId, depth: usize) -> Arc<ComposedStory> {
let indexed =
doc.stories.get(&sid).is_some_and(|st| crate::xref::has_xrefs(st) || (crate::vars::has_vars(st) && crate::vars::has_running(doc, st)));
if indexed {
Arc::new(crate::wrap::compose_among(doc, sid, &ComposeOptions::default(), &[]))
} else {
self.get_with(doc, sid, ShownOn::Own, false)
self.get_with(doc, sid, ShownOn::Own, false, depth)
}
}
+18
View File
@@ -23,6 +23,13 @@ use designcraft_doc::story;
pub const MAX_PATTERN: usize = 4096;
/// Backtracking steps one search may take before it gives up.
pub const BACKTRACK_LIMIT: usize = 200_000;
/// Matches one GREP style may contribute to one paragraph.
///
/// [`BACKTRACK_LIMIT`] bounds a single search, not a scan: `find_all` pays it again for every
/// match, so a pattern tuned just under the limit cost `BACKTRACK_LIMIT × matches` per paragraph
/// on every relayout (#1598). A paragraph cannot usefully carry more styled runs than this, and
/// a scan that reaches it stops with what it has.
pub const MAX_MATCHES_PER_PARAGRAPH: usize = 4096;
/// Compiled program size cap (bytes) for the underlying regex engine.
const SIZE_LIMIT: usize = 4 << 20;
@@ -378,6 +385,17 @@ impl Grep {
self.re.find_iter(text).take(limit).map(|m| m.map(|m| m.range()).map_err(run_err)).collect()
}
/// Non-empty matches in `text`, at most `limit` of them: an empty match styles nothing, so it
/// doesn't count toward a GREP style's cap.
pub fn find_nonempty(&self, text: &str, limit: usize) -> Result<Vec<Range<usize>>, GrepError> {
self.re
.find_iter(text)
.filter(|m| !m.as_ref().is_ok_and(|m| m.range().is_empty()))
.take(limit)
.map(|m| m.map(|m| m.range()).map_err(run_err))
.collect()
}
/// Matches in `text` and their replacements, `template` expanded per match (`$1`…; see
/// [`translate_replacement`]). Stops after the first match when `first_only`.
pub fn replacements(&self, text: &str, template: &str, first_only: bool) -> Result<Vec<(Range<usize>, String)>, GrepError> {
+88 -26
View File
@@ -23,6 +23,7 @@ pub mod optical;
mod outline;
mod overlay;
mod parabox;
mod placed;
mod ruby;
pub mod shape;
pub mod shatai;
@@ -50,6 +51,7 @@ use crate::notes::Notes;
pub use crate::notes::{find_note, hit_note, hit_note_with, note_caret};
pub use crate::outline::FrameOutline;
use crate::parabox::ParaBox;
pub use crate::placed::Placement;
use crate::shape::{Glyph, StyleTable, SubstCtx};
pub use crate::table::{BorderHit, PlacedCell, StrokeSeg, TableFrag, cell_caret, find_cell, hit_border, hit_cell, hit_cell_with};
@@ -627,6 +629,9 @@ pub struct ComposeOptions {
pub label: Option<String>,
/// Anchor pages for cross-references (needed when the story contains cross-references).
pub xrefs: Option<Arc<xref::XrefIndex>>,
/// Where the story's anchored frames sit on their spreads; an anchored frame not listed is
/// composed where it is stored, on no page.
pub placed: Vec<(ItemId, Placement)>,
}
/// Nesting depth past which wrap obstacles inside groups are not collected.
@@ -659,21 +664,34 @@ fn wrap_obstacles(it: &Item, parent: Affine, inv: Affine, skip: ItemId, left_pag
/// Build the frame specs of a story from the document (geometry, wrap, page names, grid).
pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> {
frame_specs_placed(doc, sid, &[])
}
/// [`frame_specs`] with anchored frames where their host story placed them (`placed`).
pub(crate) fn frame_specs_placed(doc: &Document, sid: StoryId, placed: &[(ItemId, Placement)]) -> Vec<FrameSpec> {
let Some(st) = doc.story(sid) else { return vec![] };
let mut out = Vec::with_capacity(st.frames.len());
for &fid in &st.frames {
let loc = doc.find(fid);
let Some(item) = loc.as_ref().and_then(|l| doc.item_at(l)).or_else(|| doc.anchored_item(fid)) else { continue };
let Some(tf) = item.text_frame() else { continue };
let parent = loc.as_ref().map(|l| doc.parent_xf(l)).unwrap_or(designcraft_geom::Affine::IDENTITY);
// An anchored frame takes its page, page side, page rect and grid from where it is placed.
let placement = if loc.is_none() { placed.iter().find(|(id, _)| *id == fid).map(|(_, p)| *p) } else { None };
let parent = match (&loc, placement) {
(Some(l), _) => doc.parent_xf(l),
(None, Some(p)) => p.xf,
(None, None) => Affine::IDENTITY,
};
let xf = parent * item.xf;
// Where the frame is on the spread (a group's transform included), to find its page.
let center_x = parent.transform_rect_bbox(item.bounds()).center().x;
let inv = xf.inverse();
let mut exclusions = Vec::new();
let spread_ref = loc.as_ref().map(|l| l.spread);
let spread_ref = loc.as_ref().map(|l| l.spread).or(placement.map(|p| p.spread));
let spread = spread_ref.and_then(|r| doc.spread(r));
// Page items wrap the text of frames on the spread, not of anchored frames.
if !tf.options.ignore_wrap
&& loc.is_some()
&& let Some(sp) = spread
{
let left = |x: f64| sp.page_at_x(x).and_then(|pi| sp.pages.get(pi)).is_some_and(|p| p.side == designcraft_doc::PageSide::Left);
@@ -713,9 +731,23 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> {
let under = spread.and_then(|sp| sp.page_at_x(center_x).and_then(|pi| sp.pages.get(pi)));
let page_rect = under.map(|pg| (inv.transform_rect_bbox(pg.bounds()), inv.transform_rect_bbox(pg.margin_rect())));
let g = &doc.settings.baseline_grid;
let (start, inc) = tf.options.baseline_grid.map_or((under.map_or(g.start, |pg| g.origin(pg)), g.increment), |f| (f.start, f.increment));
// Grid lines are at spread y = start + n·inc (page tops are y = 0); map into inner space (translation only).
let ty = xf.translation().y;
let (start, inc) = match tf.options.baseline_grid {
None => (under.map_or(g.start, |pg| g.origin(pg)), g.increment),
Some(f) => {
use designcraft_doc::FrameGridRelative as R;
// The frame's own edges are in inner space; `ty` takes them to spread y as it takes
// the grid back, so a frame-relative grid keeps its place in a turned frame.
let from = match f.relative_to {
R::TopOfPage => 0.0,
R::TopMargin => under.map_or(0.0, |pg| pg.margins.top),
R::TopOfFrame => ty + item.inner_bounds().y0,
R::TopInset => ty + item.text_area().y0,
};
(from + f.start, f.increment)
}
};
let grid = (inc > 0.0 && inc.is_finite() && start.is_finite()).then_some((start - ty, inc));
// Type on a path: one line as long as the path (from the start offset).
let (area, opts) = match &tf.options.path {
@@ -783,10 +815,23 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> {
/// Compose a story of `doc` through its frames, with the text wrap of page items and of anchored
/// objects ([`wrap`]).
/// Anchored frames are composed where their host story places them (unless `opts` places them).
pub fn compose_story(doc: &Document, sid: StoryId, opts: &ComposeOptions) -> ComposedStory {
compose_story_at(doc, sid, opts, 0)
}
/// [`compose_story`] for a story `depth` hosts down from the story asked for.
fn compose_story_at(doc: &Document, sid: StoryId, opts: &ComposeOptions, depth: usize) -> ComposedStory {
let others: Vec<ComposedStory> =
wrap::neighbours(doc, sid).into_iter().map(|o| wrap::compose_among(doc, o, &ComposeOptions::default(), &[])).collect();
wrap::compose_among(doc, sid, opts, &others.iter().collect::<Vec<_>>())
let others: Vec<&ComposedStory> = others.iter().collect();
if opts.placed.is_empty() && depth < placed::MAX_DEPTH {
let placed = placed::placements(doc, sid, &mut |host| Arc::new(compose_story_at(doc, host, &ComposeOptions::default(), depth + 1)));
if !placed.is_empty() {
return wrap::compose_among(doc, sid, &ComposeOptions { placed, ..opts.clone() }, &others);
}
}
wrap::compose_among(doc, sid, opts, &others)
}
const DEFAULT_TAB: f64 = 36.0;
@@ -1018,9 +1063,9 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
};
if !story.endnotes.is_empty() {
// Endnote reference numbers continue through the document.
let first = doc.endnote_start(story.id) + story.text[..prange.start].matches(designcraft_doc::ENDNOTE_REF).count() as u32;
let first = doc.endnote_start(story.id).saturating_add(story.text[..prange.start].matches(designcraft_doc::ENDNOTE_REF).count() as u32);
for (k, (i, _)) in story.text[prange.clone()].match_indices(designcraft_doc::ENDNOTE_REF).enumerate() {
sub.notes.insert(prange.start + i, doc.endnote_options.label(first + k as u32));
sub.notes.insert(prange.start + i, doc.endnote_options.label(first.saturating_add(k as u32)));
}
}
if !story.objects.is_empty() {
@@ -1091,7 +1136,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
let cols_here = &cols[cur.fi];
let col = span_rect(cols_here, cols_here[cur.col.min(cols_here.len() - 1)], pp.span_columns);
let col = SplitCfg::of(&pp).map_or(col, |c| c.sub_rect(col, 0));
let spacing = spacing_for(&pp, base_chars.size);
let spacing = spacing_for(&pp, base_chars.size, story);
let mut lines: Vec<(std::ops::Range<usize>, String)> = Vec::new();
for _ in 0..3 {
// The same drop cap, spacing, hyphenation and breaker as the layout below.
@@ -1247,7 +1292,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
hyphenation_points(&story.text, &glyphs, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars));
suppress_hyphens(&mut hyph_after, &glyphs, no_hyph.get(pi).map_or(&[], Vec::as_slice));
let base_size = base_chars.size;
let spacing = spacing_for(&pp, base_size);
let spacing = spacing_for(&pp, base_size, story);
// An empty paragraph takes its height from its paragraph mark's character format.
let mark_chars = if glyphs.is_empty() {
std::borrow::Cow::Owned(doc.styles.resolve_char(&base_chars, story.char_format_of(prange.start)))
@@ -2258,8 +2303,22 @@ pub fn column_rule_rects(columns: &[Rect], lines: &[Line], opts: &TextFrameOptio
out
}
/// Optical margin alignment for a paragraph of `story`: (on, the size the hang is set for).
/// Ignore Optical Margin turns it off; the story's setting, when on, applies to every other
/// paragraph; otherwise the paragraph's own switch hangs at each glyph's own size.
fn optical_for(pp: &ParaProps, story: &Story) -> (bool, Option<f64>) {
if pp.ignore_optical_margin {
return (false, None);
}
match story.optical_margin.hang_size() {
Some(size) => (true, Some(size)),
None => (pp.optical_margin, None),
}
}
/// Breaker parameters from the paragraph's settings.
fn spacing_for(pp: &ParaProps, base_size: f64) -> Spacing {
fn spacing_for(pp: &ParaProps, base_size: f64, story: &Story) -> Spacing {
let (optical, optical_size) = optical_for(pp, story);
Spacing {
justify: pp.align.is_justified(),
word_min: pp.word_space_min,
@@ -2277,7 +2336,8 @@ fn spacing_for(pp: &ParaProps, base_size: f64) -> Spacing {
// The zone is the Single-line Composer's (unjustified text only), as in InDesign: the
// Paragraph Composer weighs every break of the paragraph instead.
hyph_zone: if pp.align.is_justified() || !pp.composer.single_line() { 0.0 } else { pp.hyph_zone },
optical: pp.optical_margin,
optical,
optical_size,
korean_char_breaks: pp.korean_char_breaks,
kinsoku_priority: match pp.kinsoku_type.as_str() {
"KinsokuPushInFirst" => 1,
@@ -3359,9 +3419,20 @@ fn layout_line(
let h = shape::hyphen_after(g);
line.push(h);
}
// Tabs: compute widths left to right.
let measure = x1 - x0;
let mut x = 0.0;
// Optical margin alignment and hanging punctuation: the glyphs at the line's edges hang
// outside the measure. A tab at an edge hangs nothing.
let is_tab = |g: &Glyph| g.ch == '\t' || g.ch == story::RIGHT_INDENT_TAB;
let at_edge = |g: &&Glyph| is_tab(g) || (g.adv > 0.0 && !g.is_space());
let first = line.iter().find(at_edge);
let lastg = line.iter().rev().find(at_edge);
let hl = first.map_or(0.0, |g| breaker::hang_left(g, sp));
let optical_right = lastg.map_or(0.0, |g| breaker::hang_right(g, sp));
let cjk_right = lastg.map_or(0.0, |g| g.cjk_hang);
let regular_hang = pp.kinsoku_hang == designcraft_doc::cjk::KinsokuHang::Regular;
// Tabs: compute widths left to right. Tab stops stay where they are when the line hangs, so
// tabs are measured from the hung start, and a right indent tab reaches the hung end.
let mut x = -hl;
let mut i = 0;
let mut leaders: Vec<(usize, String)> = Vec::new();
// The line's last tab and whether it is a left tab (only text after a left tab justifies).
@@ -3391,26 +3462,17 @@ fn layout_line(
leaders.push((i, t.leader.clone()));
}
let rest: f64 = line[i + 1..].iter().map(|g| g.adv).sum();
line[i].adv = (measure - x - rest).max(0.0);
let hr = if regular_hang { optical_right } else { optical_right.max(cjk_right) };
line[i].adv = (measure + hr - x - rest).max(0.0);
last_tab = Some((i, false));
}
x += line[i].adv;
i += 1;
}
let has_tab = line.iter().any(|g| g.ch == '\t' || g.ch == story::RIGHT_INDENT_TAB);
// Optical margin alignment: the measure grows by the hang of the edge glyphs.
// The measure grows by the hang of the edge glyphs.
let (mut x0, mut measure) = (x0, measure);
if (sp.optical || pp.kinsoku_hang != designcraft_doc::cjk::KinsokuHang::None) && !has_tab {
let first = line.iter().find(|g| g.adv > 0.0 && !g.is_space());
let lastg = line.iter().rev().find(|g| g.adv > 0.0 && !g.is_space());
let hl = if sp.optical { first.map_or(0.0, |g| breaker::hang(g.ch).0 * g.adv) } else { 0.0 };
let optical_right = if sp.optical { lastg.map_or(0.0, |g| breaker::hang(g.ch).1 * g.adv) } else { 0.0 };
let cjk_right = lastg.map_or(0.0, |g| g.cjk_hang);
let cjk_right = if pp.kinsoku_hang == designcraft_doc::cjk::KinsokuHang::Regular {
cjk_right.min((line.iter().map(|g| g.adv).sum::<f64>() - measure).max(0.0))
} else {
cjk_right
};
if sp.optical || pp.kinsoku_hang != designcraft_doc::cjk::KinsokuHang::None {
let cjk_right = if regular_hang { cjk_right.min((line.iter().map(|g| g.adv).sum::<f64>() - measure).max(0.0)) } else { cjk_right };
let hr = optical_right.max(cjk_right);
x0 -= hl;
measure += hl + hr;
+2 -1
View File
@@ -130,7 +130,7 @@ impl Notes {
self.num = o.start_at;
}
self.numbers[k] = self.num;
self.num += 1;
self.num = self.num.saturating_add(1);
sub.notes.insert(self.refs[k], o.label(self.numbers[k], false));
}
sub.note_position = o.ref_position;
@@ -180,6 +180,7 @@ impl Notes {
running: opts.running.clone(),
label: Some(label),
xrefs: opts.xrefs.clone(),
placed: Vec::new(),
};
let cs = crate::compose(doc, &self.notes[k].text, std::slice::from_ref(&spec), &nopts);
let lines = cs.frames.first().map(|ft| ft.lines.as_slice()).unwrap_or_default();
+77 -9
View File
@@ -9,9 +9,42 @@ use designcraft_doc::{GrepStyle, NestedStyle, NestedUntil};
use crate::grep::{Grep, GrepOptions};
/// Patterns kept compiled at once. A document's GREP styles are few; the cache grew without
/// bound across every pattern a session ever composed (#1598).
const MAX_CACHED_PATTERNS: usize = 64;
thread_local! {
/// Compiled GREP styles by pattern; None for one that doesn't compile or gave up on a search.
static PATTERNS: RefCell<HashMap<String, Option<Grep>>> = RefCell::new(HashMap::new());
/// Compiled GREP styles by pattern, with the tick they were last used at; `None` for one that
/// doesn't compile or gave up on a search. Bounded to [`MAX_CACHED_PATTERNS`], least recently
/// used evicted first.
static PATTERNS: RefCell<HashMap<String, (Option<Grep>, u64)>> = RefCell::new(HashMap::new());
/// Counts cache uses, so the least recently used entry can be found.
static TICK: RefCell<u64> = const { RefCell::new(0) };
}
/// The compiled pattern for `pattern`, compiling it on first use and evicting the least recently
/// used entry once the cache is full. `f` is given `None` when the pattern does not compile.
fn with_pattern<R>(pattern: &str, f: impl FnOnce(&mut Option<Grep>) -> R) -> R {
let now = TICK.with(|t| {
let mut t = t.borrow_mut();
*t += 1;
*t
});
PATTERNS.with(|p| {
let mut p = p.borrow_mut();
if !p.contains_key(pattern)
&& p.len() >= MAX_CACHED_PATTERNS
&& let Some(oldest) = p.iter().min_by_key(|(_, (_, used))| *used).map(|(k, _)| k.clone())
{
p.remove(&oldest);
}
let slot = p.entry(pattern.to_string()).or_insert_with(|| {
let re = Grep::new(pattern, GrepOptions::default()).map_err(|e| log::warn!("GREP style {pattern:?} skipped: {e}")).ok();
(re, now)
});
slot.1 = now;
f(&mut slot.0)
})
}
fn is_event(u: &NestedUntil, c: char, prev: Option<char>, next: Option<char>) -> bool {
@@ -71,14 +104,15 @@ pub(crate) fn overlays(text: &str, range: Range<usize>, nested: &[NestedStyle],
if g.style.is_empty() || g.pattern.is_empty() {
continue;
}
PATTERNS.with(|p| {
let mut p = p.borrow_mut();
let slot = p.entry(g.pattern.clone()).or_insert_with(|| {
Grep::new(&g.pattern, GrepOptions::default()).map_err(|e| log::warn!("GREP style {:?} skipped: {e}", g.pattern)).ok()
});
with_pattern(&g.pattern, |slot| {
let Some(re) = slot else { return };
match re.find_all(text.get(range.start..end).unwrap_or(""), usize::MAX) {
Ok(ms) => out.extend(ms.into_iter().filter(|m| !m.is_empty()).map(|m| (range.start + m.start..range.start + m.end, g.style.clone()))),
match re.find_nonempty(text.get(range.start..end).unwrap_or(""), crate::grep::MAX_MATCHES_PER_PARAGRAPH) {
Ok(ms) => {
if ms.len() == crate::grep::MAX_MATCHES_PER_PARAGRAPH {
log::warn!("GREP style {:?} matched more than {} times in one paragraph; the rest are not styled", g.pattern, ms.len());
}
out.extend(ms.into_iter().filter(|m| !m.is_empty()).map(|m| (range.start + m.start..range.start + m.end, g.style.clone())));
}
Err(e) => {
log::warn!("GREP style {:?} disabled: {e}", g.pattern);
*slot = None;
@@ -93,6 +127,40 @@ pub(crate) fn overlays(text: &str, range: Range<usize>, nested: &[NestedStyle],
mod tests {
use super::*;
/// A pattern that can match nothing still styles a paragraph longer than the cap: empty
/// matches don't count toward it.
#[test]
fn empty_matches_do_not_use_up_the_grep_cap() {
let text = format!("{}a", "b".repeat(crate::grep::MAX_MATCHES_PER_PARAGRAPH + 100));
let o = overlays(&text, 0..text.len(), &[], &[GrepStyle { style: "A".into(), pattern: "a*".into() }], 0);
assert_eq!(o.len(), 1, "the one non-empty match at the end is styled");
}
/// A GREP style's scan is bounded two ways (#1598): the matches one paragraph can take, and
/// the patterns kept compiled at once.
#[test]
fn a_grep_scan_and_the_pattern_cache_are_bounded() {
// More matches in one paragraph than the cap: the scan stops with what it has instead of
// paying the backtrack limit again for each one.
let text = "a ".repeat(crate::grep::MAX_MATCHES_PER_PARAGRAPH + 500);
let o = overlays(&text, 0..text.len(), &[], &[GrepStyle { style: "A".into(), pattern: "a".into() }], 0);
assert_eq!(o.len(), crate::grep::MAX_MATCHES_PER_PARAGRAPH, "the scan stops at the cap");
// The cache holds at most MAX_CACHED_PATTERNS, and the one just used is not the one
// dropped: a document's own styles stay compiled however many patterns a session saw.
PATTERNS.with(|p| p.borrow_mut().clear());
for i in 0..MAX_CACHED_PATTERNS + 20 {
let pattern = format!("x{i}y");
overlays("x1y", 0..3, &[], &[GrepStyle { style: "S".into(), pattern }], 0);
}
let (len, has_last) = PATTERNS.with(|p| {
let p = p.borrow();
(p.len(), p.contains_key(&format!("x{}y", MAX_CACHED_PATTERNS + 19)))
});
assert_eq!(len, MAX_CACHED_PATTERNS, "the cache is bounded");
assert!(has_last, "the most recently used pattern is still cached");
}
#[test]
fn nested_and_grep_ranges() {
let text = "Q: what is 42? A drop of 7 words.\n".to_string();
+84
View File
@@ -0,0 +1,84 @@
//! Where anchored text frames sit on their spread.
//!
//! An anchored frame is not a spread item: its host story's composition places it on a line. Its
//! own story is composed against that placement (page, page side, page rect, baseline grid), so
//! the host story is composed first.
use std::sync::Arc;
use designcraft_doc::{Document, Item, ItemId, SpreadRef, StoryId};
use designcraft_geom::Affine;
use crate::{ComposedStory, FrameText};
/// Hosts nested deeper than this (a note anchored in a note anchored in …, or stories anchored in
/// each other in a cycle) are composed where they are stored.
pub(crate) const MAX_DEPTH: usize = 8;
/// An anchored item's parent space on its spread, where its host story's composition placed it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Placement {
pub spread: SpreadRef,
/// The item's parent space → spread space.
pub xf: Affine,
}
/// The placements of `sid`'s anchored frames, from their host stories composed by `host`.
pub(crate) fn placements(doc: &Document, sid: StoryId, host: &mut dyn FnMut(StoryId) -> Arc<ComposedStory>) -> Vec<(ItemId, Placement)> {
let Some(st) = doc.story(sid) else { return Vec::new() };
let mut anchored = None;
let mut out = Vec::new();
for &fid in &st.frames {
if doc.find(fid).is_some() {
continue;
}
let items = anchored.get_or_insert_with(|| doc.anchored_items());
// A frame anchored in its own story would need its own composition to place it.
let Some(owner) = items.iter().find_map(|(owner, it)| (it.id == fid).then_some(*owner)).filter(|o| *o != sid) else { continue };
if let Some(p) = locate(doc, &host(owner), fid) {
out.push((fid, p));
}
}
out
}
/// Where `cs` (a host story's composition) places item `fid`: in its frames' anchored objects,
/// footnotes and table cells.
fn locate(doc: &Document, cs: &ComposedStory, fid: ItemId) -> Option<Placement> {
cs.frames.iter().find_map(|ft| {
let loc = doc.find(ft.frame)?;
let frame = doc.item_at(&loc)?;
let xf = in_frame(ft, doc.parent_xf(&loc) * doc.text_xf(frame), fid, 0)?;
Some(Placement { spread: loc.spread, xf })
})
}
/// The parent space of `fid` among the objects of `ft`, whose text space maps to the spread by
/// `xf`.
fn in_frame(ft: &FrameText, xf: Affine, fid: ItemId, depth: usize) -> Option<Affine> {
if depth > MAX_DEPTH {
return None;
}
let objects = ft.objects.iter().find_map(|o| in_item(&o.object.item, xf * Affine::translate(o.origin.to_vec2()), fid, 0));
objects
.or_else(|| ft.notes.iter().find_map(|n| in_frame(n.text.frames.first()?, xf * Affine::translate(n.origin.to_vec2()), fid, depth + 1)))
.or_else(|| {
ft.tables
.iter()
.flat_map(|t| &t.cells)
.find_map(|c| in_frame(c.text.frames.first()?, xf * Affine::translate(c.origin.to_vec2()), fid, depth + 1))
})
}
/// The parent space of `fid` in `it` (it or a group member), `it`'s parent space mapping to the
/// spread by `parent`.
fn in_item(it: &Item, parent: Affine, fid: ItemId, depth: usize) -> Option<Affine> {
if it.id == fid {
return Some(parent);
}
if depth > crate::MAX_WRAP_DEPTH {
return None;
}
let xf = parent * it.xf;
it.children().iter().find_map(|c| in_item(c, xf, fid, depth + 1))
}
+228 -1
View File
@@ -577,6 +577,7 @@ fn same_as_clipping_wrap_follows_the_clipping_path() {
);
let triangle = PathData::single(SubPath::polyline(&[Point::new(75.0, 0.0), Point::new(150.0, 200.0), Point::new(0.0, 200.0)], true));
it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
layer_options: None,
asset: designcraft_doc::AssetId(1),
size: (150.0, 200.0),
xf: designcraft_geom::Affine::translate((150.0, 100.0)),
@@ -725,6 +726,7 @@ fn an_image_contour_stops_at_its_frame() {
let mut it = designcraft_doc::Item::new(ItemId(d.alloc()), lid, designcraft_doc::Shape::Polygon, frame);
// The image overhangs the frame on every side.
it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
layer_options: None,
asset: designcraft_doc::AssetId(d.alloc()),
size: (300.0, 250.0),
xf: designcraft_geom::Affine::IDENTITY,
@@ -769,6 +771,7 @@ fn a_clipping_path_contour_stops_at_its_frame() {
true,
));
it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
layer_options: None,
asset: designcraft_doc::AssetId(1),
size: (150.0, 200.0),
xf: designcraft_geom::Affine::translate((150.0, 100.0)),
@@ -1358,6 +1361,111 @@ fn optical_margin_hangs_punctuation() {
assert!(lines.iter().all(|l| l.end_x <= l.x1 + 12.0));
}
#[test]
fn optical_margin_hangs_the_edges_of_lines_with_tabs() {
// Both line breakers let the edge glyphs of a line hang, tab or not, so layout hangs them
// too: a line that fits only because its period hangs is not squeezed into the measure or
// set past it. The left hang leaves tab stops where they are.
let stop = designcraft_doc::TabStop { position: 40.0, align: TabAlign::Left, leader: String::new(), align_on: String::new() };
let rest = "right here. More words follow it on the next line.";
let cases = [
("tab", format!("“A”\tsits {rest}"), Align::LeftJustified),
("no tab", format!("“A” sits {rest}"), Align::LeftJustified),
("right indent tab", format!("“A” sits{}{rest}", story::RIGHT_INDENT_TAB), Align::Left),
];
for (name, text, align) in &cases {
let ch = |g: &PlacedGlyph| text.get(g.byte..).and_then(|s| s.chars().next()).unwrap_or(' ');
let edge_glyphs = |l: &Line| -> Vec<PlacedGlyph> { l.glyphs.iter().filter(|g| g.visible && !ch(g).is_whitespace()).cloned().collect() };
for composer in [Composer::Paragraph, Composer::SingleLine] {
let mut ending = 0;
for w in 100..=240 {
let para = ParaAttrs {
align: Some(*align),
optical_margin: Some(true),
hyphenate: Some(false),
composer: Some(composer),
tabs: Some(vec![stop.clone()]),
..Default::default()
};
let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, f64::from(w), 1000.0), para);
let cs = compose_story(&d, sid, &ComposeOptions::default());
let lines = all_lines(&cs);
for (i, l) in lines.iter().enumerate() {
let Some(last) = edge_glyphs(l).last().cloned() else { continue };
let hang = breaker::hang(ch(&last)).1 * last.adv;
let end = last.x + last.adv;
assert!(end <= l.x1 + hang + 0.01, "{name}, {composer:?}, width {w}: line {i} ends at {end}, past {} and its hang {hang}", l.x1);
}
let glyphs = edge_glyphs(lines[0]);
let (x0, x1) = (lines[0].x0, lines[0].x1);
let first = &glyphs[0];
assert!((first.x + first.adv - x0).abs() < 0.01, "{name}, {composer:?}, width {w}: the opening quote is at {}", first.x);
if *name == "tab" {
let s = glyphs.iter().find(|g| ch(g) == 's').unwrap();
assert!((s.x - (x0 + 40.0)).abs() < 0.01, "{composer:?}, width {w}: the text after the tab starts at {}", s.x);
}
// Line 0 ends with the period of "here.": the period hangs past the measure.
let last = glyphs.last().unwrap();
if text.get(last.byte..).is_some_and(|s| s.starts_with(". More")) {
ending += 1;
assert!((last.x - x1).abs() < 0.01, "{name}, {composer:?}, width {w}: the period starts at {}, not at {x1}", last.x);
}
}
assert!(ending > 3, "{name}, {composer:?}: line 0 ends with the period at {ending} widths");
}
}
}
/// How far the opening quote of a one-line paragraph at `text_size` hangs into the left margin,
/// with the story's Optical Margin Alignment `story` (on, size) and the paragraph's attributes.
fn quote_hang(text_size: f64, story: Option<f64>, para: ParaAttrs) -> f64 {
let text = "“Quoted” words.";
let first_x = |story: Option<f64>, para: ParaAttrs| {
let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 400.0, 200.0), para);
let st = d.story_mut(sid).unwrap();
st.format_chars(0..text.len(), |f| f.over.size = Some(text_size));
if let Some(size) = story {
st.optical_margin = designcraft_doc::OpticalMargin { on: true, size };
}
let cs = compose_story(&d, sid, &ComposeOptions::default());
let lines = all_lines(&cs);
assert_eq!(lines.len(), 1);
lines[0].glyphs[0].x
};
first_x(None, ParaAttrs::default()) - first_x(story, para)
}
#[test]
fn story_optical_margin_hang_scales_with_its_size() {
let at12 = quote_hang(12.0, Some(12.0), ParaAttrs::default());
let at24 = quote_hang(12.0, Some(24.0), ParaAttrs::default());
assert!(at12 > 1.0, "{at12}");
assert!((at24 - 2.0 * at12).abs() < 0.01, "{at12} {at24}");
// The size is the type size the hang is set for: 24 pt text at 24 hangs like 12 at 12, twice as far.
let big = quote_hang(24.0, Some(24.0), ParaAttrs::default());
assert!((big - 2.0 * at12).abs() < 0.01, "{at12} {big}");
}
#[test]
fn ignore_optical_margin_paragraph_does_not_hang() {
let ignored = ParaAttrs { ignore_optical_margin: Some(true), optical_margin: Some(true), ..Default::default() };
assert_eq!(quote_hang(12.0, Some(12.0), ignored.clone()), 0.0);
assert_eq!(quote_hang(12.0, None, ignored), 0.0);
}
#[test]
fn paragraph_optical_margin_hangs_at_the_text_size() {
// Documents saved before the story setting existed: the paragraph's switch hangs each
// character by its own size, not the story's default 12 pt.
let own = ParaAttrs { optical_margin: Some(true), ..Default::default() };
let at24 = quote_hang(24.0, None, own.clone());
assert!((at24 - quote_hang(24.0, Some(24.0), ParaAttrs::default())).abs() < 0.01);
assert!((at24 - 2.0 * quote_hang(12.0, None, own)).abs() < 0.01);
// The story setting, when on, sets the size.
let story = quote_hang(24.0, Some(12.0), ParaAttrs { optical_margin: Some(true), ..Default::default() });
assert!((story - at24 / 2.0).abs() < 0.01, "{story} {at24}");
}
// ---------- golden paragraph corpus ----------
/// Original prose (written for this corpus) with a realistic mix of short and long words.
@@ -6697,7 +6805,7 @@ fn roman_word_break_breaks_inside_words_without_hyphens() {
fn grid_doc(text: &str, para: ParaAttrs, edit: impl FnOnce(&mut Document, StoryId)) -> (Vec<Line>, (f64, f64)) {
let (mut d, sid, fid) = doc_with(text, Rect::new(0.0, 0.0, 300.0, 400.0), para);
if let Some(tf) = d.item_mut(fid).and_then(|i| i.text_frame_mut()) {
tf.options.baseline_grid = Some(designcraft_doc::FrameBaselineGrid { start: 0.0, increment: 12.0 });
tf.options.baseline_grid = Some(designcraft_doc::FrameBaselineGrid::new(0.0, 12.0));
}
edit(&mut d, sid);
// Vertical frames have no baseline grid.
@@ -7407,6 +7515,37 @@ fn baseline_grid_relative_to_the_top_margin_starts_at_each_pages_margin() {
assert_eq!(lines.len(), 41);
}
/// A frame's custom grid (start 10, increment 13) measured from the page top, the top margin
/// (36), the frame top (100) or the top inset (6 below it).
#[test]
fn frame_baseline_grid_relative_to_page_margin_frame_or_inset() {
use designcraft_doc::{FrameBaselineGrid, FrameGridRelative as R};
let first_baseline = |relative_to: Option<R>| {
let mut d = Document::new(&NewDocument { margins: designcraft_doc::Margins::uniform(36.0), ..Default::default() });
let lid = d.default_layer();
let para = ParaFormat { para: ParaAttrs { grid_align: Some(GridAlign::AllLines), ..Default::default() }, ..Default::default() };
let (fid, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 100.0, 336.0, 400.0), lid, "Grid", para).unwrap();
let st = d.story_mut(sid).unwrap();
st.paras[0].chars =
designcraft_doc::CharAttrs { size: Some(11.0), leading: Some(designcraft_doc::Leading::Points(13.0)), ..Default::default() };
let tf = d.item_mut(fid).unwrap().text_frame_mut().unwrap();
tf.options.inset = [6.0, 0.0, 0.0, 0.0];
tf.options.baseline_grid = relative_to.map(|r| FrameBaselineGrid { relative_to: r, ..FrameBaselineGrid::new(10.0, 13.0) });
// A document grid that matches none of the frame grids.
d.settings.baseline_grid.start = 0.0;
d.settings.baseline_grid.increment = 0.0;
compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0].baseline
};
// Without a grid the first baseline sits an ascent below the top inset.
let natural = first_baseline(None);
assert!(natural > 106.0 && natural < 120.0, "{natural}");
for (r, origin) in [(R::TopOfPage, 10.0), (R::TopMargin, 46.0), (R::TopOfFrame, 110.0), (R::TopInset, 116.0)] {
let want = (0..).map(|n| origin + 13.0 * f64::from(n)).find(|y| *y >= natural - 1e-9).unwrap();
let got = first_baseline(Some(r));
assert!((got - want).abs() < 1e-6, "{r:?}: first baseline {got}, want {want} (natural {natural})");
}
}
/// A group's transform moves a frame from the left page of a spread to the right one: the grid
/// starts at the right page's top margin.
#[test]
@@ -7658,3 +7797,91 @@ fn an_rtl_first_line_indent_is_at_the_right_edge() {
assert!((left_edge(lines[0]) - rect.x0).abs() < 1.0, "left to right, first line at the edge: {}", left_edge(lines[0]));
assert!((left_edge(lines[1]) - (rect.x0 + 10.0 * MM)).abs() < 1.0, "left to right, wrapped line indented: {}", left_edge(lines[1]));
}
/// A marginal note (an anchored text frame, Only Align First Line to Grid, 9/10) hangs 15 pt
/// above its anchor's baseline on a right-hand page. It composes against the document grid where
/// it is placed: its first line moves down onto the grid line, the second follows 10 pt below,
/// and its page is the host's. Composed where it was stored, it snapped to a grid measured from
/// its own top edge and sat on no page.
#[test]
fn anchored_text_frames_compose_against_the_grid_where_they_are_placed() {
use designcraft_doc::anchored::AnchorRelative as R;
let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() });
d.settings.baseline_grid.start = 36.0;
d.settings.baseline_grid.increment = 10.0;
let lid = d.default_layer();
let page = d.spreads[1].pages[1].clone();
assert_eq!(page.side, designcraft_doc::PageSide::Right);
// Body lines at 46, 56, 66, … (on the grid) while the frame top is at 36.
let body = "One.\nTwo.\nThree.\nFour.";
let rect = Rect::new(page.x + 108.0, 36.0, page.x + page.width - 36.0, 400.0);
let (bf, sid) = d.add_text_frame(SpreadRef::Doc(1), rect, lid, body, ParaFormat::default()).unwrap();
d.item_mut(bf).unwrap().text_frame_mut().unwrap().options.first_baseline = FirstBaseline::Leading;
let nine_ten = |f: &mut designcraft_doc::story::CharFormat| {
f.over.size = Some(9.0);
f.over.leading = Some(designcraft_doc::Leading::Points(10.0));
};
d.story_mut(sid).unwrap().format_chars(0..body.len(), nine_ten);
// The note: its own story in a 60 × 40 frame anchored in the third line.
let (nid, nsid) = (ItemId(d.alloc()), StoryId(d.alloc()));
let text = "Note one\u{2028}note two";
let para = ParaFormat { para: ParaAttrs { grid_align: Some(GridAlign::FirstLineOnly), ..Default::default() }, ..Default::default() };
let mut story = Story::with_text(nsid, text, para);
story.format_chars(0..text.len(), nine_ten);
story.frames.push(nid);
d.stories.insert(nsid, Arc::new(story));
let mut note =
designcraft_doc::Item::new(nid, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 60.0, 40.0)));
note.content = Content::Text(designcraft_doc::TextFrame { story: nsid, options: Default::default() });
let position = designcraft_doc::AnchorPosition::Custom {
x_relative: R::TextFrame,
y_relative: R::Anchor,
x_offset: -72.0,
y_offset: -15.0,
object_point: 0,
ref_point: 0,
keep_within_column: false,
spine_relative: false,
};
let at = body.find("Three").unwrap();
d.story_mut(sid).unwrap().insert_object(at, designcraft_doc::AnchoredObject::new(note, position));
let cache = Cache::new();
let on_grid = |y: f64| on_grid(y, (36.0, 10.0));
// (anchor line baseline, note baselines), in spread space.
let baselines = |d: &Document, cs: &ComposedStory| {
let host = compose_story(d, sid, &ComposeOptions::default());
let ft = host.frame(bf).unwrap();
let o = &ft.objects[0];
let fxf = d.item(bf).unwrap().xf;
let anchor = (fxf * Point::new(0.0, ft.lines[o.line].baseline)).y;
let nxf = fxf * Affine::translate(o.origin.to_vec2()) * d.anchored_item(nid).unwrap().xf;
(anchor, all_lines(cs).iter().map(|l| (nxf * Point::new(0.0, l.baseline)).y).collect::<Vec<_>>())
};
let near = |a: f64, b: f64| (a - b).abs() < 1e-6;
let (anchor, lines) = baselines(&d, &cache.get(&d, nsid, None));
assert!(near(anchor, 66.0), "anchor baseline {anchor}");
assert_eq!(lines.len(), 2);
assert!(near(lines[0], anchor) && near(lines[1], anchor + 10.0), "note baselines {lines:?}, anchor {anchor}");
assert_eq!(baselines(&d, &compose_story(&d, nsid, &ComposeOptions::default())).1, lines);
// The note is on the host's right-hand page, with that page's rectangle.
let placed = placed::placements(&d, nsid, &mut |h| Arc::new(compose_story(&d, h, &ComposeOptions::default())));
let spec = &frame_specs_placed(&d, nsid, &placed)[0];
assert_eq!((spec.page, spec.right_page, spec.left_page), (Some(2), true, false));
assert!(spec.page_rect.is_some());
// Host moved down 5 pt: the anchor is off the grid, and the note's first line moves down from
// 10 pt above it onto the grid line 5 pt above it. The cache composes the note again.
let xf = d.item(bf).unwrap().xf;
d.item_mut(bf).unwrap().xf = Affine::translate((0.0, 5.0)) * xf;
let (anchor, lines) = baselines(&d, &cache.get(&d, nsid, None));
assert!(near(anchor, 71.0), "anchor baseline {anchor}");
assert!(on_grid(lines[0]) && near(lines[0], anchor - 5.0) && near(lines[1], lines[0] + 10.0), "note baselines {lines:?}, anchor {anchor}");
assert_eq!(baselines(&d, &compose_story(&d, nsid, &ComposeOptions::default())).1, lines);
// The host's page turns into a left-hand page: the note is composed again for that side.
let before = cache.get(&d, nsid, None);
Arc::make_mut(&mut d.spreads[1]).pages[1].side = designcraft_doc::PageSide::Left;
assert!(!Arc::ptr_eq(&before, &cache.get(&d, nsid, None)));
}
+1 -1
View File
@@ -182,7 +182,7 @@ fn settled(a: &[Vec<Exclusion>], b: &[Vec<Exclusion>]) -> bool {
/// [`neighbours`]) and of its own.
pub(crate) fn compose_among(doc: &Document, sid: StoryId, opts: &ComposeOptions, others: &[&ComposedStory]) -> ComposedStory {
let Some(st) = doc.story(sid) else { return ComposedStory { story: sid, ..Default::default() } };
let mut specs = crate::frame_specs(doc, sid);
let mut specs = crate::frame_specs_placed(doc, sid, &opts.placed);
if !others.is_empty() {
let mut obs = Vec::new();
for cs in others {
+67
View File
@@ -15,6 +15,72 @@ use crate::{Asset, AssetId, Content, Document, Item};
/// is treated as using every asset, so nothing it references is merged away or pruned.
const MAX_DEPTH: usize = 64;
fn item_layer_options(it: &Item, depth: usize, overrides_only: bool) -> bool {
depth > MAX_DEPTH
|| it.graphic().is_some_and(|g| g.layer_options.as_ref().is_some_and(|o| !overrides_only || o.has_visibility_overrides()))
|| it.children().iter().any(|child| item_layer_options(child, depth + 1, overrides_only))
}
fn story_layer_options(st: &Story, depth: usize, overrides_only: bool) -> bool {
depth > MAX_DEPTH
|| st.objects.iter().any(|object| item_layer_options(&object.item, depth + 1, overrides_only))
|| st.tables.values().flat_map(|table| &table.cells).any(|cell| {
cell.graphic.as_ref().is_some_and(|g| g.layer_options.as_ref().is_some_and(|o| !overrides_only || o.has_visibility_overrides()))
|| story_layer_options(&cell.text, depth + 1, overrides_only)
})
|| st.notes.iter().chain(&st.endnotes).any(|note| story_layer_options(&note.text, depth + 1, overrides_only))
}
fn item_any(it: &Item, depth: usize, f: &impl Fn(&Item) -> bool) -> bool {
depth > MAX_DEPTH || f(it) || it.children().iter().any(|child| item_any(child, depth + 1, f))
}
fn story_any(st: &Story, depth: usize, f: &impl Fn(&Item) -> bool) -> bool {
depth > MAX_DEPTH
|| st.objects.iter().any(|object| item_any(&object.item, depth + 1, f))
|| st.tables.values().flat_map(|table| &table.cells).any(|cell| story_any(&cell.text, depth + 1, f))
|| st.notes.iter().chain(&st.endnotes).any(|note| story_any(&note.text, depth + 1, f))
}
impl Document {
/// Whether saving needs a reader that retains per-placement graphic layer settings.
/// Exceeding the traversal budget conservatively requires the newer format.
pub fn has_graphic_layer_options(&self) -> bool {
self.spreads.iter().chain(&self.parents).flat_map(|spread| &spread.items).any(|item| item_layer_options(item, 0, false))
|| self.stories.values().any(|story| story_layer_options(story, 0, false))
}
/// Whether any placed PDF, on document or parent spreads, in groups or anchored, explicitly
/// shows every layer (an override with an empty hidden list). Older readers would take that
/// for "use the file's defaults". Exceeding the traversal budget conservatively says yes.
pub fn has_explicit_all_on_pdf_layers(&self) -> bool {
let all_on = |it: &Item| it.pdf_layer_override && it.pdf_hidden_layers.is_empty();
self.spreads.iter().chain(&self.parents).flat_map(|spread| &spread.items).any(|item| item_any(item, 0, &all_on))
|| self.stories.values().any(|story| story_any(story, 0, &all_on))
}
/// Whether any placed PDF, on document or parent spreads, in groups or anchored, has an
/// Object Layer Options choice (hidden layers or explicit all-on). Exceeding the traversal
/// budget conservatively says yes.
pub fn has_pdf_layer_overrides(&self) -> bool {
let chosen = |it: &Item| it.has_pdf_layer_override();
self.spreads.iter().chain(&self.parents).flat_map(|spread| &spread.items).any(|item| item_any(item, 0, &chosen))
|| self.stories.values().any(|story| story_any(story, 0, &chosen))
}
/// Placements outside the ordinary document-spread frame walk used by preflight.
pub fn has_unwalked_graphic_layer_visibility_overrides(&self) -> bool {
self.parents.iter().flat_map(|spread| &spread.items).any(|item| item_layer_options(item, 0, true))
|| self.stories.values().any(|story| story_layer_options(story, 0, true))
}
/// Includes parent items, anchored objects, graphic cells and nested notes.
pub fn has_graphic_layer_visibility_overrides(&self) -> bool {
self.spreads.iter().chain(&self.parents).flat_map(|spread| &spread.items).any(|item| item_layer_options(item, 0, true))
|| self.stories.values().any(|story| story_layer_options(story, 0, true))
}
}
impl Asset {
/// Whether `other` holds the same file: the same type, PDF page and crop, link and bytes. Files at
/// different paths stay apart even with identical bytes, since each can be updated or
@@ -356,6 +422,7 @@ mod tests {
fn graphic_item(id: u64, asset: u64, crop: f64) -> Item {
let mut it = Item::new(ItemId(id), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)));
it.content = Content::Graphic(Graphic {
layer_options: None,
asset: AssetId(asset),
size: (10.0, 10.0),
xf: Affine::translate((crop, 0.0)),
+34
View File
@@ -650,6 +650,34 @@ pub const MAX_TAB_LEADER: usize = 8;
/// Stops closer than this share a position.
pub const SAME_TAB_POSITION: f64 = 0.01;
/// Smallest type size the engine accepts, in points.
pub const MIN_TYPE_SIZE: f64 = 0.1;
/// Largest type size the engine accepts, in points, as InDesign's own field allows.
pub const MAX_TYPE_SIZE: f64 = 1296.0;
/// Largest measure an imported indent, spacing or offset can carry, in points: the furthest tab
/// stop, which is the widest page.
pub const MAX_MEASURE: f64 = MAX_TAB_POSITION;
/// A type size read from a file, or `None` when it is not a size the engine can mean.
///
/// A file can hold any number, including `NaN`, `inf`, `0`, a negative or `1e300`. Every importer
/// had its own spelling of this test, or only checked that the value was finite -- so tagged text
/// took `<cSize:1e300>` and set a 1e300 pt size on the text (#1593). A size outside the range is
/// dropped rather than clamped, so the text keeps the size its style gives it.
pub fn imported_type_size(pt: f64) -> Option<f64> {
(pt.is_finite() && (MIN_TYPE_SIZE..=MAX_TYPE_SIZE).contains(&pt)).then_some(pt)
}
/// An indent, spacing or offset read from a file, clamped to ±[`MAX_MEASURE`]; `None` when the
/// value is not finite.
///
/// Unlike a type size this is clamped rather than dropped: a file that says "indent everything"
/// still indents to the widest page the engine has, where dropping the value would quietly
/// restore the style's own indent and hide what the file asked for (#1593).
pub fn imported_measure(pt: f64) -> Option<f64> {
pt.is_finite().then(|| pt.clamp(-MAX_MEASURE, MAX_MEASURE))
}
impl TabStop {
/// A finite position clamped to `0..=MAX_TAB_POSITION`.
pub fn clamp_position(v: f64) -> Result<f64, String> {
@@ -1274,7 +1302,13 @@ attr_set! {
/// again with the list (an indent the paragraph had before stays).
list_hang: bool = false,
balance_ragged: bool = false,
/// Hang punctuation outside the measure in this paragraph, at each character's own size
/// (DesignCraft's own per-paragraph switch; a story with Optical Margin Alignment on
/// overrides it with the story's size).
optical_margin: bool = false,
/// Ignore Optical Margin: no hang in this paragraph, whatever the story or
/// `optical_margin` say.
ignore_optical_margin: bool = false,
/// None uses the legacy built-in rules; an empty set explicitly disables kinsoku.
kinsoku: Option<crate::cjk::Kinsoku> = None => "deserialize_explicit_value",
/// Preserved spacing-table reference; compatibility audit reports unsupported composition.
+213 -11
View File
@@ -87,7 +87,9 @@ impl Document {
/// The horizontal ruler of spread `r` in pieces `(from x, to x, base)`, spread coordinates,
/// covering every x: one piece, or one per page for a page origin.
pub fn ruler_pieces(&self, r: SpreadRef) -> Vec<(f64, f64, Point)> {
let Some(sp) = self.spread(r) else { return Vec::new() };
let Some(sp) = self.spread(r) else {
return Vec::new();
};
if self.settings.ruler_origin != crate::RulerOrigin::Page || sp.pages.len() < 2 {
return self.ruler_base(r, 0.0).map(|o| (f64::NEG_INFINITY, f64::INFINITY, o)).into_iter().collect();
}
@@ -318,15 +320,16 @@ impl Document {
let pos_to = other.frames.iter().position(|f| *f == to).unwrap_or(0);
let after = other.frames.split_off(pos_to);
let before = std::mem::take(&mut other.frames);
let (vertical, direction) = (other.vertical, other.direction);
let (vertical, direction, optical_margin) = (other.vertical, other.direction, other.optical_margin);
let offset;
{
let st = self.story_mut(fs).ok_or(DocError::NoStory(fs))?;
offset = if st.text.is_empty() { 0 } else { st.text.len().saturating_add(1) };
// The story keeps its own direction, even when it was empty and takes the other's text.
let own = (st.vertical, st.direction);
// The story keeps its own direction and options, even when it was empty and takes the
// other's text.
let own = (st.vertical, st.direction, st.optical_margin);
st.append_story(other);
(st.vertical, st.direction) = own;
(st.vertical, st.direction, st.optical_margin) = own;
let at = st.frames.iter().position(|f| *f == from).map_or(st.frames.len(), |p| p + 1);
for (k, f) in after.iter().enumerate() {
st.frames.insert(at + k, *f);
@@ -345,6 +348,7 @@ impl Document {
ns.frames = before.clone();
ns.vertical = vertical;
ns.direction = direction;
ns.optical_margin = optical_margin;
for f in &before {
if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) {
t.story = nid;
@@ -363,7 +367,9 @@ impl Document {
let from_empty = fs.is_none_or(|fs| self.story(fs).is_some_and(|s| s.text.is_empty() && s.frames == [from]));
let ts = self.frame_story(to)?;
let to_first = ts.and_then(|ts| self.story(ts)).is_some_and(|s| s.frames.first() == Some(&to));
let (Some(ts), true, true) = (ts, from_empty, to_first && from != to) else { return self.thread(from, to) };
let (Some(ts), true, true) = (ts, from_empty, to_first && from != to) else {
return self.thread(from, to);
};
self.check_thread_links(from, to, true)?;
if let Some(fs) = fs {
self.stories.remove(&fs);
@@ -466,7 +472,9 @@ impl Document {
return Err(DocError::Invalid(format!("{id} is locked or on a locked layer")));
}
}
let (Some(fl), Some(tl)) = (self.find(from), self.find(to)) else { return Err(DocError::NoItem(to)) };
let (Some(fl), Some(tl)) = (self.find(from), self.find(to)) else {
return Err(DocError::NoItem(to));
};
let same_place = match (fl.spread, tl.spread) {
(SpreadRef::Doc(_), SpreadRef::Doc(_)) => true,
(a, b) => a == b,
@@ -526,12 +534,12 @@ impl Document {
/// stays with the first part and may become overset).
pub fn unthread_after(&mut self, frame: ItemId) -> Result<()> {
let sid = self.item(frame).and_then(|i| i.text_frame()).map(|t| t.story).ok_or(DocError::NoItem(frame))?;
let (tail, vertical, direction) = {
let (tail, vertical, direction, optical_margin) = {
let st = self.story_mut(sid).ok_or(DocError::NoStory(sid))?;
let p = st.frames.iter().position(|f| *f == frame).ok_or(DocError::NoItem(frame))?;
let tail = st.frames.split_off(p + 1);
st.rev = st.rev.wrapping_add(1);
(tail, st.vertical, st.direction)
(tail, st.vertical, st.direction, st.optical_margin)
};
if tail.is_empty() {
return Ok(());
@@ -541,6 +549,7 @@ impl Document {
ns.frames = tail.clone();
ns.vertical = vertical;
ns.direction = direction;
ns.optical_margin = optical_margin;
self.stories.insert(nid, Arc::new(ns));
for f in tail {
if let Some(t) = self.item_mut(f).and_then(Item::text_frame_mut) {
@@ -554,20 +563,21 @@ impl Document {
/// `frame` stays on the page with a new empty story.
pub fn remove_from_thread(&mut self, frame: ItemId) -> Result<()> {
let sid = self.item(frame).and_then(|i| i.text_frame()).map(|t| t.story).ok_or(DocError::NoItem(frame))?;
let (vertical, direction) = {
let (vertical, direction, optical_margin) = {
let st = self.story_mut(sid).ok_or(DocError::NoStory(sid))?;
if st.frames.len() < 2 {
return Err(DocError::Invalid(format!("frame {frame} is not threaded to another frame")));
}
st.frames.retain(|f| *f != frame);
st.rev = st.rev.wrapping_add(1);
(st.vertical, st.direction)
(st.vertical, st.direction, st.optical_margin)
};
let nid = StoryId(self.alloc());
let mut ns = Story::new(nid);
ns.frames = vec![frame];
ns.vertical = vertical;
ns.direction = direction;
ns.optical_margin = optical_margin;
self.stories.insert(nid, Arc::new(ns));
let t = self.item_mut(frame).and_then(Item::text_frame_mut).ok_or(DocError::NoItem(frame))?;
t.story = nid;
@@ -638,6 +648,45 @@ impl Document {
found
}
/// Every item on document and parent spreads, in groups, and among the objects anchored in
/// stories, including the stories in table cells and in notes: walks that must cover every
/// placement use this, not [`Document::all_items`].
pub fn placed_items(&self) -> Vec<&Item> {
let mut v = Vec::new();
for sp in self.spreads.iter().chain(&self.parents) {
for it in &sp.items {
placed_item(it, 0, &mut v);
}
}
for st in self.stories.values() {
placed_story_items(st, 0, &mut v);
}
v
}
/// Applies `f` to every placed item `hit` selects, wherever it sits: on document or parent
/// spreads, in groups, and anchored in a story (also inside table cells and notes).
/// Returns how many items `f` saw.
pub fn update_placed_items(&mut self, hit: impl Fn(&Item) -> bool, mut f: impl FnMut(&mut Item)) -> usize {
let mut n = 0;
for sp in self.spreads.iter_mut().chain(self.parents.iter_mut()) {
if !sp.items.iter().any(|it| has_hit(it, &hit)) {
continue;
}
for it in Arc::make_mut(sp).items.iter_mut() {
if has_hit(it, &hit) {
update_placed_item(Arc::make_mut(it), 0, &hit, &mut f, &mut n);
}
}
}
for st in self.stories.values_mut() {
if story_has_hit(st, 0, &hit) {
update_story_items(Arc::make_mut(st), 0, &hit, &mut f, &mut n);
}
}
n
}
/// Every item id (pre-order) on document spreads.
pub fn all_items(&self) -> Vec<ItemId> {
let mut v = Vec::new();
@@ -732,6 +781,91 @@ impl Document {
}
}
/// How deep nested stories (a cell's text, a footnote's text, an endnote's text) are followed
/// when every placement is visited. A document nested deeper is left alone.
const MAX_PLACEMENT_DEPTH: usize = 64;
/// The items anchored in `st`, and in the stories nested in its cells and notes, pre-order.
fn placed_story_items<'a>(st: &'a Story, depth: usize, v: &mut Vec<&'a Item>) {
if depth > MAX_PLACEMENT_DEPTH {
return;
}
for o in &st.objects {
placed_item(&o.item, 0, v);
}
for c in st.tables.values().flat_map(|t| &t.cells) {
placed_story_items(&c.text, depth + 1, v);
}
for n in st.notes.iter().chain(&st.endnotes) {
placed_story_items(&n.text, depth + 1, v);
}
}
/// Whether `it` or anything in it is selected by `hit`.
fn has_hit(it: &Item, hit: &impl Fn(&Item) -> bool) -> bool {
fn check(it: &Item, depth: usize, hit: &impl Fn(&Item) -> bool) -> bool {
hit(it) || (depth < MAX_PLACEMENT_DEPTH && it.children().iter().any(|child| check(child, depth + 1, hit)))
}
check(it, 0, hit)
}
/// [`Document::update_placed_items`] for one item: only the branches holding a hit are copied.
fn update_placed_item(it: &mut Item, depth: usize, hit: &impl Fn(&Item) -> bool, f: &mut impl FnMut(&mut Item), n: &mut usize) {
if hit(it) {
f(it);
*n += 1;
}
if depth >= MAX_PLACEMENT_DEPTH {
return;
}
if let Some(children) = it.children_mut() {
for c in children.iter_mut().filter(|c| has_hit(c, hit)) {
update_placed_item(Arc::make_mut(c), depth + 1, hit, f, n);
}
}
}
/// [`Document::update_placed_items`] for the placements of one story, and of the stories nested
/// in its cells and notes.
fn update_story_items(st: &mut Story, depth: usize, hit: &impl Fn(&Item) -> bool, f: &mut impl FnMut(&mut Item), n: &mut usize) {
if depth > MAX_PLACEMENT_DEPTH {
return;
}
for o in st.objects.iter_mut() {
if has_hit(&o.item, hit) {
update_placed_item(&mut Arc::make_mut(o).item, 0, hit, f, n);
}
}
for t in st.tables.values_mut().filter(|t| t.cells.iter().any(|c| story_has_hit(&c.text, depth + 1, hit))) {
for c in &mut Arc::make_mut(t).cells {
if story_has_hit(&c.text, depth + 1, hit) {
update_story_items(&mut c.text, depth + 1, hit, f, n);
}
}
}
for note in st.notes.iter_mut().chain(st.endnotes.iter_mut()) {
if story_has_hit(&note.text, depth + 1, hit) {
update_story_items(&mut Arc::make_mut(note).text, depth + 1, hit, f, n);
}
}
}
fn placed_item<'a>(it: &'a Item, depth: usize, v: &mut Vec<&'a Item>) {
v.push(it);
if depth < MAX_PLACEMENT_DEPTH {
for child in it.children() {
placed_item(child, depth + 1, v);
}
}
}
fn story_has_hit(st: &Story, depth: usize, hit: &impl Fn(&Item) -> bool) -> bool {
depth <= MAX_PLACEMENT_DEPTH
&& (st.objects.iter().any(|o| has_hit(&o.item, hit))
|| st.tables.values().flat_map(|t| &t.cells).any(|c| story_has_hit(&c.text, depth + 1, hit))
|| st.notes.iter().chain(&st.endnotes).any(|n| story_has_hit(&n.text, depth + 1, hit)))
}
/// Does `p` (in the item's parent space) hit `it`?
pub fn item_hit(it: &Item, p: Point, tol: f64) -> bool {
if let Content::Group { items } = &it.content {
@@ -776,3 +910,71 @@ pub fn hit_descendant(it: &Item, p: Point, tol: f64, want: &dyn Fn(&Item) -> boo
/// How far from its path type on a path is hit (points): about a cap height of 12–18 pt type,
/// either side (the type may sit on or under the path).
const PATH_TYPE_REACH: f64 = 12.0;
#[cfg(test)]
mod placed_item_tests {
use super::*;
#[test]
fn exhaustive_placements_keep_unselected_branches_shared() {
let mut d = Document::new(&Default::default());
let layer = d.default_layer();
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 80.0, 80.0), layer, "Body", Default::default()).unwrap();
let id = ItemId(d.alloc());
let mut item = Item::new(id, layer, Shape::Rectangle, Default::default());
item.content = Content::Group { items: vec![Arc::new(Item::new(ItemId(d.alloc()), layer, Shape::Rectangle, Default::default()))] };
let child = item.children()[0].id;
let mut table = crate::Table::new(1, 1, 1, 0, 0, 20.0);
table.cells[0].text.insert_object(0, crate::AnchoredObject { item, position: Default::default() });
d.story_mut(sid).unwrap().insert_table(0, table);
let untouched = StoryId(d.alloc());
d.stories.insert(untouched, Arc::new(Story::new(untouched)));
let snapshot = d.clone();
assert!(d.placed_items().iter().any(|i| i.id == child));
assert_eq!(d.update_placed_items(|i| i.id == child, |i| i.hidden = true), 1);
assert!(d.placed_items().iter().find(|i| i.id == child).unwrap().hidden);
assert!(!snapshot.placed_items().iter().find(|i| i.id == child).unwrap().hidden);
assert!(Arc::ptr_eq(&d.stories[&untouched], &snapshot.stories[&untouched]));
assert!(Arc::ptr_eq(&d.spreads[0], &snapshot.spreads[0]));
let unchanged = d.clone();
let mut calls = 0;
assert_eq!(d.update_placed_items(|_| false, |_| calls += 1), 0);
assert_eq!(calls, 0);
assert!(d.spreads.iter().zip(&unchanged.spreads).all(|(a, b)| Arc::ptr_eq(a, b)));
assert!(d.parents.iter().zip(&unchanged.parents).all(|(a, b)| Arc::ptr_eq(a, b)));
assert!(d.stories.iter().all(|(id, st)| Arc::ptr_eq(st, &unchanged.stories[id])));
}
#[test]
fn exhaustive_placements_bound_nested_stories_and_groups() {
let mut d = Document::new(&Default::default());
let layer = d.default_layer();
let target = ItemId(d.alloc());
let mut item = Item::new(target, layer, Shape::Rectangle, Default::default());
for _ in 0..MAX_PLACEMENT_DEPTH + 2 {
let mut group = Item::new(ItemId(d.alloc()), layer, Shape::Rectangle, Default::default());
group.content = Content::Group { items: vec![Arc::new(item)] };
item = group;
}
d.insert_item(SpreadRef::Doc(0), item, None).unwrap();
assert!(!d.placed_items().iter().any(|i| i.id == target));
let mut calls = 0;
assert_eq!(d.update_placed_items(|i| i.id == target, |_| calls += 1), 0);
assert_eq!(calls, 0);
let nested = ItemId(d.alloc());
let sid = StoryId(d.alloc());
let mut story = Story::new(sid);
story.insert_object(
0,
crate::AnchoredObject { item: Item::new(nested, layer, Shape::Rectangle, Default::default()), position: Default::default() },
);
for _ in 0..MAX_PLACEMENT_DEPTH + 2 {
let mut outer = Story::new(sid);
outer.notes.push(Arc::new(crate::Footnote { id: 1, text: story }));
story = outer;
}
d.stories.insert(sid, Arc::new(story));
assert!(!d.placed_items().iter().any(|i| i.id == nested));
assert_eq!(d.update_placed_items(|i| i.id == nested, |_| calls += 1), 0);
assert_eq!(calls, 0);
}
}
+2 -2
View File
@@ -147,7 +147,7 @@ impl crate::Document {
if st.id == sid {
break;
}
n += st.endnotes.len() as u32;
n = n.saturating_add(st.endnotes.len() as u32);
}
n
}
@@ -173,7 +173,7 @@ impl crate::Document {
let at = out.len();
out.insert_with(at, &note.text.text[r].replace('\n', " "), f.clone());
}
n += 1;
n = n.saturating_add(1);
}
}
out
+129 -11
View File
@@ -458,25 +458,87 @@ pub struct TextFrameOptions {
pub path: Option<PathType>,
}
/// A text frame's custom baseline grid. Saved as `[start, increment]`.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(from = "(f64, f64)", into = "(f64, f64)")]
/// A text frame's custom baseline grid. Saved as `[start, increment]` when it starts from the
/// top of the page, otherwise as `[start, increment, relativeTo]`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FrameBaselineGrid {
/// The first grid line, down from the top of the page.
/// The first grid line, down from the line `relative_to` names.
pub start: f64,
/// The distance between grid lines; 0 or less turns the grid off.
pub increment: f64,
pub relative_to: FrameGridRelative,
}
impl From<(f64, f64)> for FrameBaselineGrid {
fn from((start, increment): (f64, f64)) -> Self {
FrameBaselineGrid { start, increment }
impl FrameBaselineGrid {
/// A grid measured from the top of the page.
pub fn new(start: f64, increment: f64) -> Self {
FrameBaselineGrid { start, increment, relative_to: FrameGridRelative::TopOfPage }
}
}
impl From<FrameBaselineGrid> for (f64, f64) {
fn from(g: FrameBaselineGrid) -> Self {
(g.start, g.increment)
/// What a frame's custom baseline grid's start is measured from.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum FrameGridRelative {
#[default]
#[serde(rename = "page")]
TopOfPage,
#[serde(rename = "margin")]
TopMargin,
#[serde(rename = "frame")]
TopOfFrame,
#[serde(rename = "inset")]
TopInset,
}
impl FrameGridRelative {
pub const ALL: [FrameGridRelative; 4] =
[FrameGridRelative::TopOfPage, FrameGridRelative::TopMargin, FrameGridRelative::TopOfFrame, FrameGridRelative::TopInset];
/// The name commands and native files use: page, margin, frame or inset.
pub fn name(self) -> &'static str {
match self {
FrameGridRelative::TopOfPage => "page",
FrameGridRelative::TopMargin => "margin",
FrameGridRelative::TopOfFrame => "frame",
FrameGridRelative::TopInset => "inset",
}
}
pub fn from_name(name: &str) -> Option<Self> {
Self::ALL.into_iter().find(|r| r.name() == name)
}
}
impl Serialize for FrameBaselineGrid {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
// Files saved before Relative To existed hold two elements; a page-relative grid keeps that form.
match self.relative_to {
FrameGridRelative::TopOfPage => (self.start, self.increment).serialize(s),
r => (self.start, self.increment, r).serialize(s),
}
}
}
impl<'de> Deserialize<'de> for FrameBaselineGrid {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct Grid;
impl<'de> serde::de::Visitor<'de> for Grid {
type Value = FrameBaselineGrid;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("[start, increment] or [start, increment, relativeTo]")
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut a: A) -> Result<Self::Value, A::Error> {
use serde::de::Error;
let start = a.next_element()?.ok_or_else(|| A::Error::invalid_length(0, &self))?;
let increment = a.next_element()?.ok_or_else(|| A::Error::invalid_length(1, &self))?;
let relative_to = a.next_element()?.unwrap_or_default();
if a.next_element::<serde::de::IgnoredAny>()?.is_some() {
return Err(A::Error::invalid_length(4, &self));
}
Ok(FrameBaselineGrid { start, increment, relative_to })
}
}
d.deserialize_seq(Grid)
}
}
@@ -618,10 +680,37 @@ pub enum Fitting {
CenterContent,
}
/// Retained IDML layer choices for one placement, independent of shared asset bytes.
/// Scalar attributes are kept verbatim, including view/export/print state and link-update policy.
/// XML Self IDs are regenerated by the exporter and are not stored here.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GraphicLayerOptions {
pub attributes: std::collections::BTreeMap<String, String>,
pub layers: Vec<std::collections::BTreeMap<String, String>>,
/// Parent indices in pre-order; absent entries are top-level layers (older native files).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub parents: Vec<Option<usize>>,
}
pub const GRAPHIC_LAYER_RENDERING_WARNING: &str =
"Placed graphic layer visibility overrides are preserved but are not applied to rendering or PDF output";
impl GraphicLayerOptions {
pub fn has_visibility_overrides(&self) -> bool {
self.layers.iter().any(|layer| {
layer.get("CurrentVisibility").is_some_and(|v| Some(v) != layer.get("OriginalVisibility"))
|| ["HasViewState", "HasExportState", "HasPrintState"].iter().any(|key| layer.get(*key).is_some_and(|v| v == "true"))
})
}
}
/// A placed graphic inside a frame.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Graphic {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub layer_options: Option<GraphicLayerOptions>,
pub asset: AssetId,
/// Natural size of the graphic in points (pixels at its resolution, or PDF page size).
pub size: (f64, f64),
@@ -1393,6 +1482,10 @@ pub struct Item {
/// Object › Object Layer Options: layers of a placed PDF that are hidden.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub pdf_hidden_layers: Vec<String>,
/// Explicit placed-PDF visibility, including an all-on override with no hidden names.
/// False with an empty hidden list retains the file's own settings (legacy behavior).
#[serde(default, skip_serializing_if = "is_false")]
pub pdf_layer_override: bool,
/// Window › Interactive › Media: options of a placed video or sound.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub media: Option<MediaOptions>,
@@ -1425,7 +1518,15 @@ fn one() -> f32 {
1.0
}
fn is_false(value: &bool) -> bool {
!*value
}
impl Item {
pub fn has_pdf_layer_override(&self) -> bool {
self.pdf_layer_override || !self.pdf_hidden_layers.is_empty()
}
pub fn new(id: ItemId, layer: LayerId, shape: Shape, path: PathData) -> Self {
Item {
id,
@@ -1452,6 +1553,7 @@ impl Item {
xml_tag: String::new(),
button: None,
pdf_hidden_layers: Vec::new(),
pdf_layer_override: false,
live_caption: None,
form_field: None,
export_options: ExportOptions::default(),
@@ -1664,6 +1766,7 @@ mod tests {
#[test]
fn graphics_without_a_clipping_path_load_and_save_as_before() {
let g = Graphic {
layer_options: None,
asset: AssetId(3),
size: (40.0, 30.0),
xf: Affine::translate((5.0, 5.0)),
@@ -1675,6 +1778,7 @@ mod tests {
fill: Default::default(),
};
let v = serde_json::to_value(&g).unwrap();
assert!(v.get("layerOptions").is_none(), "legacy graphics keep their existing representation");
assert!(v.get("clip").is_none(), "{v}");
assert_eq!(serde_json::from_value::<Graphic>(v.clone()).unwrap(), g);
// A clip written with only its type and path gets the default options.
@@ -1686,10 +1790,24 @@ mod tests {
#[test]
fn frame_baseline_grid_saves_as_start_then_increment() {
// A file saved before Relative To existed: the grid starts from the top of the page and
// saves back unchanged.
let v = serde_json::json!({"baselineGrid": [6.0, 14.0]});
let o = serde_json::from_value::<TextFrameOptions>(v).unwrap();
assert_eq!(o.baseline_grid, Some(FrameBaselineGrid { start: 6.0, increment: 14.0 }));
assert_eq!(o.baseline_grid, Some(FrameBaselineGrid { start: 6.0, increment: 14.0, relative_to: FrameGridRelative::TopOfPage }));
assert_eq!(serde_json::to_value(&o).unwrap()["baselineGrid"], serde_json::json!([6.0, 14.0]));
for r in [FrameGridRelative::TopMargin, FrameGridRelative::TopOfFrame, FrameGridRelative::TopInset] {
let o = TextFrameOptions {
baseline_grid: Some(FrameBaselineGrid { relative_to: r, ..FrameBaselineGrid::new(6.0, 14.0) }),
..Default::default()
};
let v = serde_json::to_value(&o).unwrap();
assert_eq!(v["baselineGrid"], serde_json::json!([6.0, 14.0, r.name()]));
assert_eq!(serde_json::from_value::<TextFrameOptions>(v).unwrap().baseline_grid, o.baseline_grid);
}
for bad in [serde_json::json!([6.0]), serde_json::json!([6.0, 14.0, "frame", 1]), serde_json::json!([6.0, 14.0, "spine"])] {
assert!(serde_json::from_value::<TextFrameOptions>(serde_json::json!({"baselineGrid": bad})).is_err());
}
}
#[test]
+24
View File
@@ -343,6 +343,26 @@ pub enum ChapterSource {
SameAsPrevious,
}
/// EPUB Export Options › Split Document: where a reflowable EPUB starts a new content file.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum EpubSplit {
/// One content file.
#[default]
Off,
/// Single Paragraph Style: a file starts before each paragraph of the named style.
Style(String),
/// Based on Paragraph Style Export Tags: a file starts before each paragraph of a style whose
/// Export Tagging has Split Document on.
ExportTags,
}
impl EpubSplit {
pub fn is_off(&self) -> bool {
*self == EpubSplit::Off
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct DocSettings {
@@ -417,6 +437,9 @@ pub struct DocSettings {
/// The fill and stroke new shapes are drawn with: what the Tools panel's proxy shows and sets
/// with nothing selected (InDesign's document defaults).
pub new_object: NewObjectColors,
/// EPUB Export Options › Split Document.
#[serde(skip_serializing_if = "EpubSplit::is_off")]
pub epub_split: EpubSplit,
}
fn is_true(b: &bool) -> bool {
@@ -482,6 +505,7 @@ impl Default for DocSettings {
zero_point_locked: false,
column_guides_locked: true,
new_object: NewObjectColors::default(),
epub_split: EpubSplit::Off,
}
}
}
+1 -1
View File
@@ -262,7 +262,7 @@ impl crate::Document {
let Some(me) = self.story(sid) else { return o.start_at };
let mine = key(me);
let before: usize = self.stories.values().filter(|st| st.id != sid && !st.notes.is_empty() && key(st) < mine).map(|st| st.notes.len()).sum();
o.start_at + before as u32
o.start_at.saturating_add(before as u32)
}
/// Absolute page an item sits on (its center), None on parent pages / pasteboard-only spreads.
+59
View File
@@ -125,6 +125,9 @@ pub struct Story {
/// right to left, in every frame of the thread.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub vertical: bool,
/// Story ▸ Optical Margin Alignment.
#[serde(default, skip_serializing_if = "OpticalMargin::is_default")]
pub optical_margin: OpticalMargin,
/// Bumped on every edit (composition cache key).
#[serde(default)]
pub rev: u64,
@@ -160,6 +163,45 @@ pub struct Story {
pub file_link: Option<TextFileLink>,
}
/// Story ▸ Optical Margin Alignment: punctuation and the edges of some letters hang outside the
/// frame's edges, in every paragraph of the story except those with Ignore Optical Margin.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct OpticalMargin {
pub on: bool,
/// The type size (points) the hang amounts are set for: text of this size hangs by the
/// built-in proportions of each character's advance, and the hang grows with this size.
pub size: f64,
}
impl Default for OpticalMargin {
fn default() -> Self {
OpticalMargin { on: false, size: Self::DEFAULT_SIZE }
}
}
impl OpticalMargin {
pub const DEFAULT_SIZE: f64 = 12.0;
/// InDesign's range for the size.
pub const MIN_SIZE: f64 = 0.1;
pub const MAX_SIZE: f64 = 1296.0;
/// `v` clamped to the size range, or `None` when it isn't a finite number.
pub fn clamp_size(v: f64) -> Option<f64> {
v.is_finite().then(|| v.clamp(Self::MIN_SIZE, Self::MAX_SIZE))
}
/// The size the story's text hangs for, when the setting is on (a stored size outside the
/// range is clamped, a non-finite one reads as the default).
pub fn hang_size(&self) -> Option<f64> {
self.on.then(|| Self::clamp_size(self.size).unwrap_or(Self::DEFAULT_SIZE))
}
pub fn is_default(&self) -> bool {
*self == Self::default()
}
}
/// A linked text file: where a placed story came from, what the file held, and the Word Import
/// Options that brought it in, so Update Link can import it again the same way.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
@@ -201,6 +243,7 @@ impl Story {
frames: vec![],
direction: crate::TextDirection::LeftToRight,
vertical: false,
optical_margin: OpticalMargin::default(),
rev: 0,
tables: BTreeMap::new(),
notes: Vec::new(),
@@ -596,6 +639,22 @@ mod tests {
Story::with_text(StoryId(1), t, ParaFormat::default())
}
#[test]
fn optical_margin_is_optional_in_saved_stories() {
let plain = serde_json::to_value(st("Text")).unwrap();
assert!(plain.get("opticalMargin").is_none());
let old: Story = serde_json::from_value(plain).unwrap();
assert_eq!(old.optical_margin, OpticalMargin { on: false, size: 12.0 });
let mut s = st("Text");
s.optical_margin = OpticalMargin { on: true, size: 18.0 };
let back: Story = serde_json::from_value(serde_json::to_value(&s).unwrap()).unwrap();
assert_eq!(back.optical_margin, s.optical_margin);
// A hostile stored size is clamped where it is used.
assert_eq!(OpticalMargin { on: true, size: 1e9 }.hang_size(), Some(OpticalMargin::MAX_SIZE));
assert_eq!(OpticalMargin { on: true, size: -3.0 }.hang_size(), Some(OpticalMargin::MIN_SIZE));
assert_eq!(OpticalMargin { on: false, size: 18.0 }.hang_size(), None);
}
#[test]
fn paragraphs_track_newlines() {
let mut s = st("one\ntwo");
+4
View File
@@ -113,6 +113,10 @@ pub struct ExportTag {
pub tag: String,
/// CSS class (empty = from the style name).
pub class: String,
/// Split Document (EPUB only): an EPUB content file starts before each paragraph of the
/// style, when the document's EPUB split is based on export tags (`EpubSplit::ExportTags`).
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub split: bool,
}
impl Styles {
+1
View File
@@ -369,6 +369,7 @@ fn place_image(
};
let mode = fitting_of(&options.fitting);
let mut g = Graphic {
layer_options: None,
asset: AssetId(0),
size: (nw, nh),
xf: Affine::translate((inner.x0, inner.y0)),
+220 -2
View File
@@ -2,11 +2,11 @@
use std::sync::Arc;
use designcraft_doc::{EditorialNote, NOTE_MARK, StoryId};
use designcraft_doc::{ANCHOR_MARK, ENDNOTE_REF, EditorialNote, FOOTNOTE_REF, INDEX_MARK, NOTE_MARK, OBJECT_MARK, StoryId, TABLE_ANCHOR, XREF_MARK};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, has_text, ok, str_param};
use crate::Result;
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -37,6 +37,15 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"story": ts.story.0, "id": id}))
})
}),
cmd!(
"note.convertFromText",
"Convert to Note",
["Type", "Notes"],
None,
"{author?} → {story, id}. The selected text moves into a new note anchored where it was.",
has_text_range,
convert_from_text
),
cmd!("note.edit", "Edit Note", [], None, "{story, id, text?, author?}", has_doc, |s, p| {
let (sid, id) = target(p)?;
let (text, author) = (str_param(p, "text").map(|_| super::text_param(p, "text")), str_param(p, "author").map(str::to_string));
@@ -93,9 +102,76 @@ pub fn specs() -> Vec<CommandSpec> {
}
Ok(Value::Array(out))
}),
cmd!(
noundo "note.previous",
"Previous Note",
["Type", "Notes"],
None,
"{} → {story, id, pos}. The caret moves after the anchor of the story's previous note.",
has_previous,
|s, _| go_to(s, false)
),
cmd!(
noundo "note.next",
"Next Note",
["Type", "Notes"],
None,
"{} → {story, id, pos}. The caret moves after the anchor of the story's next note.",
has_next,
|s, _| go_to(s, true)
),
]
}
/// Characters whose payload lives outside the story text: a note made from text that holds one
/// would lose it.
const MARKS: [char; 8] = [NOTE_MARK, ENDNOTE_REF, FOOTNOTE_REF, OBJECT_MARK, INDEX_MARK, ANCHOR_MARK, XREF_MARK, TABLE_ANCHOR];
fn has_text_range(s: &Session) -> std::result::Result<(), String> {
has_text(s)?;
match s.active().and_then(|d| d.selection.text) {
Some(t) if t.cell.is_some() => Err("notes go in story text".into()),
Some(t) if t.is_caret() => Err("select the text to convert".into()),
_ => Ok(()),
}
}
/// Type › Notes › Convert to Note: the selected text leaves the story and becomes the text of a
/// new note, whose anchor takes the text's place.
fn convert_from_text(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "note.convertFromText";
let ts = s.doc()?.selection.text.ok_or_else(|| bad(ID, "select the text to convert"))?;
if ts.cell.is_some() {
return Err(bad(ID, "notes go in story text"));
}
let r = ts.range();
if r.is_empty() {
return Err(bad(ID, "select the text to convert"));
}
let author = str_param(p, "author").unwrap_or("").to_string();
s.edit(|d, sel| {
let story = d.story_mut(ts.story).ok_or_else(|| bad(ID, "no such story"))?;
let text = story.text.get(r.clone()).ok_or_else(|| bad(ID, "the selection is outside the story"))?.to_string();
if text.contains(MARKS) {
return Err(bad(ID, "the selection holds a table, an anchored object, a note, a footnote, an endnote or a marker"));
}
let fmt = story.format_after(r.start).clone();
story.delete(r.clone());
story.insert_with(r.start, &NOTE_MARK.to_string(), fmt);
story.fix_marks();
let k = story.text[..r.start].matches(NOTE_MARK).count();
let n = story.editorial.get_mut(k).ok_or_else(|| bad(ID, "the story's notes don't match their anchors"))?;
let n = Arc::make_mut(n);
(n.text, n.author) = (text, author);
let id = n.id;
if let Some(t) = &mut sel.text {
let c = r.start + NOTE_MARK.len_utf8();
(t.anchor, t.focus) = (c, c);
}
Ok(json!({"story": ts.story.0, "id": id}))
})
}
fn target(p: &Value) -> Result<(StoryId, u64)> {
let sid = p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("note", "`story` required"))?;
let id = p.get("id").and_then(Value::as_u64).ok_or_else(|| bad("note", "`id` required"))?;
@@ -108,6 +184,53 @@ fn anchor(st: &designcraft_doc::Story, id: u64) -> Option<usize> {
st.text.match_indices(NOTE_MARK).nth(k).map(|(i, _)| i)
}
/// The anchor offset and id of the note before or after the selection `t` in its story. Going
/// back skips the note whose anchor the caret directly follows, so repeated steps reach every note.
fn neighbor(st: &designcraft_doc::Story, t: &designcraft_doc::TextSel, forward: bool) -> Option<(usize, u64)> {
let r = t.range();
let mut anchors = st.text.match_indices(NOTE_MARK).enumerate().map(|(k, (i, _))| (k, i));
let (k, at) = if forward { anchors.find(|&(_, i)| i >= r.end) } else { anchors.filter(|&(_, i)| i + NOTE_MARK.len_utf8() < r.start).last() }?;
Some((at, st.editorial.get(k)?.id))
}
fn has_note(s: &crate::Session, forward: bool) -> std::result::Result<(), String> {
has_text(s)?;
match s.active().and_then(|d| Some((d, d.selection.text?))) {
Some((_, t)) if t.cell.is_some() => Err("notes go in story text".into()),
Some((d, t)) if d.doc.story(t.story).and_then(|st| neighbor(st, &t, forward)).is_some() => Ok(()),
_ => Err(no_note(forward).into()),
}
}
fn has_previous(s: &crate::Session) -> std::result::Result<(), String> {
has_note(s, false)
}
fn has_next(s: &crate::Session) -> std::result::Result<(), String> {
has_note(s, true)
}
fn no_note(forward: bool) -> &'static str {
if forward { "no later note in this story" } else { "no earlier note in this story" }
}
/// Type › Notes › Previous Note and Next Note: from the insertion point, the caret moves after
/// the anchor of the story's previous or next note.
fn go_to(s: &mut crate::Session, forward: bool) -> Result<Value> {
let id = if forward { "note.next" } else { "note.previous" };
let st = s.doc_mut()?;
let t = st.selection.text.ok_or_else(|| bad(id, "place the insertion point in text"))?;
if t.cell.is_some() {
return Err(bad(id, "notes go in story text"));
}
let (at, note) = st.doc.story(t.story).and_then(|x| neighbor(x, &t, forward)).ok_or_else(|| bad(id, no_note(forward)))?;
let pos = at + NOTE_MARK.len_utf8();
st.selection.text = Some(designcraft_doc::TextSel::caret(t.story, pos));
st.bump_revision();
s.typing_format = None;
Ok(json!({"story": t.story.0, "id": note, "pos": pos}))
}
#[cfg(test)]
mod tests {
use serde_json::json;
@@ -136,6 +259,46 @@ mod tests {
assert!(s.doc().unwrap().doc.stories[&sid].editorial.is_empty());
}
#[test]
fn previous_and_next_note_step_through_the_story_s_notes() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "One two three."})).unwrap();
let len = designcraft_doc::NOTE_MARK.len_utf8();
let caret = |s: &mut Session, at: usize| s.execute("text.select", &json!({"story": r["story"], "anchor": at, "focus": at})).unwrap();
caret(&mut s, 3);
let first = s.execute("note.new", &json!({"text": "a"})).unwrap();
caret(&mut s, 7 + len);
let second = s.execute("note.new", &json!({"text": "b"})).unwrap();
caret(&mut s, 0);
let disabled = |s: &mut Session, id: &str| matches!(s.execute(id, &json!({})), Err(crate::EngineError::Disabled(..)));
assert!(disabled(&mut s, "note.previous"), "no note before the start");
let n = s.execute("note.next", &json!({})).unwrap();
assert_eq!((n["id"].clone(), n["pos"].clone()), (first["id"].clone(), json!(3 + len)));
let n = s.execute("note.next", &json!({})).unwrap();
assert_eq!((n["id"].clone(), n["pos"].clone()), (second["id"].clone(), json!(7 + 2 * len)));
let t = s.doc().unwrap().selection.text.unwrap();
assert_eq!((t.anchor, t.focus), (7 + 2 * len, 7 + 2 * len), "the caret follows the anchor");
assert!(disabled(&mut s, "note.next"), "no note after the last");
// From just after the second anchor, Previous Note goes to the first one.
let n = s.execute("note.previous", &json!({})).unwrap();
assert_eq!(n["id"], first["id"]);
assert!(disabled(&mut s, "note.previous"));
// From the middle of the story, each direction finds its neighbor.
caret(&mut s, 5 + len);
assert_eq!(s.execute("note.previous", &json!({})).unwrap()["id"], first["id"]);
caret(&mut s, 5 + len);
assert_eq!(s.execute("note.next", &json!({})).unwrap()["id"], second["id"]);
// Formatting set at the insertion point does not survive the move.
s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap();
assert!(s.typing_format.is_some());
s.execute("note.previous", &json!({})).unwrap();
assert!(s.typing_format.is_none(), "note.previous");
s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap();
s.execute("note.next", &json!({})).unwrap();
assert!(s.typing_format.is_none(), "note.next");
}
#[test]
fn notes_insert_into_a_story_whose_markers_lack_payloads() {
let mut s = Session::new();
@@ -159,4 +322,59 @@ mod tests {
assert_eq!(st.endnotes[1].text.text, "An endnote.");
assert_eq!(st.notes[1].text.text, "A footnote.");
}
#[test]
fn selected_text_converts_to_a_note_at_its_place() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "One two\nthree four."})).unwrap();
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
let mark = designcraft_doc::NOTE_MARK.len_utf8();
s.execute("text.select", &json!({"story": r["story"], "anchor": 3, "focus": 3})).unwrap();
s.execute("note.new", &json!({"text": "earlier"})).unwrap();
// "two\nthree " across the paragraph return, selected backwards.
let (a, b) = (3 + mark + 1, 3 + mark + 1 + "two\nthree ".len());
s.execute("text.select", &json!({"story": r["story"], "anchor": b, "focus": a})).unwrap();
let n = s.execute("note.convertFromText", &json!({"author": "Ed"})).unwrap();
let st = &s.doc().unwrap().doc.stories[&sid];
st.check().unwrap();
assert_eq!(st.text, format!("One{m} {m}four.", m = designcraft_doc::NOTE_MARK));
assert_eq!(st.paras.len(), 1, "the paragraph return went into the note");
let list = s.execute("note.list", &json!({})).unwrap();
assert_eq!(list[0]["text"], "earlier");
assert_eq!((list[1]["id"].clone(), list[1]["at"].clone()), (n["id"].clone(), json!(a)));
assert_eq!((list[1]["text"].as_str(), list[1]["author"].as_str()), (Some("two\nthree "), Some("Ed")));
let caret = s.doc().unwrap().selection.text.map(|t| (t.anchor, t.focus));
assert_eq!(caret, Some((a + mark, a + mark)), "the insertion point follows the anchor");
s.execute("note.convertToText", &json!({"story": n["story"], "id": n["id"]})).unwrap();
assert_eq!(s.doc().unwrap().doc.stories[&sid].text, format!("One{} two\nthree four.", designcraft_doc::NOTE_MARK));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
let st = &s.doc().unwrap().doc.stories[&sid];
assert_eq!((st.text.as_str(), st.editorial.len()), (format!("One{} two\nthree four.", designcraft_doc::NOTE_MARK).as_str(), 1));
}
#[test]
fn convert_to_note_needs_selected_story_text_without_objects() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Draft copy."})).unwrap();
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
s.execute("text.select", &json!({"story": r["story"], "anchor": 5, "focus": 5})).unwrap();
let disabled = |r| matches!(r, Err(crate::EngineError::Disabled(..)));
assert!(disabled(s.execute("note.convertFromText", &json!({}))), "an insertion point selects no text");
s.execute("note.new", &json!({"text": "keep"})).unwrap();
let before = s.doc().unwrap().doc.stories[&sid].clone();
let all = before.len();
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": all})).unwrap();
let refused = s.execute("note.convertFromText", &json!({}));
assert!(matches!(refused, Err(crate::EngineError::BadParams { .. })), "a note inside the selection would be lost");
assert_eq!(s.doc().unwrap().doc.stories[&sid], before);
s.execute("text.select", &json!({"story": r["story"], "anchor": all, "focus": all})).unwrap();
s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap();
s.execute("text.insert", &json!({"text": "cell", "raw": true})).unwrap();
s.execute("text.move", &json!({"dir": "left", "extend": true})).unwrap();
assert!(s.doc().unwrap().selection.text.is_some_and(|t| t.cell.is_some() && !t.is_caret()));
assert!(disabled(s.execute("note.convertFromText", &json!({}))), "notes go in story text");
}
}
+1 -1
View File
@@ -50,7 +50,7 @@ pub fn specs() -> Vec<CommandSpec> {
for st in d.stories.values().filter(|st| !st.endnotes.is_empty()) {
let first = d.endnote_start(st.id);
for (k, n) in st.endnotes.iter().enumerate() {
out.push(json!({"story": st.id.0, "id": n.id, "number": first + k as u32, "text": n.text.text}));
out.push(json!({"story": st.id.0, "id": n.id, "number": first.saturating_add(k as u32), "text": n.text.text}));
}
}
out.sort_by_key(|v| v["number"].as_u64());
+312 -25
View File
@@ -13,8 +13,30 @@ pub fn specs() -> Vec<CommandSpec> {
"{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), flatten?: high|medium|low|ppi (Transparency Flattener: spreads with transparency are rasterised), spreads?: bool, fullScreen?: bool, bookmarksPanel?: bool, pageLayout?: single|continuous|twoUp|twoUpCover|twoUpContinuous, view?: fitPage|fitWidth|actual, advanceSeconds?: number (interactive PDF), bleed?: bool (document bleed), marks?: bool | {crop?, bleed?, pageInfo?, weight?, offset?}, standard?: \"none\"|\"x4\"|\"a2b\", compressImages?: bool, tagged?: bool (structure tree: stories as paragraphs, figures with alt text), media?: bool (interactive: embed placed video and sound), title?, author?, created?: int (creation date in Unix seconds; default now, for reproducible files)} → {path, bytes, pages, warnings} (no path: {base64, …})",
has_doc, export_pdf),
cmd!(noundo "file.exportEpub", "Export EPUB (Reflowable)…", ["File"], None,
"{path?, title?, author?, language?: \"en\", identifier?: str (dc:identifier; a stable urn:uuid by default), coverImage?: path (a PNG or JPEG as the cover), tocTitle?: str (the table of contents' title; from the language by default), cover?: bool (the first page as the cover image), embedFonts?: bool (package the stylesheet's fonts whose licence allows it, whole files), fixedLayout?: bool (pre-paginated: each page as an image with its text)} → {path, bytes, warnings} (no path: {base64, bytes, warnings}); warnings name images without alt text, exported as decorative, and fonts that could not be embedded",
"{path?, title?, author?, language?: \"en\", identifier?: str (dc:identifier; a stable urn:uuid by default), coverImage?: path (a PNG or JPEG as the cover), tocTitle?: str (the table of contents' title; from the language by default), cover?: bool (the first page as the cover image), embedFonts?: bool (package the stylesheet's fonts whose licence allows it, whole files), split?: \"off\"|\"exportTags\"|{style: name} (Split Document for this export, not stored; the document's `document.epubSplit` by default), fixedLayout?: bool (pre-paginated: each page as an image with its text)} → {path, bytes, warnings} (no path: {base64, bytes, warnings}); warnings name images without alt text, exported as decorative, and fonts that could not be embedded",
has_doc, export_epub),
cmd!(
"document.epubSplit",
"EPUB Split Document",
[],
None,
"{split?: \"off\"|\"exportTags\"|{style: name}} — EPUB Export Options › Split Document, stored with the document: one content file (off, the default), a file before each paragraph whose style has Export Tagging › Split Document on (exportTags), or before each paragraph of one style → {split}",
has_doc,
|s, p| {
const ID: &str = "document.epubSplit";
let cur = s.doc()?.doc.settings.epub_split.clone();
let Some(v) = p.get("split") else { return Ok(json!({"split": cur})) };
let split = epub_split_param(ID, &s.doc()?.doc, v)?;
if split != cur {
let stored = split.clone();
s.edit(move |d, _| {
d.settings.epub_split = stored;
Ok(Value::Null)
})?;
}
Ok(json!({"split": split}))
}
),
cmd!(noundo "file.exportFixedEpub", "Export EPUB (Fixed Layout)…", ["File"], None,
"{path?, title?, author?, language?} — pre-paginated EPUB → {path, bytes} (no path: {base64, bytes})",
has_doc, |s, p| {
@@ -40,7 +62,9 @@ fn print_booklet(s: &mut Session, p: &Value) -> Result<Value> {
let kind = match str_param(p, "type").unwrap_or("saddleStitch") {
"saddleStitch" => designcraft_pdf::BookletKind::SaddleStitch,
"twoUpConsecutive" | "twoUp" => designcraft_pdf::BookletKind::TwoUpConsecutive,
t => return Err(bad(ID, format!("unknown type `{t}` (saddleStitch, twoUpConsecutive)"))),
t => {
return Err(bad(ID, format!("unknown type `{t}` (saddleStitch, twoUpConsecutive)")));
}
};
let opts = designcraft_pdf::BookletOptions { kind, space_between: p.get("spaceBetween").and_then(Value::as_f64).unwrap_or(0.0), title: None };
let st = s.doc()?;
@@ -62,9 +86,13 @@ fn eps_previews(d: &designcraft_doc::Document) -> (Cow<'_, designcraft_doc::Docu
// in the document: unused or hidden graphics must not prevent an export.
let mut used = std::collections::BTreeSet::new();
for block in designcraft_epub::reading_order(d) {
let designcraft_epub::Block::Image(id) = block else { continue };
let designcraft_epub::Block::Image(id) = block else {
continue;
};
let Some(it) = d.item(id) else { continue };
let designcraft_doc::Content::Graphic(g) = &it.content else { continue };
let designcraft_doc::Content::Graphic(g) = &it.content else {
continue;
};
used.insert(g.asset);
if d.assets.get(&g.asset).is_some_and(|a| designcraft_doc::media_kind(&a.mime).is_some())
&& let Some(poster) = it.media.as_ref().and_then(|m| m.poster)
@@ -75,7 +103,9 @@ fn eps_previews(d: &designcraft_doc::Document) -> (Cow<'_, designcraft_doc::Docu
let mut out = Cow::Borrowed(d);
let mut warnings = Vec::new();
for id in used {
let Some(a) = d.assets.get(&id).filter(|a| designcraft_images::is_eps(&a.data)) else { continue };
let Some(a) = d.assets.get(&id).filter(|a| designcraft_images::is_eps(&a.data)) else {
continue;
};
let png = designcraft_images::eps_png(&a.data);
if png.is_none() {
warnings.push(format!("{}: unusable EPS preview; graphic omitted", a.name));
@@ -242,8 +272,22 @@ fn embedded_fonts(d: &designcraft_doc::Document, warnings: &mut Vec<String>) ->
out
}
/// A Split Document setting (`"off"`, `"exportTags"` or `{"style": name}`), naming a paragraph
/// style the document has.
pub(crate) fn epub_split_param(id: &str, doc: &designcraft_doc::Document, v: &Value) -> Result<designcraft_doc::EpubSplit> {
let split: designcraft_doc::EpubSplit =
serde_json::from_value(v.clone()).map_err(|_| bad(id, "`split`: \"off\", \"exportTags\" or {\"style\": name}"))?;
if let designcraft_doc::EpubSplit::Style(name) = &split
&& doc.styles.para(name).is_none()
{
return Err(bad(id, format!("no paragraph style `{name}`")));
}
Ok(split)
}
fn export_epub(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let split = p.get("split").map(|v| epub_split_param("file.exportEpub", &st.doc, v)).transpose()?;
// Object Export Options › Rasterize: those objects go in as images.
let (raster, _) = rasterize_where(&st.doc, &s.cache, 144.0, false, |it| it.export_options.rasterize && !threaded(&st.doc, it));
let epub_doc: &designcraft_doc::Document = raster.as_ref().unwrap_or(&st.doc);
@@ -270,6 +314,7 @@ fn export_epub(s: &mut Session, p: &Value) -> Result<Value> {
identifier: p.get("identifier").and_then(Value::as_str).map(str::to_string),
cover,
toc_title: p.get("tocTitle").and_then(Value::as_str).map(str::to_string),
split,
generic_families: generic_families(&st.doc),
fonts: if p.get("embedFonts").and_then(Value::as_bool).unwrap_or(false) && !fixed {
embedded_fonts(&st.doc, &mut warnings)
@@ -286,9 +331,13 @@ fn export_epub(s: &mut Session, p: &Value) -> Result<Value> {
let d = &st.doc;
let mut pages = Vec::new();
for i in 0..d.page_count() {
let Some((si, pi)) = d.page_loc(i) else { continue };
let Some((si, pi)) = d.page_loc(i) else {
continue;
};
let b = d.spreads[si].pages[pi].bounds();
let Some(img) = rr.render_page(d, &s.cache, i, 2.0, false, &ropts) else { continue };
let Some(img) = rr.render_page(d, &s.cache, i, 2.0, false, &ropts) else {
continue;
};
pages.push(designcraft_epub::FixedPage { png: img.to_png(), width: b.width(), height: b.height(), text: page_text(d, &s.cache, i) });
}
let bytes = designcraft_epub::export_fixed_epub(d, &pages, &opts).map_err(|e| crate::EngineError::Other(e.to_string()))?;
@@ -434,6 +483,7 @@ fn flatten(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache, pp
);
let mut it = Item::new(ItemId(out.alloc()), layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(r));
it.content = Content::Graphic(Graphic {
layer_options: None,
asset: aid,
size: (w as f64, h as f64),
xf: designcraft_geom::Affine::translate((r.x0, r.y0))
@@ -502,7 +552,9 @@ fn flatten(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache, pp
);
let new = image_item(&mut out, reach, img, it.layer);
let osp = std::sync::Arc::make_mut(&mut out.spreads[si]);
let Some(pos) = osp.items.iter().position(|x| x.id == it.id) else { continue };
let Some(pos) = osp.items.iter().position(|x| x.id == it.id) else {
continue;
};
if threaded(d, it) {
// Keep the frame (its story flows on) under the image.
osp.items.insert(pos + 1, std::sync::Arc::new(new));
@@ -604,6 +656,7 @@ fn rasterize_where(
);
let mut img_item = Item::new(ItemId(out.alloc()), it.layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(reach));
img_item.content = Content::Graphic(Graphic {
layer_options: None,
asset: aid,
size: (w as f64, h as f64),
xf: designcraft_geom::Affine::translate((reach.x0, reach.y0)) * designcraft_geom::Affine::scale(1.0 / k),
@@ -669,26 +722,33 @@ fn wrap_stand_in(it: &designcraft_doc::Item, depth: usize) -> designcraft_doc::I
}
/// A copy of `d` where placed PDFs with Object Layer Options hidden layers are 300 ppi images.
///
/// Parent-page and anchored placements count too, including anchors inside table-cell,
/// footnote and endnote text. Only the export copy is rewritten.
fn rasterize_layered(d: &designcraft_doc::Document) -> Result<(Option<designcraft_doc::Document>, usize)> {
let ids: Vec<designcraft_doc::ItemId> =
d.all_items().into_iter().filter(|id| d.item(*id).is_some_and(|it| !it.pdf_hidden_layers.is_empty() && it.graphic().is_some())).collect();
if ids.is_empty() {
let choices: Vec<_> = d
.placed_items()
.into_iter()
.filter(|it| it.has_pdf_layer_override())
.filter_map(|it| {
let g = it.graphic()?;
let a = d.assets.get(&g.asset).filter(|a| designcraft_render::is_pdf(&a.data))?;
Some((it.id, g.clone(), a.clone(), it.pdf_hidden_layers.clone()))
})
.collect();
if choices.is_empty() {
return Ok((None, 0));
}
let mut out = d.clone();
let mut images = std::collections::HashMap::new();
let mut n = 0;
for id in ids {
let Some((g, hidden)) = out.item(id).and_then(|it| Some((it.graphic()?.clone(), it.pdf_hidden_layers.clone()))) else { continue };
let Some(a) = out.assets.get(&g.asset).cloned() else { continue };
if !designcraft_render::is_pdf(&a.data) {
continue;
}
for (id, g, a, hidden) in choices {
// The whole page at 300 ppi (the graphic may show only a box of it).
let shown = designcraft_render::shown_box(&a);
let [l, t, r, b] = shown.unwrap_or([0.0, 0.0, 1.0, 1.0]);
let page_side = (g.size.0 / (r - l)).max(g.size.1 / (b - t));
let side = (page_side * 300.0 / 72.0).clamp(64.0, 8000.0) as u32;
let png = designcraft_render::pdf_page_png_checked(&a.data, a.page as usize, side, &hidden, shown)
let png = designcraft_render::pdf_page_png_options_checked(&a.data, a.page as usize, side, Some(&hidden), shown)
.map_err(|e| EngineError::Other(format!("{}: {e}", a.name)))?;
let px = designcraft_render::image_size(&png);
let aid = designcraft_doc::AssetId(out.alloc());
@@ -703,11 +763,19 @@ fn rasterize_layered(d: &designcraft_doc::Document) -> Result<(Option<designcraf
..Default::default()
}),
);
if let Some(designcraft_doc::Content::Graphic(gg)) = out.item_mut(id).map(|it| &mut it.content) {
gg.asset = aid;
}
images.insert(id, aid);
n += 1;
}
out.update_placed_items(
|it| images.contains_key(&it.id),
|it| {
if let designcraft_doc::Content::Graphic(gg) = &mut it.content
&& let Some(aid) = images.get(&it.id)
{
gg.asset = *aid;
}
},
);
Ok((Some(out), n))
}
@@ -733,9 +801,8 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
),
};
let flattened = ppi.map(|ppi| flatten(&st.doc, &s.cache, ppi));
// Placed PDFs with hidden layers go out as images showing just their visible layers.
let base: &designcraft_doc::Document = flattened.as_ref().map_or(&st.doc, |f| &f.0);
let (layered, layer_count) = rasterize_layered(base)?;
let (layered, layer_count) = if opts.standard == Standard::PdfA2b { rasterize_layered(base)? } else { (None, 0) };
let base2: &designcraft_doc::Document = layered.as_ref().unwrap_or(base);
let (fx_doc, fx_count) = rasterize_effects(base2, &s.cache);
let doc: &designcraft_doc::Document = fx_doc.as_ref().unwrap_or(base2);
@@ -743,6 +810,10 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
let doc: &designcraft_doc::Document = &boxed;
let r = designcraft_pdf::export_pdf_with_report(doc, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
let mut r = r;
// Flattening and effect rasterization replace placements, so inspect the source too.
if st.doc.has_graphic_layer_visibility_overrides() && !r.warnings.iter().any(|w| w == designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING) {
r.warnings.push(designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING.into());
}
// Transparency Blend Space: the page group of pages with transparency.
let spreads = designcraft_pdf::sheet_spreads(doc, &opts);
let blended: Vec<bool> = spreads.iter().map(|si| doc.spreads.get(*si).is_some_and(|sp| sp.items.iter().any(|it| transparent(it)))).collect();
@@ -774,7 +845,9 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
"fitPage" => "/Fit",
"fitWidth" => "/FitH null",
"actual" => "/XYZ null null 1",
o => return Err(bad("file.exportPdf", format!("unknown view `{o}` (fitPage, fitWidth, actual)"))),
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}]"));
@@ -824,6 +897,184 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
}
}
#[cfg(test)]
mod placed_layer_tests {
use std::sync::Arc;
use designcraft_doc::{AnchoredObject, Document, Item, ItemId, SpreadRef, StoryId, Table};
use designcraft_geom::Rect;
use serde_json::json;
use crate::Session;
fn two_layer_pdf() -> Vec<u8> {
let content = "/OC /a BDC 0 0 1 rg 0 0 50 100 re f EMC /OC /b BDC 1 0 0 rg 50 0 50 100 re f EMC";
let objs = [
"<</Type/Catalog/Lang(en-GB)/Pages 2 0 R/OCProperties<</OCGs[5 0 R 6 0 R]/D<</Order[5 0 R 6 0 R]/Name(Default)/Locked[5 0 R]>>>>>>"
.to_string(),
"<</Type/Pages/Kids[3 0 R]/Count 1>>".to_string(),
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Resources<</Properties<</a 5 0 R/b 6 0 R>>>>/Contents 4 0 R>>".to_string(),
format!("<</Length {}>>\nstream\n{content}\nendstream", content.len()),
"<</Type/OCG/Name(Blue)>>".to_string(),
"<</Type/OCG/Name(Red)>>".to_string(),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{}\nendobj\n", i + 1, o).as_bytes());
}
let x = out.len();
let mut t = format!("xref\n0 {}\n0000000000 65535 f\r\n", objs.len() + 1);
for o in offs {
t.push_str(&format!("{o:010} 00000 n\r\n"));
}
t.push_str(&format!("trailer\n<</Size {}/Root 1 0 R>>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1));
out.extend_from_slice(t.as_bytes());
out
}
fn placement<'a>(d: &'a Document, location: &str, id: ItemId, sid: StoryId) -> &'a Item {
match location {
"cell" => &d.story(sid).unwrap().tables.values().next().unwrap().cells[0].text.objects[0].item,
"footnote" => &d.story(sid).unwrap().notes[0].text.objects[0].item,
"endnote" => &d.story(sid).unwrap().endnotes[0].text.objects[0].item,
"anchor" => &d.story(sid).unwrap().objects[0].item,
_ => d.item(id).unwrap(),
}
}
#[test]
fn stale_layer_fields_on_non_pdf_placements_do_not_block_pdf_a() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let result = s
.execute(
"file.place",
&json!({
"base64": super::super::file::base64_encode(&two_layer_pdf()), "name": "layers.pdf",
}),
)
.unwrap();
let id = ItemId(result["id"].as_u64().unwrap());
s.execute("object.layerOptions", &json!({"ids": [id.0], "hidden": ["Blue"]})).unwrap();
// Relinking to an ordinary image can leave legacy fields on the placement.
// They are not an actionable placed-PDF override and must not fail closed.
let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let aid = d.item(id).unwrap().graphic().unwrap().asset;
let asset = Arc::make_mut(d.assets.get_mut(&aid).unwrap());
asset.mime = "image/png".into();
asset.data = Arc::new(designcraft_render::Rendered { width: 1, height: 1, pixels: vec![0, 0, 0, 0] }.to_png());
let (copy, count) = super::rasterize_layered(d).unwrap();
assert_eq!(count, 0);
assert!(copy.is_none());
s.execute("file.exportPdf", &json!({"standard": "a2b"})).unwrap();
}
fn check_placement(location: &str) {
for all_on in [false, true] {
check_choice(location, all_on);
}
}
fn check_choice(location: &str, all_on: bool) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap();
let placed = s
.execute(
"file.place",
&json!({
"base64": super::super::file::base64_encode(&two_layer_pdf()), "name": "layers.pdf",
"x": 0, "y": 0, "width": 100,
"spread": {"kind": if location == "parent" { "parent" } else { "doc" }, "index": 0}
}),
)
.unwrap();
let id = ItemId(placed["id"].as_u64().unwrap());
let hidden = if all_on { vec![] } else { vec!["Blue"] };
s.execute("object.layerOptions", &json!({"ids": [id.0], "hidden": hidden, "useDefaults": false})).unwrap();
let sid = {
let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let layer = d.default_layer();
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 80.0, 80.0), layer, "Body", Default::default()).unwrap();
if !matches!(location, "doc" | "parent") {
let item = (**d.spreads[0].items.iter().find(|it| it.id == id).unwrap()).clone();
Arc::make_mut(&mut d.spreads[0]).items.retain(|it| it.id != id);
let object = AnchoredObject { item, position: Default::default() };
let story = d.story_mut(sid).unwrap();
match location {
"cell" => {
let mut table = Table::new(1, 1, 1, 0, 0, 20.0);
table.cells[0].text.insert_object(0, object);
story.insert_table(0, table);
}
"footnote" => {
story.insert_note(0, "Note", Default::default()).unwrap();
Arc::make_mut(&mut story.notes[0]).text.insert_object(0, object);
}
"endnote" => {
story.insert_endnote(0, "Endnote", Default::default()).unwrap();
Arc::make_mut(&mut story.endnotes[0]).text.insert_object(0, object);
}
"anchor" => story.insert_object(0, object),
_ => assert!(matches!(location, "doc" | "parent")),
}
}
sid
};
// Exercise the real export command for each location, not just the traversal helper.
let result = s.execute("file.exportPdf", &json!({"standard": "a2b"})).unwrap();
assert!(
result["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("1 placed PDF(s)")),
"{location}: {}",
result["warnings"]
);
// Check the rewritten placement, including its pixels. Counting a placement without
// updating the nested object would still export the file's default layer visibility.
let source = &s.doc().unwrap().doc;
let (copy, count) = super::rasterize_layered(source).unwrap();
assert_eq!(count, 1, "{location}");
let copy = copy.unwrap();
let item = placement(&copy, location, id, sid);
let asset = &copy.assets[&item.graphic().unwrap().asset];
assert_eq!(asset.mime, "image/png", "{location}");
let image = designcraft_render::images::decoded(&asset.data, 0);
let image = image.unwrap();
let pixel = image.data()[usize::from(image.height()) / 2 * usize::from(image.width()) + usize::from(image.width()) / 4];
assert_eq!(pixel.a, if all_on { 255 } else { 0 }, "Blue visibility at {location}, all_on={all_on}");
// Export preparation must not change the open document's placement or source asset.
let original = placement(source, location, id, sid);
assert_eq!(original.pdf_hidden_layers, hidden);
assert!(original.pdf_layer_override);
assert_eq!(source.assets[&original.graphic().unwrap().asset].mime, "application/pdf");
}
#[test]
fn layered_pdf_a_document_placement() {
check_placement("doc");
}
#[test]
fn layered_pdf_a_parent_placement() {
check_placement("parent");
}
#[test]
fn layered_pdf_a_story_anchor() {
check_placement("anchor");
}
#[test]
fn layered_pdf_a_table_cell_anchor() {
check_placement("cell");
}
#[test]
fn layered_pdf_a_footnote_anchor() {
check_placement("footnote");
}
#[test]
fn layered_pdf_a_endnote_anchor() {
check_placement("endnote");
}
}
#[cfg(not(target_arch = "wasm32"))]
fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
crate::io::write_file(path, bytes).map_err(EngineError::Other)
@@ -849,6 +1100,40 @@ mod text_tests {
s
}
/// Split Document is stored with the document as one undo step, follows a renamed style, and
/// an export can override it without storing its own.
#[test]
fn epub_split_document_setting() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "One\nText\nTwo\nMore"})).unwrap();
let sid = r["story"].as_u64().unwrap();
s.execute("style.paragraph.create", &json!({"name": "Chapter"})).unwrap();
for at in [0, 9] {
s.execute("text.select", &json!({"story": sid, "anchor": at, "focus": at})).unwrap();
s.execute("style.paragraph.apply", &json!({"name": "Chapter"})).unwrap();
}
let files = |s: &mut Session, p: serde_json::Value| {
let mut z = unzip(s.execute("file.exportEpub", &p).unwrap()["base64"].as_str().unwrap());
(1..).take_while(|n| z.by_name(&if *n == 1 { "OEBPS/content.xhtml".into() } else { format!("OEBPS/content{n}.xhtml") }).is_ok()).count()
};
assert_eq!(s.execute("document.epubSplit", &json!({})).unwrap()["split"], "off");
assert_eq!(files(&mut s, json!({})), 1, "one content file by default");
assert!(s.execute("document.epubSplit", &json!({"split": {"style": "Nope"}})).is_err());
assert!(s.execute("document.epubSplit", &json!({"split": "h1"})).is_err());
assert!(s.execute("file.exportEpub", &json!({"split": {"style": "Nope"}})).is_err());
assert_eq!(s.execute("document.epubSplit", &json!({"split": {"style": "Chapter"}})).unwrap()["split"], json!({"style": "Chapter"}));
assert_eq!(files(&mut s, json!({})), 2);
assert_eq!(files(&mut s, json!({"split": "off"})), 1);
assert_eq!(s.doc().unwrap().doc.settings.epub_split, designcraft_doc::EpubSplit::Style("Chapter".into()), "the override isn't stored");
s.execute("style.paragraph.edit", &json!({"name": "Chapter", "rename": "Part"})).unwrap();
assert_eq!(s.execute("document.epubSplit", &json!({})).unwrap()["split"], json!({"style": "Part"}));
assert_eq!(files(&mut s, json!({})), 2);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.execute("document.epubSplit", &json!({})).unwrap()["split"], "off");
}
#[test]
fn object_export_options() {
let mut s = Session::new();
@@ -1105,7 +1390,9 @@ mod variable_font_tests {
#[test]
fn variable_font_instance_exports_to_pdf() {
let Ok(data) = std::fs::read("/System/Library/Fonts/Supplemental/Skia.ttf") else { return };
let Ok(data) = std::fs::read("/System/Library/Fonts/Supplemental/Skia.ttf") else {
return;
};
designcraft_fonts::FontDb::global().add_font(data);
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
+3
View File
@@ -723,6 +723,7 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
let xf = Affine::translate((r.x0 + (r.width() - gw) / 2.0, r.y0 + (r.height() - gh) / 2.0)) * Affine::scale(k);
validate_placement((bw, bh), r, xf, "file.place")?;
it.content = Content::Graphic(Graphic {
layer_options: None,
asset: aid,
size: (bw, bh),
xf,
@@ -749,6 +750,7 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
it.effects = e.clone();
}
it.content = Content::Graphic(Graphic {
layer_options: None,
asset: aid,
size: (bw, bh),
xf,
@@ -913,6 +915,7 @@ fn place_drop(s: &mut Session, p: &Value) -> Result<Value> {
validate_placement((nw, nh), frame_rect, xf, "place.drop")?;
let r = s.edit(|d, sel| {
let graphic = Content::Graphic(Graphic {
layer_options: None,
asset: aid,
size: (nw, nh),
xf,
+382 -1
View File
@@ -28,7 +28,8 @@ pub fn specs() -> Vec<CommandSpec> {
fn export_idml(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let opts = designcraft_idml::ExportOptions { embed_images: p.get("embedImages").and_then(Value::as_bool).unwrap_or(true) };
let bytes = designcraft_idml::export_idml_with(&st.doc, &opts);
let layer_doc = with_pdf_layer_variants(&st.doc, opts.embed_images)?;
let bytes = designcraft_idml::export_idml_with(layer_doc.as_ref().unwrap_or(&st.doc), &opts);
match str_param(p, "path") {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
@@ -39,6 +40,99 @@ fn export_idml(s: &mut Session, p: &Value) -> Result<Value> {
}
}
/// Whether a placement holds an Object Layer Options choice an export can still apply: a graphic
/// whose asset is a PDF. A choice left on an item that is no longer a placed PDF (its link was
/// replaced with another format) is not one, so the package report does not name it.
pub(crate) fn has_applicable_pdf_layer_overrides(doc: &Document) -> bool {
doc.placed_items().into_iter().any(|it| {
it.has_pdf_layer_override() && it.graphic().and_then(|g| doc.assets.get(&g.asset)).is_some_and(|a| designcraft_render::is_pdf(&a.data))
})
}
/// Each placement gets its own embedded variant so two frames sharing a PDF can
/// choose different layers. The live document and original linked bytes stay untouched.
///
/// Parent-page and anchored placements count too, so a page that inherits the placement does not
/// quietly fall back to the file's defaults. Read and write walks also reach table-cell and
/// note text. Endnote variants are prepared too, but IDML endnote structures/artwork are not
/// serialized yet (see `docs/pdf-layer-visibility.md`).
fn with_pdf_layer_variants(doc: &Document, embed: bool) -> Result<Option<Document>> {
let choices: Vec<_> = doc
.placed_items()
.into_iter()
.filter(|it| it.has_pdf_layer_override())
.filter_map(|it| {
let asset = it.graphic().and_then(|g| doc.assets.get(&g.asset)).filter(|a| designcraft_render::is_pdf(&a.data))?;
Some((it.id, asset, it.pdf_hidden_layers.clone(), it.pdf_layer_override))
})
.collect();
if choices.is_empty() {
return Ok(None);
}
if !embed {
return Err(EngineError::Other("PDF layer choices require embedded images in IDML export".into()));
}
let mut out = doc.clone();
let mut variants = std::collections::HashMap::new();
// Duplicated anchors can share an id. Reuse a variant only when source and choice agree.
let mut expected = std::collections::HashMap::new();
for (id, asset, hidden, overridden) in choices {
let signature = (asset.id, hidden.clone(), overridden);
if let Some(previous) = expected.get(&id) {
if previous != &signature {
return Err(EngineError::Other(format!("PDF layer choices: placements sharing id {id} have different sources or layer choices")));
}
continue;
}
expected.insert(id, signature);
let bytes = designcraft_render::pdf_hide_layers(&asset.data, &hidden)
.ok_or_else(|| EngineError::Other(format!("{}: PDF layer choices could not be applied", asset.name)))?;
let mut variant = (**asset).clone();
variant.id = designcraft_doc::AssetId(out.alloc());
variant.data = std::sync::Arc::new(bytes);
variant.link = None;
let aid = variant.id;
out.assets.insert(aid, std::sync::Arc::new(variant));
variants.insert(id, aid);
}
// Anchored objects live in stories, and parent pages outside the document-spread walk, so
// the choice is cleared where the placement actually is.
// Count distinct ids, allowing identical duplicated anchors, but check every hit against
// the original source and choice before rewriting (including placements using defaults).
let mut reached = std::collections::HashSet::new();
let mut conflicting_id = None;
out.update_placed_items(
|it| variants.contains_key(&it.id),
|it| {
reached.insert(it.id);
if let Some((asset, hidden, overridden)) = expected.get(&it.id)
&& (it.graphic().map(|g| g.asset) != Some(*asset) || &it.pdf_hidden_layers != hidden || it.pdf_layer_override != *overridden)
{
conflicting_id = Some(it.id);
return;
}
if let designcraft_doc::Content::Graphic(g) = &mut it.content
&& let Some(aid) = variants.get(&it.id)
{
g.asset = *aid;
}
it.pdf_layer_override = false;
it.pdf_hidden_layers.clear();
},
);
if let Some(id) = conflicting_id {
return Err(EngineError::Other(format!("PDF layer choices: placements sharing id {id} have different sources or layer choices")));
}
if reached.len() != variants.len() {
return Err(EngineError::Other(format!(
"PDF layer choices could not be applied to every placement (reached {} of {})",
reached.len(),
variants.len()
)));
}
Ok(Some(out))
}
/// [`designcraft_idml::import_idml`] with the linked images read through [`crate::io`] from
/// beside `package` (the package's file, if it has one), so the automation read root applies to
/// the paths the package names, and its PDF crops resolved.
@@ -558,3 +652,290 @@ mod tests {
assert_eq!(without_document_extension("notes.txt"), "notes.txt");
}
}
#[cfg(test)]
mod nested_layer_tests {
use std::{io::Read, sync::Arc};
use designcraft_doc::{AnchoredObject, ItemId, SpreadRef, Story, StoryId, Table};
use designcraft_geom::Rect;
use super::*;
// One default placement and two nested placements share bytes. The latter use a legacy
// nonempty hidden list and explicit all-on, so each needs a different embedded variant.
fn fixture(location: &str) -> (Session, ItemId, [ItemId; 2]) {
let pdf = designcraft_render::pdf_hide_layers(&super::super::pdflayers::tests::two_layer_pdf(), &["Red".into()]).unwrap();
let mut session = Session::new();
session.execute("file.new", &json!({})).unwrap();
let placed = session.execute("file.place", &json!({"base64": base64_encode(&pdf), "name": "layers.pdf"})).unwrap();
let control = ItemId(placed["id"].as_u64().unwrap());
let ids = {
let doc = Arc::make_mut(&mut session.doc_mut().unwrap().doc);
let original = doc.item(control).unwrap().clone();
let aid = original.graphic().unwrap().asset;
Arc::make_mut(doc.assets.get_mut(&aid).unwrap()).link = Some("/synthetic/layers.pdf".into());
let mut hidden = original.clone();
hidden.id = ItemId(doc.alloc());
hidden.pdf_hidden_layers = vec!["Blue".into()];
let mut all_on = original;
all_on.id = ItemId(doc.alloc());
all_on.pdf_layer_override = true;
let ids = [hidden.id, all_on.id];
let mut nested = Story::new(StoryId(0));
for item in [hidden, all_on] {
nested.insert_object(nested.len(), AnchoredObject { item, position: Default::default() });
}
let layer = doc.default_layer();
let (_, sid) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 200.0, 200.0), layer, "Body", Default::default()).unwrap();
let story = doc.story_mut(sid).unwrap();
match location {
"cell" => {
let mut table = Table::new(1, 1, 1, 0, 0, 100.0);
table.cells[0].text = nested;
story.insert_table(0, table);
}
"footnote" => {
story.insert_note(0, "Note", Default::default()).unwrap();
Arc::make_mut(&mut story.notes[0]).text = nested;
}
_ => {
assert_eq!(location, "endnote");
story.insert_endnote(0, "Endnote", Default::default()).unwrap();
Arc::make_mut(&mut story.endnotes[0]).text = nested;
}
}
ids
};
(session, control, ids)
}
fn states(blue: bool, red: bool) -> Vec<(String, bool)> {
vec![("Blue".into(), blue), ("Red".into(), red)]
}
fn prepared(location: &str) {
let (session, control, ids) = fixture(location);
let source = &session.doc().unwrap().doc;
let original_asset = source.item(control).unwrap().graphic().unwrap().asset;
let copy = with_pdf_layer_variants(source, true).unwrap().unwrap();
for (id, expected) in ids.into_iter().zip([states(false, true), states(true, true)]) {
let item = copy.placed_items().into_iter().find(|i| i.id == id).unwrap();
let asset = &copy.assets[&item.graphic().unwrap().asset];
assert_eq!(designcraft_render::pdf_layers(&asset.data), expected, "{location}: placement {id}");
assert_ne!(asset.id, original_asset);
assert!(asset.link.is_none());
assert!(!item.pdf_layer_override && item.pdf_hidden_layers.is_empty());
}
assert_eq!(copy.item(control).unwrap().graphic().unwrap().asset, original_asset);
assert!(Arc::ptr_eq(&copy.assets[&original_asset], &source.assets[&original_asset]));
assert_eq!(designcraft_render::pdf_layers(&source.assets[&original_asset].data), states(true, false));
assert!(source.placed_items().into_iter().filter(|i| ids.contains(&i.id)).all(|i| i.has_pdf_layer_override()));
}
fn embedded(location: &str) {
let (mut session, _, _) = fixture(location);
let original = session.doc().unwrap().doc.clone();
let result = session.execute("file.exportIdml", &json!({})).unwrap();
let bytes = base64_decode(result["base64"].as_str().unwrap());
let imported = designcraft_idml::import_idml(&bytes).unwrap();
let mut roundtrip: Vec<_> =
imported.assets.values().filter(|a| designcraft_render::is_pdf(&a.data)).map(|a| designcraft_render::pdf_layers(&a.data)).collect();
let mut expected = vec![states(true, false), states(false, true), states(true, true)];
roundtrip.sort();
expected.sort();
assert_eq!(roundtrip, expected, "{location}: reopened IDML keeps each choice");
let mut package = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut actual = Vec::new();
for index in 0..package.len() {
let mut entry = package.by_index(index).unwrap();
if !entry.name().ends_with(".xml") {
continue;
}
let mut xml = String::new();
entry.read_to_string(&mut xml).unwrap();
for cdata in xml.split("<![CDATA[").skip(1) {
let data = base64_decode(cdata.split("]]>").next().unwrap());
if designcraft_render::is_pdf(&data) {
actual.push(designcraft_render::pdf_layers(&data));
}
}
}
actual.sort();
assert_eq!(actual, expected, "{location}: each placement embeds its own visibility state");
assert!(Arc::ptr_eq(&original, &session.doc().unwrap().doc), "export must not edit the live document");
}
fn external(location: &str) {
let (mut session, _, ids) = fixture(location);
// Each choice alone must reject the export, including a legacy nonempty hidden list.
for active in ids {
let original = session.doc().unwrap().doc.clone();
let mut one = (*original).clone();
one.update_placed_items(
|i| ids.contains(&i.id) && i.id != active,
|i| {
i.pdf_layer_override = false;
i.pdf_hidden_layers.clear();
},
);
let mut single = Session::new();
single.add_document(DocState::new(one, None));
let before = single.doc().unwrap().doc.clone();
let err = single.execute("file.exportIdml", &json!({"embedImages": false})).unwrap_err().to_string();
assert!(err.contains("embedded images"), "{location}: {err}");
assert!(Arc::ptr_eq(&before, &single.doc().unwrap().doc));
}
Arc::make_mut(&mut session.doc_mut().unwrap().doc).update_placed_items(
|i| ids.contains(&i.id),
|i| {
i.pdf_layer_override = false;
i.pdf_hidden_layers.clear();
},
);
session.execute("file.exportIdml", &json!({"embedImages": false})).unwrap();
}
// Duplicating a text frame that holds an anchored placed PDF leaves two placements sharing
// one ItemId (renumber gap, tracked separately). Export must still apply the choice to both.
#[test]
fn duplicated_frame_keeps_its_anchored_pdf_choice_in_idml() {
let pdf = designcraft_render::pdf_hide_layers(&super::super::pdflayers::tests::two_layer_pdf(), &["Red".into()]).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let placed = s.execute("file.place", &json!({"base64": base64_encode(&pdf), "name": "layers.pdf"})).unwrap();
let control = ItemId(placed["id"].as_u64().unwrap());
let (frame, chosen) = {
let doc = Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let mut item = doc.item(control).unwrap().clone();
item.id = ItemId(doc.alloc());
item.pdf_hidden_layers = vec!["Blue".into()];
let chosen = item.id;
let layer = doc.default_layer();
let (frame, sid) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 200.0, 200.0), layer, "Body", Default::default()).unwrap();
doc.story_mut(sid).unwrap().insert_object(0, AnchoredObject { item, position: Default::default() });
(frame, chosen)
};
s.execute("transform.move", &json!({"ids": [frame.0], "dx": 10, "dy": 10, "copy": true})).unwrap();
let doc = s.doc().unwrap().doc.clone();
let with_choice: Vec<_> = doc.placed_items().into_iter().filter(|i| i.has_pdf_layer_override()).map(|i| i.id).collect();
assert_eq!(with_choice, [chosen, chosen], "both placements share one id");
assert!(doc.check().unwrap_err().to_string().contains(&format!("duplicate item id {chosen}")));
let result = s.execute("file.exportIdml", &json!({})).unwrap();
let imported = designcraft_idml::import_idml(&base64_decode(result["base64"].as_str().unwrap())).unwrap();
let mut layers: Vec<_> =
imported.assets.values().filter(|a| designcraft_render::is_pdf(&a.data)).map(|a| designcraft_render::pdf_layers(&a.data)).collect();
layers.sort();
assert_eq!(layers, vec![states(false, true), states(true, false)]);
}
// Shared anchored ids are a pre-existing duplication gap. Conflicting choices must not
// silently select one variant; this fixture changes only the copied anchor's choice.
#[test]
fn shared_id_with_divergent_pdf_choices_is_rejected() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let pdf = designcraft_render::pdf_hide_layers(&super::super::pdflayers::tests::two_layer_pdf(), &["Red".into()]).unwrap();
let placed = s.execute("file.place", &json!({"base64": base64_encode(&pdf), "name": "layers.pdf"})).unwrap();
let control = ItemId(placed["id"].as_u64().unwrap());
let frame = {
let doc = Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let mut item = doc.item(control).unwrap().clone();
item.id = ItemId(doc.alloc());
item.pdf_hidden_layers = vec!["Blue".into()];
let layer = doc.default_layer();
let (frame, sid) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 200.0, 200.0), layer, "Body", Default::default()).unwrap();
doc.story_mut(sid).unwrap().insert_object(0, AnchoredObject { item, position: Default::default() });
frame
};
s.execute("transform.move", &json!({"ids": [frame.0], "dx": 10, "dy": 10, "copy": true})).unwrap();
// Change ONLY the copy in the newest story to explicit all-on.
{
let doc = Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let (_, st) = doc.stories.iter_mut().filter(|(_, st)| !st.objects.is_empty()).last().unwrap();
let o = &mut Arc::make_mut(st).objects[0];
let it = &mut Arc::make_mut(o).item;
it.pdf_hidden_layers.clear();
it.pdf_layer_override = true;
}
let before = s.doc().unwrap().doc.clone();
let chosen: Vec<_> = before.placed_items().into_iter().filter(|i| i.has_pdf_layer_override()).collect();
assert_eq!(chosen.len(), 2);
assert_eq!(chosen[0].id, chosen[1].id);
assert_eq!(chosen[0].graphic().unwrap().asset, chosen[1].graphic().unwrap().asset);
assert_ne!(chosen[0].pdf_hidden_layers, chosen[1].pdf_hidden_layers);
let err = s.execute("file.exportIdml", &json!({})).unwrap_err().to_string();
assert!(err.contains("placements sharing id") && err.contains("different sources or layer choices"), "{err}");
assert!(Arc::ptr_eq(&before, &s.doc().unwrap().doc));
}
// Cross-document paste: the pasted frame's anchored placement keeps its source id,
// which can collide with an unrelated placement in the destination.
#[test]
fn shared_id_with_different_pdf_assets_is_rejected() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let pdf = designcraft_render::pdf_hide_layers(&super::super::pdflayers::tests::two_layer_pdf(), &["Red".into()]).unwrap();
let a = s.execute("file.place", &json!({"base64": base64_encode(&pdf), "name": "a.pdf"})).unwrap()["id"].as_u64().unwrap();
s.execute("object.layerOptions", &json!({"ids": [a], "hidden": ["Blue"]})).unwrap();
let f = s.execute("frame.create", &json!({"rect": [72, 72, 400, 400], "content": "text", "text": "Hello"})).unwrap()["id"].as_u64().unwrap();
let sid = s.doc().unwrap().doc.item(ItemId(f)).unwrap().text_frame().unwrap().story;
s.execute("anchored.insert", &json!({"ids": [a], "story": sid.0, "pos": 0})).unwrap();
s.execute("selection.set", &json!({"ids": [f]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("file.new", &json!({})).unwrap();
// A different, unrelated PDF placement in the destination (no choice).
let b = s
.execute("file.place", &json!({"base64": base64_encode(&super::super::pdflayers::tests::two_layer_pdf()), "name": "b.pdf"}))
.unwrap()["id"]
.as_u64()
.unwrap();
s.execute("edit.paste", &json!({})).unwrap();
assert_eq!(a, b, "fresh documents allocate the same first placement id");
let before = s.doc().unwrap().doc.clone();
let graphics: Vec<_> = before.placed_items().into_iter().filter(|i| i.graphic().is_some()).collect();
assert_eq!(graphics.len(), 2);
assert_eq!(graphics[0].id, graphics[1].id);
assert_ne!(graphics[0].graphic().unwrap().asset, graphics[1].graphic().unwrap().asset);
assert!(!graphics[0].has_pdf_layer_override(), "destination placement uses file defaults");
assert!(graphics[1].has_pdf_layer_override());
let err = s.execute("file.exportIdml", &json!({})).unwrap_err().to_string();
assert!(err.contains("placements sharing id") && err.contains("different sources or layer choices"), "{err}");
assert!(Arc::ptr_eq(&before, &s.doc().unwrap().doc));
}
#[test]
fn nested_idml_cell_variants_are_applied() {
prepared("cell");
}
#[test]
fn nested_idml_footnote_variants_are_applied() {
prepared("footnote");
}
// Endnote structures/artwork are not serialized by IDML yet. Check preparation without
// mistaking a correct export-only variant for endnote-artwork serialization support.
#[test]
fn nested_idml_endnote_variants_are_prepared() {
prepared("endnote");
}
#[test]
fn nested_idml_cell_embedded_choices() {
embedded("cell");
}
#[test]
fn nested_idml_footnote_embedded_choices() {
embedded("footnote");
}
#[test]
fn nested_idml_cell_external_choices_are_rejected() {
external("cell");
}
#[test]
fn nested_idml_footnote_external_choices_are_rejected() {
external("footnote");
}
#[test]
fn nested_idml_endnote_external_choices_are_rejected() {
external("endnote");
}
}
+44 -1
View File
@@ -396,8 +396,23 @@ fn reimport(d: &mut Document, sid: StoryId, path: &str, bytes: &[u8], before: Fi
#[cfg(not(target_arch = "wasm32"))]
fn reload(path: impl AsRef<Path>) -> Result<(Vec<u8>, (u32, u32))> {
reload_capped(path, super::interchange::MAX_LINKED_FILE_BYTES)
}
/// A linked graphic read again from disk, and its pixel size, reading at most `cap` bytes.
///
/// How large a linked file is belongs to whoever wrote the document, not to whoever opens it: the
/// path comes out of the document, and Update, Relink and Relink Folder all follow it. An
/// unbounded `read` of a link pointing at a multi-GB file exhausted memory (#1596). The cap is
/// the one packaging already applies to a linked file, and a file over it is refused by name
/// rather than read.
#[cfg(not(target_arch = "wasm32"))]
fn reload_capped(path: impl AsRef<Path>, cap: u64) -> Result<(Vec<u8>, (u32, u32))> {
let path = path.as_ref();
let b = crate::io::read_file(path).map_err(EngineError::Other)?;
let b = crate::io::read_capped(path, cap).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?;
if b.len() as u64 > cap {
return Err(bad("links", format!("{}: the linked file is larger than {} MB", path.display(), cap / (1024 * 1024))));
}
if designcraft_render::is_pdf(&b) {
designcraft_render::check_pdf_input_size(b.len()).map_err(|e| bad("links", format!("{}: {e}", path.display())))?;
}
@@ -584,6 +599,31 @@ mod tests {
designcraft_render::Rendered { width: w, height: h, pixels: vec![200u8; (w * h * 4) as usize] }.to_png()
}
/// A linked file's size is the document's choice, so a link is read under a cap (#1596).
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_linked_file_over_the_cap_is_refused_instead_of_read() {
let dir = std::env::temp_dir().join(format!("dc-link-cap-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("big.png");
let bytes = png(40, 40);
std::fs::write(&path, &bytes).unwrap();
// Under the cap the file is read and measured as before.
let (read, px) = reload_capped(&path, bytes.len() as u64).expect("a file within the cap is read");
assert_eq!((read.len(), px), (bytes.len(), (40, 40)));
// Over it the file is refused by name, and the error says what the limit is.
let err = reload_capped(&path, (bytes.len() - 1) as u64).expect_err("a file over the cap is refused");
let msg = err.to_string();
assert!(msg.contains("big.png") && msg.contains("larger than"), "the error names the file and the limit: {msg}");
// The real cap is the one packaging uses, so an ordinary graphic still loads.
assert!(reload(&path).is_ok(), "a normal linked graphic is unaffected");
let _ = std::fs::remove_dir_all(&dir);
}
fn pdf(sizes: &[(u32, u32)]) -> Vec<u8> {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": sizes.len(), "facingPages": false})).unwrap();
@@ -1128,6 +1168,7 @@ mod shared_asset_tests {
designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, 9.0, 9.0)),
);
it.content = Content::Graphic(Graphic {
layer_options: None,
asset: sid,
size: (4.0, 4.0),
xf: designcraft_geom::Affine::IDENTITY,
@@ -1173,6 +1214,7 @@ mod shared_asset_tests {
designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, 9.0, 9.0)),
);
it.content = Content::Graphic(Graphic {
layer_options: None,
asset: missing,
size: (4.0, 4.0),
xf: designcraft_geom::Affine::IDENTITY,
@@ -1209,6 +1251,7 @@ mod shared_asset_tests {
designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, 9.0, 9.0)),
);
it.content = Content::Graphic(Graphic {
layer_options: None,
asset: missing,
size: (4.0, 4.0),
xf: designcraft_geom::Affine::IDENTITY,
+3 -3
View File
@@ -583,8 +583,8 @@ mod tests {
d.settings.lists.push(NumberedList { name: "Steps".into(), continue_across_stories: false });
d.settings.lists.push(NumberedList { name: "Unused".into(), ..Default::default() });
let st = d.styles_mut();
st.export_tags.insert("p:Head".into(), ExportTag { tag: "h1".into(), class: "title".into() });
st.export_tags.insert("c:Lead".into(), ExportTag { tag: "em".into(), class: String::new() });
st.export_tags.insert("p:Head".into(), ExportTag { tag: "h1".into(), class: "title".into(), split: true });
st.export_tags.insert("c:Lead".into(), ExportTag { tag: "em".into(), class: String::new(), ..Default::default() });
st.para_mut("Quote").unwrap().para.list_name = Some("Steps".into());
Ok(())
})
@@ -592,7 +592,7 @@ mod tests {
let mut s = doc_with_body_and_lead();
s.execute("style.load", &json!({"base64": file_of(&src), "conflicts": "autoRename"})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.styles.export_tag("Head", false).map(|t| t.tag.as_str()), Some("h1"));
assert_eq!(d.styles.export_tag("Head", false).map(|t| (t.tag.as_str(), t.split)), Some(("h1", true)), "Split Document comes along");
assert_eq!(d.styles.export_tag("Lead 2", true).map(|t| t.tag.as_str()), Some("em"), "the tag follows the new name");
assert_eq!(d.styles.export_tag("Lead", true), None);
assert_eq!(d.settings.lists.iter().map(|l| (l.name.as_str(), l.continue_across_stories)).collect::<Vec<_>>(), [("Steps", false)]);
+1
View File
@@ -19,6 +19,7 @@ fn math_item(d: &mut designcraft_doc::Document, latex: &str, size: f64, lid: des
let r = Rect::new(at.0, at.1, at.0 + w, at.1 + h);
let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r));
it.content = Content::Graphic(Graphic {
layer_options: None,
asset: aid,
size: (w, h),
xf: Affine::translate(at),
+2
View File
@@ -32,6 +32,7 @@ pub(crate) fn place_media(s: &mut Session, p: &Value, name: String, mime: &str,
let it = d.item_mut(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?;
let r = it.inner_bounds();
it.content = Content::Graphic(Graphic {
layer_options: None,
asset: aid,
size: (r.width(), r.height()),
xf: Affine::translate((r.x0, r.y0)),
@@ -52,6 +53,7 @@ pub(crate) fn place_media(s: &mut Session, p: &Value, name: String, mime: &str,
let r = super::finite_rect("file.place", Rect::new(x, y, x + w, y + h))?;
let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r));
it.content = Content::Graphic(Graphic {
layer_options: None,
asset: aid,
size: (w, h),
xf: Affine::translate((x, y)),
+11
View File
@@ -221,4 +221,15 @@ mod tests {
// Caret is now in footnote text: no nested footnotes.
assert!(s.execute("footnote.insert", &json!({})).is_err());
}
#[test]
fn huge_start_at_does_not_overflow() {
let (mut s, sid) = session_with_text();
s.execute("text.select", &json!({"story": sid.0, "anchor": 16, "focus": 16})).unwrap();
s.execute("footnote.insert", &json!({"text": "x"})).unwrap();
s.execute("footnote.options", &json!({"startAt": 4294967295u32})).unwrap();
let d = s.doc().unwrap().doc.clone();
let cs = s.cache.get(&d, sid, None);
assert_eq!(cs.frames[0].notes.len(), 1);
}
}
+106 -2
View File
@@ -584,7 +584,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Effects",
[],
None,
"{effects?: {dropShadow?: {on?, color?, opacity?, angle?, globalLight?, distance?, size?, spread?}, innerShadow?, outerGlow?, innerGlow? {on?, color?, opacity?, size?, choke?, center?, mode? (blend mode: Screen, Multiply, …; Screen by default), technique?: softer|precise}, bevel?, satin?, feather? (width), featherOptions? {choke? (%), corners?: sharp|rounded|diffused}, directionalFeather?, gradientFeather?} (the fields given replace the object's, as the Effects dialog sets them), opacity?: 0..1, blend?, ids?} — every effect at once, one undo step",
"{effects?: {dropShadow?: {on?, color?, opacity?, angle?, globalLight?, distance?, size?, spread?}, innerShadow?, outerGlow?, innerGlow? {on?, color?, opacity?, size?, choke?, center?, mode? (blend mode: Screen, Multiply, …; Screen by default), technique?: softer|precise}, bevel?, satin?, feather? (width), featherOptions? {choke? (%), corners?: sharp|rounded|diffused}, directionalFeather?, gradientFeather?} (the fields given replace the object's, as the Effects dialog sets them), opacity?: 0..1, blend?, globalLight? (the document's global light angle, as an Angle field with Use Global Light sets it), ids?} — every effect at once, one undo step",
has_selection,
effects_all
),
@@ -598,7 +598,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Text Frame Options…",
["Object"],
Some("Cmd+B"),
"{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, columnsKind?: fixedNumber|fixedWidth, columnWidth? (pt, Fixed Width), firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin? (pt), autoSize?: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference?: topLeft|topCenter|topRight|leftCenter|center|rightCenter|bottomLeft|bottomCenter|bottomRight (the point that stays put), useMinimumHeight?: bool, minimumHeight? (pt), useMinimumWidth?: bool, minimumWidth? (pt), noLineBreaks?: bool, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}",
"{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, columnsKind?: fixedNumber|fixedWidth, columnWidth? (pt, Fixed Width), firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin? (pt), autoSize?: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference?: topLeft|topCenter|topRight|leftCenter|center|rightCenter|bottomLeft|bottomCenter|bottomRight (the point that stays put), useMinimumHeight?: bool, minimumHeight? (pt), useMinimumWidth?: bool, minimumWidth? (pt), noLineBreaks?: bool, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), baselineGrid?: {start? (pt), increment? (pt), relativeTo?: page|margin|frame|inset} (the frame's custom baseline grid; fields not given keep the frame's, or come from the document grid; null turns it off), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}",
has_selection,
text_frame_options
),
@@ -2293,8 +2293,16 @@ fn effects_all(s: &mut Session, p: &Value) -> Result<Value> {
Some(BlendMode::parse(raw).ok_or_else(|| bad(ID, format!("unknown blend mode `{raw}`")))?)
}
};
// The document's global light angle, changed from an Angle field with Use Global Light on.
let global_light = match p.get("globalLight") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_f64().filter(|a| a.is_finite()).ok_or_else(|| bad(ID, "globalLight must be a number"))?),
};
let ids = targets(s, p)?;
s.edit(|d, _| {
if let Some(a) = global_light {
d.settings.global_light = (a + 180.0).rem_euclid(360.0) - 180.0;
}
let mut changed = 0;
for id in &ids {
let Some(it) = d.item_mut(*id) else { continue };
@@ -2506,6 +2514,41 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result<Value> {
as u8,
),
};
// `Some(None)` turns the frame's custom baseline grid off.
let custom_grid = match p.get("baselineGrid") {
None => None,
Some(Value::Null) => Some(None),
Some(Value::Object(g)) => {
let num = |key: &str, lo: f64, hi: f64| match g.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => match v.as_f64() {
Some(x) if x.is_finite() && (lo..=hi).contains(&x) => Ok(Some(x)),
_ => Err(bad(ID, format!("baselineGrid.{key} must be a number of points from {lo} to {hi}, not {v}"))),
},
};
let start = num("start", -15552.0, 15552.0)?;
let increment = num("increment", 0.0, 15552.0)?;
let relative_to = match g.get("relativeTo") {
None | Some(Value::Null) => None,
Some(v) => Some(
v.as_str()
.and_then(designcraft_doc::FrameGridRelative::from_name)
.ok_or_else(|| bad(ID, format!("baselineGrid.relativeTo must be page|margin|frame|inset, not {v}")))?,
),
};
Some(Some((start, increment, relative_to)))
}
Some(v) => return Err(bad(ID, format!("baselineGrid must be an object or null, not {v}"))),
};
// A frame without a custom grid starts from the document's.
let doc_grid = {
let g = &st.doc.settings.baseline_grid;
let relative_to = match g.relative_to {
designcraft_doc::GridRelative::TopOfPage => designcraft_doc::FrameGridRelative::TopOfPage,
designcraft_doc::GridRelative::TopMargin => designcraft_doc::FrameGridRelative::TopMargin,
};
designcraft_doc::FrameBaselineGrid { start: g.start, increment: g.increment, relative_to }
};
let rule_color = match p.get("columnRuleColor") {
None | Some(Value::Null) => None,
Some(Value::String(c)) if st.doc.swatch(c).is_some() => Some(c.clone()),
@@ -2596,6 +2639,17 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = rule_bottom {
o.column_rule_bottom_inset = v;
}
match custom_grid {
Some(Some((start, increment, relative_to))) => {
let mut g = o.baseline_grid.unwrap_or(doc_grid);
g.start = start.unwrap_or(g.start);
g.increment = increment.unwrap_or(g.increment);
g.relative_to = relative_to.unwrap_or(g.relative_to);
o.baseline_grid = Some(g);
}
Some(None) => o.baseline_grid = None,
None => {}
}
// Fixed Width columns set the frame's width: n columns, the gutters between them and
// the left and right insets.
let fixed_width = o.columns_kind == designcraft_doc::ColumnsKind::FixedWidth && o.column_width > 0.0;
@@ -4099,6 +4153,26 @@ mod light_tests {
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(angle(&s), 45.0);
}
/// The Effects dialog's Angle with Use Global Light on changes the global light, with the
/// effects, in one undo step.
#[test]
fn effects_sets_the_global_light_with_the_effects() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = s.execute("frame.create", &json!({"rect": [10, 10, 50, 50]})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("frame.create", &json!({"rect": [60, 10, 90, 50]})).unwrap()["id"].as_u64().unwrap();
s.execute("object.dropShadow", &json!({"on": true, "globalLight": true, "ids": [b]})).unwrap();
let before = s.doc().unwrap().doc.settings.global_light;
s.execute("object.effects", &json!({"ids": [a], "effects": {"dropShadow": {"on": true, "globalLight": true}}, "globalLight": 30})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.settings.global_light, 30.0);
let other = &d.item(ItemId(b)).unwrap().effects.drop_shadow;
assert_eq!(d.light_angle(other.angle, other.global_light), 30.0, "every shadow on Global Light follows");
assert!(s.execute("object.effects", &json!({"ids": [a], "globalLight": "up"})).is_err());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.settings.global_light, before, "one undo step");
}
}
#[cfg(test)]
@@ -5609,3 +5683,33 @@ mod fill_tint_tests {
assert!(stroke_of(&s, b).is_none());
}
}
#[cfg(test)]
mod frame_baseline_grid_tests {
use super::*;
use designcraft_doc::{FrameBaselineGrid, FrameGridRelative};
#[test]
fn text_frame_options_set_a_custom_baseline_grid_relative_to() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Hello"})).unwrap();
let id = ItemId(r["id"].as_u64().unwrap());
let grid = |s: &Session| s.doc().unwrap().doc.item(id).and_then(|i| i.text_frame()).and_then(|t| t.options.baseline_grid);
// Turning it on with only Relative To takes the document grid's start and increment.
let doc = s.doc().unwrap().doc.settings.baseline_grid.clone();
s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": {"relativeTo": "frame"}})).unwrap();
assert_eq!(grid(&s), Some(FrameBaselineGrid { start: doc.start, increment: doc.increment, relative_to: FrameGridRelative::TopOfFrame }));
s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": {"start": 10, "increment": 13, "relativeTo": "inset"}})).unwrap();
assert_eq!(grid(&s), Some(FrameBaselineGrid { start: 10.0, increment: 13.0, relative_to: FrameGridRelative::TopInset }));
// Fields not given keep the frame's.
s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": {"relativeTo": "margin"}})).unwrap();
assert_eq!(grid(&s), Some(FrameBaselineGrid { start: 10.0, increment: 13.0, relative_to: FrameGridRelative::TopMargin }));
for bad in [json!({"relativeTo": "spine"}), json!({"start": f64::MAX}), json!({"increment": -1}), json!(3)] {
assert!(s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": bad})).is_err(), "{bad}");
}
assert_eq!(grid(&s).map(|g| g.relative_to), Some(FrameGridRelative::TopMargin));
s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": null})).unwrap();
assert_eq!(grid(&s), None);
}
}
+53 -7
View File
@@ -427,12 +427,19 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
.iter()
.map(|f| format!(" {} {}{}\n", f.family, f.style, copy_font(&root, f, &fonts_dir, &mut copied, &mut taken, &mut files)))
.collect();
// The IDML copy links the placed files, so a per-placement PDF layer choice cannot travel in
// it (an external-only IDML export rejects one for the same reason). Say so in the report
// instead of leaving a silent difference between the package's copies.
let mut idml_layer_note = "";
if p.get("idml").and_then(Value::as_bool).unwrap_or(true) {
let path = package_output_path(&dir, &title, "idml")?;
// The images are in Links/ beside it: link them there, without embedded copies.
let opts = designcraft_idml::ExportOptions { embed_images: false };
root.write(&path, &designcraft_idml::export_idml_with(&packed, &opts))?;
files.push(path);
if super::interchange::has_applicable_pdf_layer_overrides(&packed) {
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) {
let path = package_output_path(&dir, &title, "pdf")?;
@@ -456,6 +463,7 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
for l in links.as_array().into_iter().flatten() {
r += &format!(" {} ({})\n", l["name"].as_str().unwrap_or(""), l["status"].as_str().unwrap_or(""));
}
r += idml_layer_note;
if let Some(issues) = pre.get("issues").and_then(Value::as_array) {
r += &format!("\nPreflight: {} issue(s)\n", issues.len());
for i in issues {
@@ -510,6 +518,32 @@ mod tests {
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]
fn package_report_names_placed_pdf_layer_choices() {
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-layers-{}-{nonce}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let pdf = super::super::pdflayers::tests::two_layer_pdf();
let mut s = Session::new();
s.execute("file.new", &json!({"title": "LayeredPackage", "width": 100, "height": 100})).unwrap();
let id = s
.execute("file.place", &json!({"base64": super::super::base64_encode(&pdf), "name": "art.pdf", "x": 0, "y": 0, "width": 100}))
.unwrap()["id"]
.clone();
s.execute("object.layerOptions", &json!({"ids": [id], "hidden": ["Red"]})).unwrap();
let r = s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": true, "pdf": false})).unwrap();
let report = r["report"].as_str().unwrap();
assert!(report.contains("Placed PDF layers"), "{report}");
// The DesignCraft copy keeps the choice; only the linked IDML copy shows file defaults.
let packed = super::super::from_bytes(&std::fs::read(dir.join("LayeredPackage.designcraft")).unwrap()).unwrap();
let item = packed.item(designcraft_doc::ItemId(id.as_u64().unwrap())).unwrap();
assert_eq!(item.pdf_hidden_layers, vec!["Red".to_string()]);
assert!(item.has_pdf_layer_override());
std::fs::remove_dir_all(dir).unwrap();
}
/// The package's IDML links its images to the files in the package's Links folder and embeds
/// no copies of them.
#[test]
@@ -907,13 +941,12 @@ mod tests {
let outside = base.join("separate");
std::fs::create_dir_all(&outside).unwrap();
let redirected = base.join("Package");
match symlink_dir(&outside, &redirected) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
let _ = std::fs::remove_dir_all(&base);
return;
}
Err(e) => panic!("could not create directory redirect: {e}"),
// A symbolic link needs Developer Mode or elevation (os error 1314 without them); a
// directory junction needs neither, and `is_redirect` treats both as redirects.
if symlink_dir(&outside, &redirected).is_err() && !make_junction(&outside, &redirected) {
eprintln!("skipped: directory redirects can't be made here");
let _ = std::fs::remove_dir_all(&base);
return;
}
let mut s = Session::new();
s.execute("file.new", &json!({"title": "Annual"})).unwrap();
@@ -922,4 +955,17 @@ mod tests {
std::fs::remove_dir(&redirected).unwrap();
let _ = std::fs::remove_dir_all(&base);
}
/// Makes `link` a directory junction to `target` with `mklink /J`, and reports whether it was made.
#[cfg(windows)]
fn make_junction(target: &Path, link: &Path) -> bool {
use std::os::windows::process::CommandExt;
// Windows paths can't hold `"`, so quoting each one keeps `&` and spaces in it literal for cmd.
std::process::Command::new("cmd")
.args(["/D", "/C"])
.raw_arg(format!("mklink /J \"{}\" \"{}\"", link.display(), target.display()))
.output()
.is_ok_and(|output| output.status.success())
}
}
+274 -18
View File
@@ -15,13 +15,15 @@ fn placed_pdf(s: &Session, id: designcraft_doc::ItemId) -> Option<std::sync::Arc
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "object.pdfLayers", "Object Layers", [], None, "{id?} → [{name, visible, default}] — the layers of the selected placed PDF", has_selection, |s, p| {
let id = super::id_param(p, "id").or_else(|| s.active().and_then(|d| d.selection.items.first().copied())).ok_or_else(|| bad("object.pdfLayers", "no selection"))?;
let id = super::id_param(p, "id").or_else(|| s.active().and_then(|d| d.selection.items.iter().copied().find(|id| placed_pdf(s, *id).is_some()))).ok_or_else(|| bad("object.pdfLayers", "no selection"))?;
let data = placed_pdf(s, id).ok_or_else(|| bad("object.pdfLayers", "not a placed PDF"))?;
let hidden = s.doc()?.doc.item(id).map(|i| i.pdf_hidden_layers.clone()).unwrap_or_default();
let item = s.doc()?.doc.item(id);
let hidden = item.map(|i| i.pdf_hidden_layers.clone()).unwrap_or_default();
let overridden = item.is_some_and(|i| i.has_pdf_layer_override());
Ok(Value::Array(
designcraft_render::pdf_layers(&data)
.into_iter()
.map(|(name, on)| json!({"visible": if hidden.is_empty() { on } else { !hidden.contains(&name) }, "name": name, "default": on}))
.map(|(name, on)| json!({"visible": if overridden { !hidden.contains(&name) } else { on }, "name": name, "default": on, "overridden": overridden}))
.collect(),
))
}),
@@ -30,30 +32,39 @@ pub fn specs() -> Vec<CommandSpec> {
"Object Layer Options…",
["Object"],
None,
"{ids?, hidden: [layer name]} — layers of placed PDFs to hide ([] = the file's own settings)",
"{ids?, hidden: [layer name], useDefaults?: bool} — hide layers; useDefaults:false makes [] explicitly all on; legacy [] restores file defaults",
has_selection,
|s, p| {
let ids = targets(s, p)?;
let ids: Vec<_> = targets(s, p)?.into_iter().filter(|id| placed_pdf(s, *id).is_some()).collect();
let hidden: Vec<String> = p
.get("hidden")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect())
.unwrap_or_default();
if ids.iter().all(|id| placed_pdf(s, *id).is_none()) {
let use_defaults = match p.get("useDefaults") {
None => hidden.is_empty(),
Some(v) => v.as_bool().ok_or_else(|| bad("object.layerOptions", "useDefaults must be a boolean"))?,
};
if use_defaults && !hidden.is_empty() {
return Err(bad("object.layerOptions", "useDefaults cannot be combined with hidden layers"));
}
if ids.is_empty() {
return Err(bad("object.layerOptions", "select a placed PDF"));
}
// Validate every selected PDF before editing any item or recording undo.
// Repeated placements of the same source need only one rewrite check.
if !hidden.is_empty() {
// Validate every selected PDF before editing any item or recording undo, so a
// mixed request stays atomic. Any explicit choice, including all-on
// (useDefaults:false with no hidden names), needs one actual rewrite per distinct
// source: a PDF can list readable layers and still fail the rewrite, and a
// layerless PDF cannot be rewritten at all. Resetting to the file defaults needs no
// variant and is always allowed.
if !use_defaults {
let mut checked = std::collections::HashSet::new();
for id in &ids {
if let Some(data) = placed_pdf(s, *id)
&& checked.insert(std::sync::Arc::as_ptr(&data))
&& designcraft_render::pdf_hide_layers(&data, &hidden).is_none()
{
let Some(data) = placed_pdf(s, *id) else { continue };
if checked.insert(std::sync::Arc::as_ptr(&data)) && designcraft_render::pdf_hide_layers(&data, &hidden).is_none() {
return Err(bad(
"object.layerOptions",
"the selected PDF layers could not be hidden; use the file defaults or replace the unsupported PDF",
"the selected PDF layers could not be changed (no readable optional-content layers, or an unsupported file); use the file defaults or replace the PDF",
));
}
}
@@ -62,9 +73,10 @@ pub fn specs() -> Vec<CommandSpec> {
for id in &ids {
if let Some(it) = d.item_mut(*id) {
it.pdf_hidden_layers = hidden.clone();
it.pdf_layer_override = !use_defaults;
}
}
Ok(json!({"hidden": hidden}))
Ok(json!({"hidden": hidden, "useDefaults": use_defaults}))
})
}
),
@@ -243,7 +255,7 @@ pub(crate) mod tests {
let redo = s.doc().unwrap().history.redo.len();
for ids in [json!([good, bad]), json!([bad, good])] {
let err = s.execute("object.layerOptions", &json!({"ids": ids, "hidden": ["Red"]})).unwrap_err();
assert!(err.to_string().contains("could not be hidden"));
assert!(err.to_string().contains("could not be changed"));
assert_eq!(s.execute("file.serialize", &json!({})).unwrap(), before);
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
assert_eq!(s.doc().unwrap().history.redo.len(), redo);
@@ -263,6 +275,46 @@ pub(crate) mod tests {
s.execute("file.exportPdf", &json!({})).unwrap();
}
#[test]
fn explicit_all_on_validates_an_actual_rewrite_before_editing() {
// 33 xref sections: the layers stay readable, but the visibility rewrite refuses the history.
let mut unsupported = two_layer_pdf();
for _ in 0..32 {
add_pdf_revision(&mut unsupported);
}
assert_eq!(designcraft_render::pdf_layers(&unsupported).len(), 2);
assert!(designcraft_render::pdf_hide_layers(&unsupported, &[]).is_none());
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100, "pages": 1})).unwrap();
let good = place(&mut s, &two_layer_pdf(), 0.0);
let bad = place(&mut s, &unsupported, 100.0);
let before = s.execute("file.serialize", &json!({})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
for ids in [json!([good, bad]), json!([bad, good]), json!([bad])] {
let err = s.execute("object.layerOptions", &json!({"ids": ids, "hidden": [], "useDefaults": false})).unwrap_err();
assert!(err.to_string().contains("could not be changed"), "{err}");
assert_eq!(s.execute("file.serialize", &json!({})).unwrap(), before, "rejected all-on must not edit any target");
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
}
assert_eq!(pixel(&s, 175), [255, 0, 0, 255], "the unsupported source keeps showing its file defaults");
s.execute("file.exportPdf", &json!({})).unwrap();
// The supported source alone still accepts the explicit all-on choice.
s.execute("object.layerOptions", &json!({"ids": [good], "hidden": [], "useDefaults": false})).unwrap();
assert_eq!(s.execute("object.pdfLayers", &json!({"id": good})).unwrap()[1]["overridden"], true);
// Resetting to file defaults needs no variant, so it bypasses the rewrite check.
for reset in [json!({"ids": [good, bad], "hidden": []}), json!({"ids": [good, bad], "useDefaults": true})] {
s.execute("object.layerOptions", &reset).unwrap();
for id in [&good, &bad] {
assert_eq!(s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["overridden"], false);
}
}
// A stale explicit all-on choice that outlived its rewritable source still fails closed.
let bad_id = serde_json::from_value(bad.clone()).unwrap();
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).item_mut(bad_id).unwrap().pdf_layer_override = true;
assert_eq!(pixel(&s, 175), [255, 255, 255, 255], "unrewritable explicit choice must not draw the unmodified source");
assert!(s.execute("file.exportPdf", &json!({})).is_err());
}
#[test]
fn unreadable_non_null_prev_entries_refuse_layer_rewrites() {
for prev in ["7 0 R", "-1", "false", "<<>>", "[]", "/Unknown", "(unknown)"] {
@@ -374,8 +426,212 @@ pub(crate) mod tests {
assert!(right[0] > 200 && right[1] > 200 && right[2] > 200, "the red layer is hidden: {right:?}");
assert!(px(&s, 125)[2] > 200, "blue still shows");
assert_eq!(s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["visible"], false);
// PDF export carries the choice (the graphic goes out as an image).
let r = s.execute("file.exportPdf", &json!({})).unwrap();
assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("layer")), "{r}");
assert!(r["warnings"].as_array().unwrap().is_empty(), "{r}");
let bytes = super::super::file::base64_decode(r["base64"].as_str().unwrap());
let image = designcraft_render::render_pdf_page(&bytes, 0, 792).unwrap();
let width = usize::from(image.width());
assert!(image.data()[150 * width + 125].a > 200);
assert_eq!(image.data()[150 * width + 175].a, 0);
}
#[test]
fn placed_pdf_all_on_is_distinct_from_defaults_and_survives_exports() {
let pdf = designcraft_render::pdf_hide_layers(&two_layer_pdf(), &["Red".into()]).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = s
.execute("file.place", &json!({"base64": super::super::file::base64_encode(&pdf), "name":"off.pdf", "x":100,"y":100,"width":100}))
.unwrap()["id"]
.clone();
let visible = |s: &mut Session| s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["visible"].as_bool().unwrap();
let pixel = |s: &Session| {
let mut rr = designcraft_render::Renderer::new();
rr.threads = 0;
rr.render_page(&s.doc().unwrap().doc, &s.cache, 0, 1.0, false, &Default::default()).unwrap().pixel(175, 150)
};
assert!(!visible(&mut s));
assert!(pixel(&s)[1] > 200, "red hidden by default");
s.execute("object.layerOptions", &json!({"ids":[id], "hidden":[], "useDefaults":false})).unwrap();
assert!(visible(&mut s));
assert!(pixel(&s)[0] > 200 && pixel(&s)[1] < 40, "all-on renders red");
s.execute("edit.undo", &json!({})).unwrap();
assert!(!visible(&mut s));
s.execute("edit.redo", &json!({})).unwrap();
assert!(visible(&mut s));
let bytes = designcraft_format::save(&s.doc().unwrap().doc).unwrap();
let roundtrip = designcraft_format::load(&bytes).unwrap();
let item = roundtrip.item(designcraft_doc::ItemId(id.as_u64().unwrap())).unwrap();
assert!(item.pdf_layer_override && item.pdf_hidden_layers.is_empty());
let exported = s.execute("file.exportPdf", &json!({})).unwrap();
let bytes = super::super::file::base64_decode(exported["base64"].as_str().unwrap());
let raster = designcraft_render::render_pdf_page(&bytes, 0, 842).unwrap();
let px = raster.data().get(150 * usize::from(raster.width()) + 175).unwrap();
assert!(px.r > 200 && px.g < 40, "PDF export retains red: {px:?}");
let idml = s.execute("file.exportIdml", &json!({})).unwrap();
let bytes = super::super::file::base64_decode(idml["base64"].as_str().unwrap());
let imported = designcraft_idml::import_idml(&bytes).unwrap();
let pdf_asset = imported.assets.values().find(|a| designcraft_render::is_pdf(&a.data)).unwrap();
assert!(designcraft_render::pdf_layers(&pdf_asset.data).iter().all(|(_, on)| *on));
assert!(s.execute("file.exportIdml", &json!({"embedImages":false})).is_err());
assert_eq!(s.doc().unwrap().doc.assets.values().find(|a| a.name == "off.pdf").unwrap().data.as_slice(), pdf);
s.execute("object.layerOptions", &json!({"ids":[id],"hidden":[]})).unwrap();
assert!(!visible(&mut s), "legacy empty list still resets defaults");
s.execute("object.layerOptions", &json!({"ids":[id],"hidden":[],"useDefaults":false})).unwrap();
s.execute("object.layerOptions", &json!({"ids":[id],"useDefaults":true})).unwrap();
assert!(!visible(&mut s), "explicit reset restores defaults");
}
#[test]
fn parent_page_choices_reach_idml_variants_and_raster_export() {
// Red is hidden by the file itself, so "show every layer" is a visible difference.
let pdf = designcraft_render::pdf_hide_layers(&two_layer_pdf(), &["Red".into()]).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap();
let id = s
.execute(
"file.place",
&json!({"base64": super::super::file::base64_encode(&pdf), "name": "on-parent.pdf", "spread": {"kind": "parent", "index": 0}, "x": 0.0, "y": 0.0, "width": 100}),
)
.unwrap()["id"]
.clone();
let item = designcraft_doc::ItemId(id.as_u64().unwrap());
assert!(
matches!(s.doc().unwrap().doc.find(item).unwrap().spread, designcraft_doc::SpreadRef::Parent(0)),
"the placement sits on a parent page"
);
s.execute("object.layerOptions", &json!({"ids": [id], "hidden": [], "useDefaults": false})).unwrap();
// Embedded IDML: the parent-page placement gets its own variant, like a document one.
let idml = s.execute("file.exportIdml", &json!({})).unwrap();
let imported = designcraft_idml::import_idml(&super::super::file::base64_decode(idml["base64"].as_str().unwrap())).unwrap();
let asset = imported.assets.values().find(|a| designcraft_render::is_pdf(&a.data)).unwrap();
assert!(designcraft_render::pdf_layers(&asset.data).iter().all(|(_, on)| *on), "the embedded copy shows every layer");
// An external-only copy would keep the original file's defaults: the choice is rejected.
let err = s.execute("file.exportIdml", &json!({"embedImages": false})).unwrap_err().to_string();
assert!(err.contains("embedded images"), "{err}");
// PDF/A-2b rasterises a parent-page placement with a choice, warnings included.
let a2b = s.execute("file.exportPdf", &json!({"standard": "a2b"})).unwrap();
assert!(a2b["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|w| w.contains("exported as images"))), "{a2b}");
}
/// An object anchored in a story is a placement too: the IDML copy gets a variant that shows
/// every layer, an external-only copy is refused, and PDF/A-2b rasterises it, rather than each
/// quietly falling back to the file's defaults.
#[test]
fn an_anchored_placement_keeps_its_layer_choice_in_saves_and_exports() {
use designcraft_doc::{AnchoredObject, Content, Graphic, Item, Shape, SpreadRef};
use designcraft_geom::{Affine, Rect, shapes};
let pdf = designcraft_render::pdf_hide_layers(&two_layer_pdf(), &["Red".into()]).unwrap();
let mut d = designcraft_doc::Document::new(&Default::default());
let asset = designcraft_doc::AssetId(d.alloc());
d.add_asset(designcraft_doc::Asset {
id: asset,
name: "anchored.pdf".into(),
mime: "application/pdf".into(),
data: std::sync::Arc::new(pdf),
..Default::default()
});
let mut item =
Item::new(designcraft_doc::ItemId(d.alloc()), d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)));
item.pdf_layer_override = true;
item.content = Content::Graphic(Graphic {
asset,
size: (100.0, 100.0),
xf: Affine::IDENTITY,
clip: None,
auto_fit: Default::default(),
fit_align: 4,
crop: [0.0; 4],
wrap: Default::default(),
fill: Default::default(),
layer_options: None,
});
let layer = d.default_layer();
let (_, story) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 80.0, 80.0), layer, "Body", Default::default()).unwrap();
d.story_mut(story).unwrap().insert_object(0, AnchoredObject { item, position: Default::default() });
assert!(d.has_pdf_layer_overrides(), "the anchored placement carries the choice");
let mut s = Session::new();
s.add_document(crate::DocState::new(d, None));
// Embedded IDML carries a variant that shows every layer.
let idml = s.execute("file.exportIdml", &json!({})).unwrap();
let imported = designcraft_idml::import_idml(&super::super::file::base64_decode(idml["base64"].as_str().unwrap())).unwrap();
let copies: Vec<_> = imported.assets.values().filter(|a| designcraft_render::is_pdf(&a.data)).collect();
assert!(!copies.is_empty(), "the anchored placement is exported");
assert!(
copies.iter().all(|a| designcraft_render::pdf_layers(&a.data).iter().all(|(_, on)| *on)),
"the embedded copy must not fall back to the file's defaults"
);
// An external-only copy would keep the original file's defaults.
assert!(s.execute("file.exportIdml", &json!({"embedImages": false})).is_err());
// PDF/A-2b rasterises a placement with a choice, warnings included.
let a2b = s.execute("file.exportPdf", &json!({"standard": "a2b"})).unwrap();
assert!(a2b["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|w| w.contains("exported as images"))), "{a2b}");
}
/// Relinking a placed PDF to an image leaves the item's layer fields behind. They are no longer a
/// placed-PDF choice: the package report asks for a graphic whose asset is a PDF, the same thing
/// the export walks ask for, so it does not name the stale field, and exports run unaffected.
#[test]
fn a_stale_choice_on_a_non_pdf_placement_is_not_a_placed_pdf_choice() {
let png = designcraft_render::Rendered { width: 4, height: 3, pixels: [30, 90, 180, 255].repeat(12) }.to_png();
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let id = s
.execute("file.place", &json!({"base64": super::super::file::base64_encode(&png), "name": "photo.png", "x": 0, "y": 0, "width": 100}))
.unwrap()["id"]
.clone();
let item = designcraft_doc::ItemId(id.as_u64().unwrap());
// The state a relink from a PDF leaves behind: fields set, asset no longer a PDF.
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).item_mut(item).unwrap().pdf_hidden_layers = vec!["Red".into()];
let d = &s.doc().unwrap().doc;
assert!(d.item(item).unwrap().has_pdf_layer_override(), "the stale field is on the item");
assert!(d.has_pdf_layer_overrides(), "the field is there");
assert!(!super::super::interchange::has_applicable_pdf_layer_overrides(d), "but it is not a placed-PDF choice");
// Nothing is dropped: the field is not a choice any export applies.
s.execute("file.exportIdml", &json!({})).unwrap();
s.execute("file.exportPdf", &json!({"standard": "a2b"})).unwrap();
s.execute("file.exportPdf", &json!({})).unwrap();
}
#[test]
fn layerless_pdf_rejects_overrides_without_breaking_idml_export() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let pdf = s.execute("file.exportPdf", &json!({})).unwrap()["base64"].clone();
let id = s.execute("file.place", &json!({"base64":pdf,"name":"plain.pdf","x":0,"y":0,"width":100})).unwrap()["id"].clone();
let layered = s
.execute(
"file.place",
&json!({"base64":super::super::file::base64_encode(&two_layer_pdf()),"name":"layered.pdf","x":120,"y":0,"width":100}),
)
.unwrap()["id"]
.clone();
let before = serde_json::to_value(&*s.doc().unwrap().doc).unwrap();
assert!(s.execute("object.layerOptions", &json!({"ids":[layered,id],"hidden":[],"useDefaults":false})).is_err());
assert_eq!(serde_json::to_value(&*s.doc().unwrap().doc).unwrap(), before);
assert!(s.execute("file.exportIdml", &json!({})).is_ok());
s.execute("object.layerOptions", &json!({"ids":[id],"hidden":[],"useDefaults":true})).unwrap();
assert!(!s.doc().unwrap().doc.item(designcraft_doc::ItemId(id.as_u64().unwrap())).unwrap().has_pdf_layer_override());
}
#[test]
fn pdf_layer_options_ignore_non_pdf_targets_in_mixed_selection() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let placed = s
.execute(
"file.place",
&json!({"base64": super::super::file::base64_encode(&two_layer_pdf()), "name":"art.pdf", "x":100,"y":100,"width":100}),
)
.unwrap();
let other = s.execute("frame.create", &json!({"rect":[0,0,20,20]})).unwrap();
for ids in [json!([other["id"], placed["id"]]), json!([placed["id"], other["id"]])] {
s.execute("selection.set", &json!({"ids":ids})).unwrap();
assert_eq!(s.execute("object.pdfLayers", &json!({})).unwrap().as_array().unwrap().len(), 2);
}
assert!(s.execute("object.pdfLayers", &json!({"id":other["id"]})).is_err(), "explicit non-PDF id remains an error");
s.execute("object.layerOptions", &json!({"ids":[placed["id"],other["id"]],"hidden":[],"useDefaults":false})).unwrap();
assert!(!s.doc().unwrap().doc.item(designcraft_doc::ItemId(other["id"].as_u64().unwrap())).unwrap().has_pdf_layer_override());
assert!(s.execute("object.layerOptions", &json!({"ids":[placed["id"]],"hidden":["Red"],"useDefaults":true})).is_err());
assert!(s.execute("object.layerOptions", &json!({"ids":[placed["id"]],"useDefaults":"false"})).is_err());
}
}
+28 -2
View File
@@ -249,6 +249,17 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> {
let xf = parent * it.xf;
pending.extend(it.children().iter().rev().map(|child| (child.as_ref(), xf)));
let page = page_of(d, SpreadRef::Doc(si), it, parent);
if let Content::Graphic(g) = &it.content
&& g.layer_options.as_ref().is_some_and(designcraft_doc::GraphicLayerOptions::has_visibility_overrides)
{
out.push(Issue {
severity: "warning",
kind: "unsupportedGraphicLayers",
message: designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING.into(),
item: Some(it.id.0),
page,
});
}
match &it.content {
Content::Graphic(g) => match d.assets.get(&g.asset) {
None => out.push(Issue { severity: "error", kind: "missingLink", message: "Missing graphic".into(), item: Some(it.id.0), page }),
@@ -307,6 +318,17 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> {
}
}
}
// Nested graphic cells and anchored/parent items may not have a page frame
// in the walk above. Keep the warning visible after a native save/reopen.
if d.has_unwalked_graphic_layer_visibility_overrides() {
out.push(Issue {
severity: "warning",
kind: "unsupportedGraphicLayers",
message: designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING.into(),
item: None,
page: None,
});
}
out
}
@@ -354,11 +376,15 @@ mod placed_vector_tests {
s.execute("file.place", &json!({"base64": base64_encode(&png), "name": "photo.jpg", "x": 72, "y": 72, "width": 20})).unwrap();
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let asset = doc.assets.values_mut().next().unwrap();
std::sync::Arc::make_mut(asset).link = Some("/definitely/missing dir/photo.jpg".into());
// The temporary folder is an absolute path with the default settings on Windows, macOS and Linux. On Windows,
// `/x` names no drive, so `document_path` refuses it when the document has no path.
let link = std::env::temp_dir().join(format!("dc-preflight-missing-{} dir", std::process::id())).join("photo.jpg");
let link = link.to_string_lossy().into_owned();
std::sync::Arc::make_mut(asset).link = Some(link.clone());
let r = s.execute("preflight.run", &json!({})).unwrap();
let missing: Vec<&str> =
r["issues"].as_array().unwrap().iter().filter(|i| i["kind"] == "missingLink").map(|i| i["message"].as_str().unwrap()).collect();
assert_eq!(missing, ["Missing link: /definitely/missing dir/photo.jpg"]);
assert_eq!(missing, [format!("Missing link: {link}")]);
}
}
+130 -15
View File
@@ -249,7 +249,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export Tagging",
[],
None,
"{style, character?: bool, tag?: p|h1…h6|blockquote|pre|li|… (span|em|strong|code|sup|sub… for characters; \"\" = automatic), class?} → the tagging",
"{style, character?: bool, tag?: p|h1…h6|blockquote|pre|li|… (span|em|strong|code|sup|sub… for characters; \"\" = automatic), class?, split?: bool (paragraphs: Split Document, EPUB only)} → the tagging",
has_doc,
|s, p| {
let name = str_param(p, "style").ok_or_else(|| bad("style.exportTag", "`style` required"))?.to_string();
@@ -258,8 +258,12 @@ pub fn specs() -> Vec<CommandSpec> {
if !exists {
return Err(bad("style.exportTag", format!("no style `{name}`")));
}
let key = format!("{}:{name}", if character { "c" } else { "p" });
let key = export_tag_key(!character, &name);
let (tag, class) = (str_param(p, "tag").map(str::to_string), str_param(p, "class").map(str::to_string));
let split = p.get("split").and_then(Value::as_bool);
if character && split == Some(true) {
return Err(bad("style.exportTag", "`split` is for paragraph styles"));
}
s.edit(|d, _| {
let st = d.styles_mut();
let e = st.export_tags.entry(key.clone()).or_default();
@@ -269,8 +273,11 @@ pub fn specs() -> Vec<CommandSpec> {
if let Some(c) = class.clone() {
e.class = c;
}
if let Some(v) = split {
e.split = v;
}
let out = serde_json::to_value(&*e).unwrap_or_default();
if e.tag.is_empty() && e.class.is_empty() {
if *e == designcraft_doc::ExportTag::default() {
st.export_tags.remove(&key);
}
Ok(out)
@@ -302,7 +309,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Paragraph Style…",
[],
None,
"{name, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, firstLineIndent, spaceBefore, spaceAfter, …; startAt?: null = continue numbering; grepStyles?: [{style, pattern}]: a pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}, fromSelection?: bool}",
"{name, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, firstLineIndent, spaceBefore, spaceAfter, ignoreOpticalMargin, …; startAt?: null = continue numbering; grepStyles?: [{style, pattern}]: a pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}, fromSelection?: bool}",
has_doc,
create_para
),
@@ -311,7 +318,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Paragraph Style Options…",
[],
None,
"{name, rename?, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, spaceBefore, …; ruleAbove?/ruleBelow?: only the named rule fields change; startAt?: null = continue numbering; kinsoku?: set | null (default rules); a grepStyles pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}}",
"{name, rename?, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, spaceBefore, ignoreOpticalMargin, …; ruleAbove?/ruleBelow?: only the named rule fields change; startAt?: null = continue numbering; kinsoku?: set | null (default rules); a grepStyles pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}}",
has_doc,
edit_para
),
@@ -934,7 +941,7 @@ fn edit_para(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("style.paragraph.edit", "missing name"))?.to_string();
let chars: CharAttrs = attrs("style.paragraph.edit", "chars", p, |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.edit")?;
let rename = str_param(p, "rename").map(str::to_string);
let rename = str_param(p, "rename").map(str::trim).filter(|n| *n != name).map(str::to_string);
let based = p.get("basedOn").cloned();
s.edit(|d, _| {
if let Some(Value::String(b)) = &based
@@ -942,6 +949,14 @@ fn edit_para(s: &mut Session, p: &Value) -> Result<Value> {
{
return Err(bad("style.paragraph.edit", "based-on would create a cycle"));
}
if let Some(n) = &rename {
if n.is_empty() || n.starts_with('[') {
return Err(bad("style.paragraph.edit", format!("can't rename to `{n}`")));
}
if d.styles.para(n).is_some() {
return Err(bad("style.paragraph.edit", format!("a paragraph style named `{n}` already exists")));
}
}
// A rule object changes only the rule fields it names, over the style's resolved rule.
let (current, _) = d.styles.resolve_para_style(&name);
let mut para: ParaAttrs = attrs("style.paragraph.edit", "para", p, |a: &mut ParaAttrs, k, v| a.set_json_over(k, v, &current))?;
@@ -964,24 +979,35 @@ fn edit_para(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// Rename a paragraph or character style everywhere it's used.
/// Rename a paragraph or character style everywhere it's used; its Export Tagging follows.
fn rename_style(d: &mut designcraft_doc::Document, para: bool, from: &str, to: &str) {
let st = d.styles_mut();
if para {
for s in &mut d.styles_mut().paragraph {
for s in &mut st.paragraph {
if s.name == from {
s.name = to.to_string();
}
}
} else {
for s in &mut d.styles_mut().character {
for s in &mut st.character {
if s.name == from {
s.name = to.to_string();
}
}
}
// An entry already under `to` belongs to no style (names are unique), so it never survives.
st.export_tags.remove(&export_tag_key(para, to));
if let Some(tag) = st.export_tags.remove(&export_tag_key(para, from)) {
st.export_tags.insert(export_tag_key(para, to), tag);
}
redirect_style_refs(d, para, from, to);
}
/// The `Styles::export_tags` key of a paragraph (`para`) or character style.
fn export_tag_key(para: bool, name: &str) -> String {
format!("{}:{name}", if para { "p" } else { "c" })
}
/// Point every reference to style `from` at `to`, except the style's own registry entry (its
/// `name` field) — shared by renaming (which also updates the entry's name) and deletion (which
/// instead removes the entry, after its former uses point at the replacement).
@@ -1020,6 +1046,9 @@ fn redirect_style_refs(d: &mut designcraft_doc::Document, para: bool, from: &str
*r = Some(to.to_string());
}
}
if let designcraft_doc::EpubSplit::Style(n) = &mut d.settings.epub_split {
rename(n);
}
} else {
let from_parent = parent_of(st.character.iter().find(|x| x.name == from).and_then(|x| x.based_on.as_ref()));
for s in &mut st.character {
@@ -1165,8 +1194,10 @@ fn delete_style(s: &mut Session, p: &Value, para: bool) -> Result<Value> {
for b in based.into_iter().filter(|b| b.as_deref() == Some(name.as_str())) {
*b = parent.clone();
}
// The replacement keeps its own Export Tagging.
st.export_tags.remove(&export_tag_key(para, &name));
// Every other use: styles, stories, table cells, nested and GREP styles, notes, defaults.
rename_style(d, para, &name, &repl);
redirect_style_refs(d, para, &name, &repl);
ok()
})
}
@@ -1220,7 +1251,7 @@ fn edit_char(s: &mut Session, p: &Value) -> Result<Value> {
.and_then(Value::as_object)
.map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect())
.unwrap_or_default();
let rename = str_param(p, "rename").filter(|n| *n != name).map(|n| n.trim().to_string());
let rename = str_param(p, "rename").map(str::trim).filter(|n| *n != name).map(str::to_string);
let based: Option<Option<String>> = match p.get("basedOn") {
None => None,
Some(Value::Null) => Some(None),
@@ -1237,10 +1268,13 @@ fn edit_char(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad("style.character.edit", "based-on would create a cycle"));
}
}
if let Some(n) = &rename
&& (n.is_empty() || n.starts_with('[') || d.styles.char_style(n).is_some())
{
return Err(bad("style.character.edit", format!("can't rename to `{n}`")));
if let Some(n) = &rename {
if n.is_empty() || n.starts_with('[') {
return Err(bad("style.character.edit", format!("can't rename to `{n}`")));
}
if d.styles.char_style(n).is_some() {
return Err(bad("style.character.edit", format!("a character style named `{n}` already exists")));
}
}
let st = d.styles_mut().char_style_mut(&name).ok_or_else(|| bad("style.character.edit", format!("no character style `{name}`")))?;
st.chars.merge(&chars);
@@ -2810,6 +2844,13 @@ mod export_tag_tests {
s.execute("style.exportTag", &json!({"style": "Chapter Title", "tag": "h1", "class": "chapter"})).unwrap();
s.execute("style.exportTag", &json!({"style": "Key Term", "character": true, "tag": "strong"})).unwrap();
assert!(s.execute("style.exportTag", &json!({"style": "Nope"})).is_err());
// Split Document is for paragraph styles; it alone keeps the tagging.
assert!(s.execute("style.exportTag", &json!({"style": "Key Term", "character": true, "split": true})).is_err());
s.execute("style.paragraph.create", &json!({"name": "Part"})).unwrap();
assert_eq!(s.execute("style.exportTag", &json!({"style": "Part", "split": true})).unwrap()["split"], true);
assert!(s.doc().unwrap().doc.styles.export_tag("Part", false).is_some_and(|t| t.split));
s.execute("style.exportTag", &json!({"style": "Part", "split": false})).unwrap();
assert!(s.doc().unwrap().doc.styles.export_tag("Part", false).is_none(), "an untouched tagging goes");
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Origins\nA word"})).unwrap();
let sid = r["story"].as_u64().unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap();
@@ -2826,6 +2867,78 @@ mod export_tag_tests {
assert!(text.contains("/S/H1") && text.contains("/S/P"), "structure elements");
assert!(designcraft_render::pdf_page_count(&pdf) == Some(1));
}
#[test]
fn export_tagging_follows_a_renamed_style_and_goes_with_a_deleted_one() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("style.paragraph.create", &json!({"name": "Head"})).unwrap();
s.execute("style.paragraph.create", &json!({"name": "Body"})).unwrap();
s.execute("style.character.create", &json!({"name": "Term"})).unwrap();
s.execute("style.exportTag", &json!({"style": "Head", "tag": "h2", "class": "head"})).unwrap();
s.execute("style.exportTag", &json!({"style": "Body", "tag": "p", "class": "body"})).unwrap();
s.execute("style.exportTag", &json!({"style": "Term", "character": true, "tag": "em", "class": "term"})).unwrap();
let tag = |s: &Session, name: &str, character: bool| {
s.doc().unwrap().doc.styles.export_tag(name, character).map(|t| (t.tag.clone(), t.class.clone()))
};
let pair = |t: &str, c: &str| Some((t.to_string(), c.to_string()));
// A rename onto another style's name is refused, so neither tagging is lost.
assert!(s.execute("style.paragraph.edit", &json!({"name": "Head", "rename": "Body"})).is_err());
assert_eq!((tag(&s, "Head", false), tag(&s, "Body", false)), (pair("h2", "head"), pair("p", "body")));
s.execute("style.paragraph.edit", &json!({"name": "Head", "rename": "Title"})).unwrap();
s.execute("style.character.edit", &json!({"name": "Term", "rename": "Word"})).unwrap();
assert_eq!(tag(&s, "Title", false), pair("h2", "head"));
assert_eq!(tag(&s, "Head", false), None);
assert_eq!(tag(&s, "Word", true), pair("em", "term"));
assert_eq!(tag(&s, "Term", true), None);
// Moving a style into a group renames it to Group/Name.
s.execute("style.group", &json!({"kind": "paragraph", "names": ["Title"], "group": "Heads"})).unwrap();
assert_eq!(tag(&s, "Heads/Title", false), pair("h2", "head"));
assert_eq!(tag(&s, "Title", false), None);
for _ in 0..3 {
s.execute("edit.undo", &json!({})).unwrap();
}
assert_eq!(tag(&s, "Head", false), pair("h2", "head"));
assert_eq!(tag(&s, "Title", false), None);
assert_eq!(tag(&s, "Term", true), pair("em", "term"));
assert_eq!(tag(&s, "Word", true), None);
// Deleting drops the deleted style's tagging; the replacement keeps its own.
s.execute("style.paragraph.delete", &json!({"name": "Head", "replaceWith": "Body"})).unwrap();
assert_eq!(tag(&s, "Head", false), None);
assert_eq!(tag(&s, "Body", false), pair("p", "body"));
s.execute("style.character.delete", &json!({"name": "Term"})).unwrap();
assert_eq!(tag(&s, "Term", true), None);
assert!(s.doc().unwrap().doc.styles.export_tags.keys().all(|k| k == "p:Body"));
}
#[test]
fn a_rename_onto_a_taken_name_says_so_and_a_trimmed_rename_drops_a_stale_tag() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
for (kind, a, b) in [("paragraph", "Head", "Body"), ("character", "Term", "Word")] {
s.execute(&format!("style.{kind}.create"), &json!({"name": a})).unwrap();
s.execute(&format!("style.{kind}.create"), &json!({"name": b})).unwrap();
let e = s.execute(&format!("style.{kind}.edit"), &json!({"name": a, "rename": b})).unwrap_err().to_string();
assert!(e.contains(&format!("a {kind} style named `{b}` already exists")), "{e}");
let e = s.execute(&format!("style.{kind}.edit"), &json!({"name": a, "rename": "[None]"})).unwrap_err().to_string();
assert!(e.contains("can't rename to `[None]`"), "{e}");
}
// An entry left under a name no style has doesn't attach to a style renamed onto it.
s.edit(|d, _| {
d.styles_mut()
.export_tags
.insert("p:Title".into(), designcraft_doc::ExportTag { tag: "h6".into(), class: "stale".into(), ..Default::default() });
Ok(())
})
.unwrap();
s.execute("style.paragraph.edit", &json!({"name": "Head", "rename": " Title "})).unwrap();
let st = &s.doc().unwrap().doc.styles;
assert!(st.para("Title").is_some(), "the new name is trimmed");
assert_eq!(st.export_tag("Title", false), None);
}
}
#[cfg(test)]
@@ -2981,6 +3094,7 @@ mod delete_tests {
d.footnote_options.para_style = "Table Body".into();
d.endnote_options.para_style = "Table Body".into();
d.endnote_options.heading_style = "Table Body".into();
d.settings.epub_split = designcraft_doc::EpubSplit::Style("Table Body".into());
d.toc = Some(Toc {
story,
title: "Contents".into(),
@@ -2999,6 +3113,7 @@ mod delete_tests {
assert_eq!((sub.based_on.as_deref(), sub.next_style.as_deref()), (Some("Body"), Some("Body")));
assert_eq!(d.styles.default_paragraph, "Body");
assert_eq!(d.endnote_options.heading_style, "Body");
assert_eq!(d.settings.epub_split, designcraft_doc::EpubSplit::Style("Body".into()), "EPUB Split Document follows the replacement");
let after = s.execute("preflight.run", &json!({})).unwrap();
assert!(!after.to_string().contains("Table Body"), "{after}");
}
+21 -1
View File
@@ -1765,7 +1765,7 @@ fn table_style_fields(ts: &mut designcraft_doc::TableStyle, p: &Value, border: O
ts.borders = Default::default();
}
if let Some(a) = p.get("altRows").and_then(Value::as_object) {
let n = |k: &str, d: u32| a.get(k).and_then(Value::as_u64).map_or(d, |v| v as u32);
let n = |k: &str, d: u32| a.get(k).and_then(Value::as_u64).map_or(d, |v| u32::try_from(v).unwrap_or(u32::MAX));
let c = |k: &str| a.get(k).and_then(Value::as_str).unwrap_or(designcraft_color::swatch::NONE).to_string();
let t = |k: &str| a.get(k).and_then(Value::as_f64).map_or(1.0, |v| v.clamp(0.0, 1.0) as f32);
ts.alt_rows = Some(designcraft_doc::AltFills {
@@ -2017,6 +2017,7 @@ fn place_graphic(s: &mut Session, p: &Value) -> Result<Value> {
let t = st.table_mut(g.table).ok_or_else(|| bad(ID, "no table"))?;
let cell = t.cell_mut(r, c).ok_or_else(|| bad(ID, format!("no cell {r},{c}")))?;
cell.graphic = Some(designcraft_doc::Graphic {
layer_options: None,
asset: aid,
size: (nw, nh),
xf,
@@ -2480,3 +2481,22 @@ mod resize_border_tests {
assert!(s.execute("table.resizeBorder", &json!({"frame": f, "table": t, "axis": "column", "index": 0, "delta": 5})).is_err());
}
}
#[cfg(test)]
mod style_alt_rows_tests {
use serde_json::json;
use super::table_style_fields;
#[test]
fn style_alt_rows_counts_saturate() {
let mut ts = designcraft_doc::TableStyle::default();
let p = json!({"altRows": {"first": 4294967297u64, "next": 4294967296u64, "skipFirst": 4294967295u64, "skipLast": 3}});
table_style_fields(&mut ts, &p, None);
let a = ts.alt_rows.unwrap();
assert_eq!(a.first, u32::MAX, "first");
assert_eq!(a.next, u32::MAX, "next");
assert_eq!(a.skip_first, u32::MAX, "skip_first");
assert_eq!(a.skip_last, 3, "skip_last");
}
}
+243 -5
View File
@@ -104,6 +104,15 @@ pub fn specs() -> Vec<CommandSpec> {
})
}
),
cmd!(
"story.options",
"Story Options",
[],
None,
"{story?, frame?: a text frame of the story (default: the stories of the text selection or the selected frames), opticalMargin?: bool — Optical Margin Alignment: punctuation and letter edges hang outside the frame in every paragraph of the story except those with ignoreOpticalMargin, opticalMarginSize?: pt (0.1–1296, default 12) — the type size the hang amounts are set for} → {stories}",
has_doc,
story_options
),
cmd!("story.setText", "Set Story Text", [], None, "{story, text} — replace a story's whole text", has_doc, |s, p| {
let sid = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("story.setText", "missing story"))?);
let text = super::text_param(p, "text");
@@ -182,7 +191,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Paragraph Formatting",
[],
None,
"{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, dropCapChars?, dropCapStyle?, dropCapAlignLeft?, dropCapScaleDescenders?, hyphenate?: bool (default true), composer?, tabs?, ruleAbove?/ruleBelow?: {on?, weight?, color?, tint?, columnWidth?, offset?, leftIndent?, rightIndent?} (only the named rule fields change), …}}",
"{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, dropCapChars?, dropCapStyle?, dropCapAlignLeft?, dropCapScaleDescenders?, hyphenate?: bool (default true), composer?, opticalMargin?: bool (hang at each character's own size; a story with story.options opticalMargin overrides it), ignoreOpticalMargin?: bool (no hang in the paragraph), tabs?, ruleAbove?/ruleBelow?: {on?, weight?, color?, tint?, columnWidth?, offset?, leftIndent?, rightIndent?} (only the named rule fields change), …}}",
has_text_or_frames,
|s, p| format_paras(s, p.get("attrs").unwrap_or(p))
),
@@ -374,7 +383,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Selection Attributes",
[],
None,
"{mixed?: bool} → resolved character/paragraph attributes at the text selection; with mixed, paraMixed lists the paragraph attributes that differ across the selected paragraphs; the selection's start as `selectedText` (one line, up to 60 characters)",
"{mixed?: bool} → resolved character/paragraph attributes at the text selection; with mixed, paraMixed lists the paragraph attributes that differ across the selected paragraphs; the selection's start as `selectedText` (one line, up to 60 characters); `storyOptions` {opticalMargin, opticalMarginSize} of the stories story.options would change (null where they differ)",
has_doc,
selection_attrs
),
@@ -991,11 +1000,11 @@ fn sentence_bounds(s: &str, i: usize) -> (usize, usize) {
(pa + start, pb)
}
/// Type ▸ Story Direction.
fn story_direction(s: &mut Session, p: &Value) -> Result<Value> {
let vertical = p.get("vertical").and_then(Value::as_bool).ok_or_else(|| bad("type.storyDirection", "missing vertical"))?;
/// The stories of the text selection, the selected cells and the selected text frames.
fn selected_stories(s: &Session) -> Result<Vec<StoryId>> {
let st = s.doc()?;
let mut stories: Vec<StoryId> = st.selection.text.map(|t| t.story).into_iter().chain(st.selection.cells.map(|c| c.story)).collect();
stories.dedup();
for id in &st.selection.items {
if let Some(tf) = st.doc.item(*id).and_then(|i| i.text_frame())
&& !stories.contains(&tf.story)
@@ -1003,6 +1012,71 @@ fn story_direction(s: &mut Session, p: &Value) -> Result<Value> {
stories.push(tf.story);
}
}
Ok(stories)
}
/// Type ▸ Story: the story-wide options (Optical Margin Alignment and its size).
fn story_options(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "story.options";
let on = match p.get("opticalMargin") {
None => None,
Some(v) => Some(v.as_bool().ok_or_else(|| bad(CMD, "`opticalMargin` must be a bool"))?),
};
let size = match p.get("opticalMarginSize") {
None => None,
Some(v) => Some(
v.as_f64()
.and_then(designcraft_doc::OpticalMargin::clamp_size)
.ok_or_else(|| bad(CMD, "`opticalMarginSize` must be a finite number of points"))?,
),
};
if on.is_none() && size.is_none() {
return Err(bad(CMD, "nothing to set: give opticalMargin or opticalMarginSize"));
}
let explicit = |key| p.get(key).filter(|v| !v.is_null());
let stories = if explicit("story").is_some() || explicit("frame").is_some() {
// Explicit targets must not silently fall back to the current selection.
let doc = &s.doc()?.doc;
for key in ["story", "frame"] {
if let Some(value) = explicit(key) {
let id = value.as_u64().ok_or_else(|| bad(CMD, format!("`{key}` must be an unsigned integer")))?;
let sid = if key == "story" {
StoryId(id)
} else {
let item = doc.item(ItemId(id)).ok_or_else(|| bad(CMD, format!("no such frame {id}")))?;
item.text_frame().ok_or_else(|| bad(CMD, format!("frame {id} holds no text")))?.story
};
if doc.story(sid).is_none() {
return Err(bad(CMD, format!("no such story {}", sid.0)));
}
}
}
vec![story_of(s, p).ok_or_else(|| bad(CMD, "no such story"))?]
} else {
selected_stories(s)?
};
if stories.is_empty() {
return Err(bad(CMD, "no story: select text or a text frame, or give story or frame"));
}
s.edit(|d, _| {
for sid in &stories {
let st = d.story_mut(*sid).ok_or(designcraft_doc::DocError::NoStory(*sid))?;
let mut om = st.optical_margin;
om.on = on.unwrap_or(om.on);
om.size = size.unwrap_or(om.size);
if om != st.optical_margin {
st.optical_margin = om;
st.rev = st.rev.wrapping_add(1);
}
}
Ok(json!({"stories": stories.len()}))
})
}
/// Type ▸ Story Direction.
fn story_direction(s: &mut Session, p: &Value) -> Result<Value> {
let vertical = p.get("vertical").and_then(Value::as_bool).ok_or_else(|| bad("type.storyDirection", "missing vertical"))?;
let stories = selected_stories(s)?;
s.edit(|d, _| {
set_story_direction(d, &stories, vertical);
Ok(json!({"stories": stories.len()}))
@@ -1836,12 +1910,30 @@ fn selection_attrs(s: &mut Session, p: &Value) -> Result<Value> {
"paraOverrides": style_para.delta(&pp).count() + style_chars.delta(&base).count(),
"charOverrides": char_delta.count(), "characterStyleOverrides": character_style_overrides,
"selectedText": selected.trim()});
let mut stories = selected_stories(s)?;
if stories.is_empty() {
stories.push(sid);
}
out["storyOptions"] = story_options_of(&st.doc, &stories);
if bool_or(p, "mixed", false) {
out["paraMixed"] = json!(mixed_para_attrs(&st.doc, &targets));
}
Ok(out)
}
/// The story options of `stories`, as story.options names them; a value that differs between
/// the stories is null.
fn story_options_of(doc: &designcraft_doc::Document, stories: &[StoryId]) -> Value {
let mut values = stories.iter().filter_map(|sid| doc.story(*sid)).map(|st| st.optical_margin);
let Some(first) = values.next() else { return json!({"opticalMargin": null, "opticalMarginSize": null}) };
let (mut on, mut size) = (Some(first.on), Some(first.size));
for om in values {
on = on.filter(|v| *v == om.on);
size = size.filter(|v| *v == om.size);
}
json!({"opticalMargin": on, "opticalMarginSize": size})
}
/// The paragraph attributes whose resolved values differ across the paragraphs of `targets`.
fn mixed_para_attrs(doc: &designcraft_doc::Document, targets: &[Target]) -> Vec<String> {
let mut first: Option<serde_json::Map<String, Value>> = None;
@@ -3280,6 +3372,152 @@ mod bidi_caret_tests {
}
}
#[cfg(test)]
mod story_options_tests {
use super::*;
#[test]
fn malformed_explicit_story_targets_do_not_change_the_selection() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "Selected"})).unwrap();
let sid = StoryId(r["story"].as_u64().unwrap());
for target in ["story", "frame"] {
for invalid in [json!("invalid"), json!(-1), json!(1.5), json!(true), json!({}), json!([]), json!(999_999)] {
let mut params = json!({"opticalMargin": true});
params[target] = invalid;
assert!(s.execute("story.options", &params).is_err(), "{params}");
assert!(!story(&s, sid).on, "{params} changed the selected story");
}
}
// Selection fallback still works when neither explicit target is supplied.
s.execute("story.options", &json!({"opticalMargin": true})).unwrap();
assert!(story(&s, sid).on);
s.execute("story.options", &json!({"frame": r["id"], "opticalMargin": false})).unwrap();
assert!(!story(&s, sid).on);
}
#[test]
fn story_options_treat_null_targets_as_absent_and_validate_both_explicit_ids() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "Explicit"})).unwrap();
let b = s.execute("frame.create", &json!({"rect": [0, 300, 300, 500], "content": "text", "text": "Selected"})).unwrap();
let aid = StoryId(a["story"].as_u64().unwrap());
let bid = StoryId(b["story"].as_u64().unwrap());
for targets in [json!({"story": null}), json!({"frame": null}), json!({"story": null, "frame": null})] {
let mut p = targets;
p["opticalMargin"] = json!(true);
s.execute("story.options", &p).unwrap();
assert!(story(&s, bid).on);
assert!(!story(&s, aid).on);
s.execute("edit.undo", &json!({})).unwrap();
assert!(!story(&s, bid).on);
}
for targets in [json!({"story": null, "frame": a["id"]}), json!({"story": a["story"], "frame": null})] {
let mut p = targets;
p["opticalMargin"] = json!(true);
s.execute("story.options", &p).unwrap();
assert!(story(&s, aid).on);
assert!(!story(&s, bid).on);
s.execute("edit.undo", &json!({})).unwrap();
}
// Even a frame ignored by story precedence must be valid when explicitly supplied.
let non_text = s.execute("frame.create", &json!({"rect": [400, 0, 500, 100]})).unwrap();
for (targets, message) in [
(json!({"frame": 999_999}), "no such frame 999999".to_string()),
(json!({"frame": non_text["id"]}), format!("frame {} holds no text", non_text["id"])),
(json!({"story": 999_999}), "no such story 999999".to_string()),
(json!({"story": a["story"], "frame": 999_999}), "no such frame 999999".to_string()),
] {
let mut p = targets;
p["opticalMargin"] = json!(true);
assert!(s.execute("story.options", &p).unwrap_err().to_string().contains(&message), "{p}: expected {message}");
assert!(!story(&s, aid).on);
assert!(!story(&s, bid).on);
}
}
fn first_x(s: &Session, sid: StoryId) -> f64 {
let cs = s.cache.get(&s.doc().unwrap().doc, sid, None);
cs.frames[0].lines[0].glyphs[0].x
}
fn story(s: &Session, sid: StoryId) -> designcraft_doc::OpticalMargin {
s.doc().unwrap().doc.story(sid).unwrap().optical_margin
}
#[test]
fn story_options_set_optical_margin_with_one_undo_step_each() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "“Hung” words."})).unwrap();
let sid = StoryId(r["story"].as_u64().unwrap());
let flush = first_x(&s, sid);
// The selected frame's story.
s.execute("story.options", &json!({"opticalMargin": true})).unwrap();
assert_eq!(story(&s, sid), designcraft_doc::OpticalMargin { on: true, size: 12.0 });
let hung = first_x(&s, sid);
assert!(hung < flush - 1.0, "{hung} {flush}");
s.execute("story.options", &json!({"story": sid.0, "opticalMarginSize": 24})).unwrap();
assert_eq!(story(&s, sid).size, 24.0);
assert!(first_x(&s, sid) < hung - 1.0);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(story(&s, sid), designcraft_doc::OpticalMargin { on: true, size: 12.0 });
s.execute("edit.undo", &json!({})).unwrap();
assert!(!story(&s, sid).on);
assert_eq!(first_x(&s, sid), flush);
// Hostile sizes.
s.execute("story.options", &json!({"story": sid.0, "opticalMarginSize": 1e9})).unwrap();
assert_eq!(story(&s, sid).size, designcraft_doc::OpticalMargin::MAX_SIZE);
for bad in [json!({"opticalMarginSize": "big"}), json!({"opticalMargin": 1}), json!({}), json!({"story": 999_999, "opticalMargin": true})] {
assert!(s.execute("story.options", &bad).is_err(), "{bad}");
}
}
#[test]
fn ignore_optical_margin_through_type_para_and_paragraph_styles() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "“Hung” words."})).unwrap();
let sid = StoryId(r["story"].as_u64().unwrap());
let flush = first_x(&s, sid);
s.execute("story.options", &json!({"opticalMargin": true})).unwrap();
assert!(first_x(&s, sid) < flush - 1.0);
let ignored = |s: &Session| s.doc().unwrap().doc.story(sid).unwrap().paras[0].para.ignore_optical_margin;
s.execute("type.para", &json!({"attrs": {"ignoreOpticalMargin": true}})).unwrap();
assert_eq!(ignored(&s), Some(true));
assert_eq!(first_x(&s, sid), flush);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(ignored(&s), None);
s.execute("style.paragraph.create", &json!({"name": "Flush", "para": {"ignoreOpticalMargin": true}})).unwrap();
let resolved = |s: &Session| s.doc().unwrap().doc.styles.resolve_para_style("Flush").0.ignore_optical_margin;
assert!(resolved(&s));
s.execute("style.paragraph.edit", &json!({"name": "Flush", "para": {"ignoreOpticalMargin": false}})).unwrap();
assert!(!resolved(&s));
s.execute("edit.undo", &json!({})).unwrap();
assert!(resolved(&s));
s.execute("style.paragraph.apply", &json!({"name": "Flush"})).unwrap();
assert_eq!(first_x(&s, sid), flush);
}
#[test]
fn selection_attrs_report_the_story_options_with_differences_as_null() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "One"})).unwrap();
let b = s.execute("frame.create", &json!({"rect": [0, 300, 300, 500], "content": "text", "text": "Two"})).unwrap();
let options = |s: &mut Session| s.execute("type.selectionAttrs", &json!({})).unwrap()["storyOptions"].clone();
assert_eq!(options(&mut s), json!({"opticalMargin": false, "opticalMarginSize": 12.0}));
s.execute("story.options", &json!({"story": b["story"], "opticalMargin": true, "opticalMarginSize": 18})).unwrap();
assert_eq!(options(&mut s), json!({"opticalMargin": true, "opticalMarginSize": 18.0}), "the selected frame's story");
s.execute("selection.set", &json!({"ids": [a["id"], b["id"]]})).unwrap();
assert_eq!(options(&mut s), json!({"opticalMargin": null, "opticalMarginSize": null}));
s.execute("story.options", &json!({"opticalMarginSize": 18})).unwrap();
assert_eq!(options(&mut s), json!({"opticalMargin": null, "opticalMarginSize": 18.0}));
}
}
#[cfg(test)]
mod arabic_tests {
use super::*;
+421
View File
@@ -73,9 +73,256 @@ pub fn specs() -> Vec<CommandSpec> {
s.execute("text.insert", &json!({"text": c.to_string()}))
}
),
cmd!(
"variables.convertToText",
"Convert Variable to Text",
["Type", "Text Variables"],
None,
"{} — the variable instances in the selected text become plain text of the value each shows on its page; the variables stay defined",
has_selected_instance,
convert_selection
),
cmd!(
"variables.convertAllToText",
"Convert to Text",
[],
None,
"{name} — every instance of the variable in the document becomes plain text of the value it shows on its page (Text Variables › Convert to Text); the variable stays defined",
has_doc,
convert_all
),
]
}
/// The selected text holds a variable instance (InDesign: select the variable, then Convert
/// Variable to Text). The endnote story is rebuilt from the stories' endnotes, so an edit there
/// would not last.
fn has_selected_instance(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
let st = s.active().ok_or("no document")?;
let t = st.selection.text.ok_or("no text selected")?;
let held = st.doc.endnote_story != Some(t.story)
&& st
.doc
.text_story(t.story, t.cell)
.and_then(|x| x.text.get(t.range()))
.is_some_and(|x| x.chars().any(|c| designcraft_doc::vars::var_index(c).is_some()));
if held { Ok(()) } else { Err("no text variable selected".into()) }
}
/// The page story byte `pos` is shown on, which a variable's value depends on: overset text takes
/// the page of the story's last frame; parent pages and the pasteboard have none.
fn shown_page(d: &designcraft_doc::Document, cs: &designcraft_compose::ComposedStory, pos: usize) -> Option<usize> {
designcraft_compose::vars::page_of(d, cs, pos).or_else(|| cs.frames.last().and_then(|f| d.page_of_item(f.frame)))
}
/// Values of every variable on the pages asked for, as composition shows them (running headers
/// included), computed once per page.
struct PageValues<'a> {
doc: &'a designcraft_doc::Document,
running: std::sync::Arc<designcraft_compose::vars::RunningIndex>,
pages: std::collections::HashMap<Option<usize>, Vec<String>>,
}
impl PageValues<'_> {
fn get(&mut self, i: usize, page: Option<usize>) -> String {
let (doc, running) = (self.doc, &self.running);
let v = self.pages.entry(page).or_insert_with(|| designcraft_compose::vars::values(doc, page, Some(running)));
v.get(i).cloned().unwrap_or_default()
}
}
/// Byte offsets of the variable instances in `range` of `text` (of variable `which`, or any),
/// with their variable index.
fn instances(text: &str, range: std::ops::Range<usize>, which: Option<usize>) -> Vec<(usize, usize)> {
let Some(t) = text.get(range.clone()) else { return Vec::new() };
t.char_indices()
.filter_map(|(k, c)| designcraft_doc::vars::var_index(c).filter(|i| which.is_none_or(|w| w == *i)).map(|i| (range.start + k, i)))
.collect()
}
/// Replaces the instances at `at` (byte, variable) in `st` with `value(byte)`, last first so the
/// earlier offsets hold. Returns the change in length before each replaced byte, in text order.
fn replace_instances(st: &mut designcraft_doc::Story, at: &[(usize, usize)], value: &dyn Fn(usize) -> String) -> Vec<(usize, isize)> {
let mut grown = Vec::new();
for &(pos, i) in at.iter().rev() {
let len = var_char(i).map_or(0, char::len_utf8);
let v = value(pos);
st.replace(pos..pos + len, &v);
grown.push((pos, v.len() as isize - len as isize));
}
grown.reverse();
grown
}
/// Moves byte `b` of a story by the growth of the instances replaced before it.
fn shifted(b: usize, grown: &[(usize, isize)]) -> usize {
let d: isize = grown.iter().filter(|(pos, _)| *pos < b).map(|(_, g)| g).sum();
b.saturating_add_signed(d)
}
/// Replaces every instance of variable `i` in `st`, its table cells and its notes with `value`.
/// Returns the growth of `st`'s own text, as [`replace_instances`].
fn replace_nested(st: &mut designcraft_doc::Story, i: usize, value: &str, depth: usize) -> Vec<(usize, isize)> {
if depth > MAX_DEPTH {
return Vec::new();
}
let at = instances(&st.text, 0..st.text.len(), Some(i));
let grown = replace_instances(st, &at, &|_| value.to_string());
let mut nested = false;
for t in st.tables.values_mut() {
if t.cells.iter().any(|c| holds(&c.text, i, depth + 1)) {
for c in &mut std::sync::Arc::make_mut(t).cells {
replace_nested(&mut c.text, i, value, depth + 1);
}
nested = true;
}
}
for note in st.notes.iter_mut().chain(st.endnotes.iter_mut()) {
if holds(&note.text, i, depth + 1) {
replace_nested(&mut std::sync::Arc::make_mut(note).text, i, value, depth + 1);
nested = true;
}
}
if nested {
st.rev = st.rev.wrapping_add(1);
}
grown
}
/// Whether `st` or text nested in it holds an instance of variable `i`.
fn holds(st: &designcraft_doc::Story, i: usize, depth: usize) -> bool {
depth <= MAX_DEPTH
&& (st.text.chars().any(|c| designcraft_doc::vars::var_index(c) == Some(i))
|| st.tables.values().any(|t| t.cells.iter().any(|c| holds(&c.text, i, depth + 1)))
|| st.notes.iter().chain(st.endnotes.iter()).any(|n| holds(&n.text, i, depth + 1)))
}
/// Byte offset in `st` of the anchor of table `id`.
fn table_anchor(st: &designcraft_doc::Story, id: u64) -> Option<usize> {
let (pi, _) = st.paras.iter().enumerate().find(|(_, p)| p.table == Some(id))?;
let r = st.para_ranges().into_iter().nth(pi)?;
Some(r.start + st.text.get(r)?.find(designcraft_doc::story::TABLE_ANCHOR)?)
}
fn convert_selection(s: &mut Session, _: &Value) -> Result<Value> {
const ID: &str = "variables.convertToText";
let t = s.doc()?.selection.text.ok_or_else(|| bad(ID, "no text selected"))?;
let range = t.range();
let (at, values) = {
let d = &s.doc()?.doc;
let st = d.story(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
let text = d.text_story(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
// Text in a cell or a footnote is on the page of its table's or note's anchor.
let anchor = match t.cell {
None => None,
Some(c) if c.table == designcraft_doc::FOOTNOTE_TABLE => st.note_anchor(c.row as u64),
Some(c) => table_anchor(st, c.table),
};
let cs = s.cache.get(d, t.story, None);
let mut pv = PageValues { doc: d, running: s.cache.running_index(d), pages: Default::default() };
let at = instances(&text.text, range.clone(), None);
let values: std::collections::HashMap<usize, String> =
at.iter().map(|&(pos, i)| (pos, pv.get(i, shown_page(d, &cs, anchor.unwrap_or(pos))))).collect();
(at, values)
};
if at.is_empty() {
return Err(bad(ID, "no text variable selected"));
}
s.edit(|d, sel| {
let st = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
let grown = replace_instances(st, &at, &|pos| values.get(&pos).cloned().unwrap_or_default());
// The converted text stays selected.
sel.text = Some(designcraft_doc::TextSel { anchor: range.start, focus: shifted(range.end, &grown), ..t });
Ok(json!({"converted": at.len()}))
})
}
/// What converting one story's instances of a variable writes: the value for each instance in
/// its own text, and for the text of each table (by id), footnote and endnote, the value on the
/// page of its anchor.
struct StoryPlan {
sid: designcraft_doc::StoryId,
own: Vec<(usize, String)>,
tables: Vec<(u64, String)>,
notes: Vec<String>,
endnotes: Vec<String>,
}
fn convert_all(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "variables.convertAllToText";
let name = str_param(p, "name").ok_or_else(|| bad(ID, "missing name"))?;
let (i, plans) = {
let d = &s.doc()?.doc;
let i = d.variable(name).ok_or_else(|| bad(ID, format!("no variable `{name}`")))?;
let mut pv = PageValues { doc: d, running: s.cache.running_index(d), pages: Default::default() };
let mut plans = Vec::new();
for (sid, st) in &d.stories {
if Some(*sid) == d.endnote_story || !holds(st, i, 0) {
continue;
}
let cs = s.cache.get(d, *sid, None);
let mut value = |pos: usize| pv.get(i, shown_page(d, &cs, pos));
let own = instances(&st.text, 0..st.text.len(), Some(i)).into_iter().map(|(pos, _)| (pos, value(pos))).collect();
let tables = st.tables.keys().map(|id| (*id, table_anchor(st, *id).map(&mut value).unwrap_or_default())).collect();
let notes = st.text.match_indices(designcraft_doc::FOOTNOTE_REF).map(|(k, _)| value(k)).collect();
let endnotes = st.text.match_indices(designcraft_doc::ENDNOTE_REF).map(|(k, _)| value(k)).collect();
plans.push(StoryPlan { sid: *sid, own, tables, notes, endnotes });
}
(i, plans)
};
s.edit(|d, sel| {
for plan in &plans {
// A selection in a table cell or a footnote keeps its place in that text: it is
// converted first, so the loop below finds nothing left there.
if let Some(t) = sel.text.as_mut().filter(|t| t.story == plan.sid)
&& let Some(c) = t.cell
{
let value = match c.footnote_id() {
Some(id) => d.story(plan.sid).and_then(|st| st.notes.iter().position(|n| n.id == id)).and_then(|k| plan.notes.get(k)),
None => plan.tables.iter().find(|(id, _)| *id == c.table).map(|(_, v)| v),
};
if let Some(v) = value
&& let Some(text) = d.text_story_mut(plan.sid, Some(c))
{
let grown = replace_nested(text, i, v, 1);
t.anchor = shifted(t.anchor, &grown);
t.focus = shifted(t.focus, &grown);
}
}
let st = d.story_mut(plan.sid).ok_or(designcraft_doc::DocError::NoStory(plan.sid))?;
let at: Vec<(usize, usize)> = plan.own.iter().map(|(pos, _)| (*pos, i)).collect();
let grown = replace_instances(st, &at, &|pos| plan.own.iter().find(|(k, _)| *k == pos).map(|(_, v)| v.clone()).unwrap_or_default());
let mut nested = false;
for (id, v) in &plan.tables {
if let Some(t) = st.tables.get_mut(id)
&& t.cells.iter().any(|c| holds(&c.text, i, 1))
{
for c in &mut std::sync::Arc::make_mut(t).cells {
replace_nested(&mut c.text, i, v, 1);
}
nested = true;
}
}
for (note, v) in st.notes.iter_mut().zip(&plan.notes).chain(st.endnotes.iter_mut().zip(&plan.endnotes)) {
if holds(&note.text, i, 1) {
replace_nested(&mut std::sync::Arc::make_mut(note).text, i, v, 1);
nested = true;
}
}
if nested {
st.rev = st.rev.wrapping_add(1);
}
// A selection in the story keeps its place in the text.
if let Some(t) = sel.text.as_mut().filter(|t| t.story == plan.sid && t.cell.is_none()) {
t.anchor = shifted(t.anchor, &grown);
t.focus = shifted(t.focus, &grown);
}
}
Ok(Value::Null)
})
}
/// Nesting limit for tables in cells and notes (hostile documents).
const MAX_DEPTH: usize = 32;
@@ -265,6 +512,180 @@ mod tests {
assert_eq!(shown(3), "Epsilon".len(), "the fourth page (named 2) carries it");
}
/// Three pages with "Alpha" in the Heading style on page 1 and "Epsilon" on page 3, a
/// `Chapter Head` running header on Heading, and a text frame on pages 1 and 3 that each hold
/// "<head>|<head>" (two instances). Returns the two frames' stories.
fn running_heads() -> (Session, designcraft_doc::StoryId, designcraft_doc::StoryId) {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap();
s.execute("style.paragraph.create", &json!({"name": "Heading"})).ok();
let frame = |s: &mut Session, page: usize, y: f64, text: &str| {
let sp = s.doc().unwrap().doc.page_loc(page).unwrap().0;
let r = s.execute("frame.create", &json!({"spread": sp, "rect": [72, y, 400, y + 60.0], "content": "text", "text": text})).unwrap();
s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story
};
for (page, text) in [(0, "Alpha"), (2, "Epsilon")] {
let sid = frame(&mut s, page, 200.0, text);
s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 1})).unwrap();
s.execute("style.paragraph.apply", &json!({"name": "Heading"})).unwrap();
}
s.execute("variables.define", &json!({"name": "Chapter Head", "type": "runningHeader", "style": "Heading"})).unwrap();
let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap();
let both = format!("{c}|{c}");
let (p1, p3) = (frame(&mut s, 0, 400.0, &both), frame(&mut s, 2, 400.0, &both));
(s, p1, p3)
}
/// Convert Variable to Text turns the selected instance into the value it shows on its own
/// page (Epsilon on page 3, not page 1's Alpha); the other instance and the variable stay.
#[test]
fn convert_variable_to_text_uses_the_instances_page() {
let (mut s, p1, p3) = running_heads();
let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap();
let second = c.len_utf8() + 1;
s.execute("text.select", &json!({"story": p3.0, "anchor": second, "focus": second + c.len_utf8()})).unwrap();
s.execute("variables.convertToText", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.story(p3).unwrap().text, format!("{c}|Epsilon"), "only the selected instance");
assert_eq!(d.story(p1).unwrap().text, format!("{c}|{c}"));
assert!(d.variable("Chapter Head").is_some(), "the variable stays defined");
let t = s.doc().unwrap().selection.text.unwrap();
assert_eq!((t.anchor, t.focus), (second, second + "Epsilon".len()), "the converted text stays selected");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.story(p3).unwrap().text, format!("{c}|{c}"), "one undo step");
}
/// The menu item is enabled only for a selection that holds an instance: not a caret, not
/// text without one.
#[test]
fn convert_variable_to_text_needs_a_selected_instance() {
let (mut s, p1, _) = running_heads();
let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap();
let bar = c.len_utf8();
let enabled = |s: &Session| (crate::find_command("variables.convertToText").unwrap().enabled)(s).is_ok();
for (anchor, focus) in [(0, 0), (bar, bar + 1)] {
s.execute("text.select", &json!({"story": p1.0, "anchor": anchor, "focus": focus})).unwrap();
assert!(!enabled(&s), "{anchor}..{focus}");
}
s.execute("text.select", &json!({"story": p1.0, "anchor": 0, "focus": bar})).unwrap();
assert!(enabled(&s));
}
/// A story threaded from a frame on page 1, which holds only "Lead", to a frame on page 3,
/// where the caret is left at the end of "Body". A cell's or a note's own byte 0 read in this
/// story is on page 1, its anchor on page 3.
fn threaded(s: &mut Session) -> designcraft_doc::StoryId {
let sp1 = s.doc().unwrap().doc.page_loc(0).unwrap().0;
let sp3 = s.doc().unwrap().doc.page_loc(2).unwrap().0;
let r = s.execute("frame.create", &json!({"spread": sp1, "rect": [72, 500, 400, 520], "content": "text", "text": "Lead\nBody"})).unwrap();
let from = r["id"].as_u64().unwrap();
s.execute("text.thread", &json!({"from": from, "rect": [72, 500, 400, 700], "spread": sp3})).unwrap();
let d = s.doc().unwrap().doc.clone();
let sid = d.item(designcraft_doc::ItemId(from)).unwrap().text_frame().unwrap().story;
let cs = s.cache.get(&d, sid, None);
let page = |pos| designcraft_compose::vars::page_of(&d, &cs, pos);
assert_eq!((page(0), page("Lead\nB".len())), (Some(0), Some(2)), "Lead on page 1, Body on page 3");
s.execute("text.select", &json!({"story": sid.0, "anchor": "Lead\nBody".len(), "focus": "Lead\nBody".len()})).unwrap();
sid
}
/// An instance selected in a table cell becomes the value on the page of the table's anchor.
#[test]
fn convert_variable_to_text_in_a_table_cell() {
let (mut s, ..) = running_heads();
let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap();
let sid = threaded(&mut s);
s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap();
s.execute("table.setCell", &json!({"row": 0, "col": 0, "text": c.to_string()})).unwrap();
let table = *s.doc().unwrap().doc.story(sid).unwrap().tables.keys().next().unwrap();
let cell = designcraft_doc::CellAddr { table, row: 0, col: 0 };
s.doc_mut().unwrap().selection =
designcraft_doc::Selection::text(designcraft_doc::TextSel { story: sid, anchor: 0, focus: c.len_utf8(), frame: None, cell: Some(cell) });
s.execute("variables.convertToText", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.text_story(sid, Some(cell)).unwrap().text, "Epsilon");
}
/// An instance selected in a footnote becomes the value on the page of the note's reference.
#[test]
fn convert_variable_to_text_in_a_footnote() {
let (mut s, ..) = running_heads();
let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap();
let sid = threaded(&mut s);
s.execute("footnote.insert", &json!({"text": c.to_string()})).unwrap();
let id = s.doc().unwrap().doc.story(sid).unwrap().notes[0].id;
let note = designcraft_doc::CellAddr::footnote(id);
let at = s.doc().unwrap().doc.text_story(sid, Some(note)).unwrap().text.find(c).unwrap();
s.doc_mut().unwrap().selection = designcraft_doc::Selection::text(designcraft_doc::TextSel {
story: sid,
anchor: at,
focus: at + c.len_utf8(),
frame: None,
cell: Some(note),
});
s.execute("variables.convertToText", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
let text = &d.text_story(sid, Some(note)).unwrap().text;
assert!(!text.contains(c) && text.ends_with("Epsilon"), "{text:?}");
}
/// Text Variables › Convert to Text turns every instance of the variable into the value on
/// its page, in table cells, footnotes and endnotes on the page of their anchor, and keeps the
/// variable.
#[test]
fn convert_all_instances_to_text() {
let (mut s, p1, p3) = running_heads();
let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap();
let sid = threaded(&mut s);
let at_end = |s: &mut Session| {
let n = s.doc().unwrap().doc.story(sid).unwrap().len();
s.execute("text.select", &json!({"story": sid.0, "anchor": n, "focus": n})).unwrap();
};
s.execute("footnote.insert", &json!({"text": c.to_string()})).unwrap();
at_end(&mut s);
s.execute("endnote.insert", &json!({"text": c.to_string()})).unwrap();
at_end(&mut s);
s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap();
s.execute("table.setCell", &json!({"row": 0, "col": 0, "text": c.to_string()})).unwrap();
s.execute("variables.convertAllToText", &json!({"name": "Chapter Head"})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.story(p1).unwrap().text, "Alpha|Alpha");
assert_eq!(d.story(p3).unwrap().text, "Epsilon|Epsilon");
let st = d.story(sid).unwrap();
assert_eq!(st.tables.values().next().unwrap().cells[0].text.text, "Epsilon", "table cell");
assert!(st.notes[0].text.text.ends_with("Epsilon"), "footnote: {:?}", st.notes[0].text.text);
assert!(st.endnotes[0].text.text.ends_with("Epsilon"), "endnote: {:?}", st.endnotes[0].text.text);
assert!(d.variable("Chapter Head").is_some(), "the variable stays defined");
}
/// A caret in a table cell or a footnote after an instance stays after the value Convert to
/// Text writes there, so typing lands after it.
#[test]
fn convert_all_keeps_the_caret_in_a_cell_or_a_footnote() {
for footnote in [false, true] {
let (mut s, ..) = running_heads();
let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap();
let sid = threaded(&mut s);
let text = format!("{c}|");
let at = if footnote {
s.execute("footnote.insert", &json!({"text": text})).unwrap();
designcraft_doc::CellAddr::footnote(s.doc().unwrap().doc.story(sid).unwrap().notes[0].id)
} else {
s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap();
s.execute("table.setCell", &json!({"row": 0, "col": 0, "text": text})).unwrap();
let table = *s.doc().unwrap().doc.story(sid).unwrap().tables.keys().next().unwrap();
designcraft_doc::CellAddr { table, row: 0, col: 0 }
};
let n = s.doc().unwrap().doc.text_story(sid, Some(at)).unwrap().text.len();
s.doc_mut().unwrap().selection =
designcraft_doc::Selection::text(designcraft_doc::TextSel { story: sid, anchor: n, focus: n, frame: None, cell: Some(at) });
s.execute("variables.convertAllToText", &json!({"name": "Chapter Head"})).unwrap();
s.execute("text.insert", &json!({"text": "x"})).unwrap();
let text = &s.doc().unwrap().doc.text_story(sid, Some(at)).unwrap().text;
assert!(text.ends_with("Epsilon|x"), "footnote {footnote}: {text:?}");
}
}
/// Deleting a variable flattens its instances in table cells too and moves the instances of
/// later variables down, so cells keep showing the same text.
#[test]
+170 -40
View File
@@ -1,15 +1,17 @@
//! The folders automation may read and write.
//!
//! `designcraft-cli mcp --automation-read-root <dir> --automation-write-root <dir>` confines the
//! files an agent's commands reach. [`AutomationRoots`] holds the two folders. While they are in force, every path a command
//! reads goes through [`check_read`] and every path it writes through [`check_write`]:
//! files an agent's commands reach; each flag may be given more than once (#1552). [`AutomationRoots`]
//! holds the folders of each kind. While they are in force, every path a command reads goes through
//! [`check_read`] and every path it writes through [`check_write`]:
//!
//! - The path is made absolute against the working directory, as the file system reads it. Its
//! links are followed: for a file that doesn't exist yet, the links of its nearest existing
//! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so
//! is a link that leads nowhere).
//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside
//! `/work`), without regard to case on Windows.
//! - The result must lie inside one of the roots of its kind, compared by whole components
//! (`/work2` isn't inside `/work`), without regard to case on Windows. Two roots don't make their
//! common parent one.
//! - Read and write access are separate: a root given alone grants only its own kind of access.
//!
//! Roots are in force on a thread inside [`confine`], and in the whole process after
@@ -32,11 +34,11 @@ pub enum Access {
Write,
}
/// The folders automation may read and write; a root left out grants no access of its kind.
/// The folders automation may read and write; a kind with no root grants no access of its kind.
#[derive(Debug)]
pub struct AutomationRoots {
read: Option<Root>,
write: Option<Root>,
read: Vec<Root>,
write: Vec<Root>,
}
#[derive(Debug)]
@@ -62,47 +64,61 @@ impl Root {
}
impl AutomationRoots {
/// The roots `read` and `write`, which must be existing folders; `None` when neither is given
/// (nothing is confined then).
pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result<Option<Arc<Self>>, String> {
if read.is_none() && write.is_none() {
/// The roots `read` and `write`, which must be existing folders (one given twice counts once);
/// `None` when neither kind has any (nothing is confined then).
pub fn new(read: &[&Path], write: &[&Path]) -> Result<Option<Arc<Self>>, String> {
if read.is_empty() && write.is_empty() {
return Ok(None);
}
let read = read.map(|p| Root::open(p, Access::Read)).transpose()?;
let write = write.map(|p| Root::open(p, Access::Write)).transpose()?;
Ok(Some(Arc::new(Self { read, write })))
let open = |paths: &[&Path], access: Access| -> Result<Vec<Root>, String> {
let mut roots: Vec<Root> = Vec::with_capacity(paths.len());
for path in paths {
let root = Root::open(path, access)?;
if !roots.iter().any(|r| r.resolved == root.resolved) {
roots.push(root);
}
}
Ok(roots)
};
Ok(Some(Arc::new(Self { read: open(read, Access::Read)?, write: open(write, Access::Write)? })))
}
/// The read root, absolute with its links followed.
pub fn read_root(&self) -> Option<&Path> {
self.read.as_ref().map(|r| r.shown.as_path())
/// The roots for people: `read A, B; write C` (`none` for a kind without any), absolute with
/// their links followed.
pub fn describe(&self) -> String {
let list = |roots: &[Root]| {
if roots.is_empty() {
return "none".to_string();
}
roots.iter().map(|r| r.shown.display().to_string()).collect::<Vec<_>>().join(", ")
};
format!("read {}; write {}", list(&self.read), list(&self.write))
}
/// The write root, absolute with its links followed.
pub fn write_root(&self) -> Option<&Path> {
self.write.as_ref().map(|r| r.shown.as_path())
}
/// May the file or folder at `path` be read (or written)? `Err` says why not.
/// May the file or folder at `path` be read (or written)? It must lie inside one of the roots
/// of that kind. `Err` says why not.
pub fn check(&self, path: impl AsRef<Path>, access: Access) -> Result<(), String> {
let path = path.as_ref();
let root = match access {
Access::Read => self.read.as_ref(),
Access::Write => self.write.as_ref(),
let roots = match access {
Access::Read => &self.read,
Access::Write => &self.write,
};
let Some(Root { resolved: folder, shown }) = root else {
if roots.is_empty() {
return match access {
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {}", path.display())),
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {}", path.display())),
};
};
if inside(&resolve(path)?, folder) {
}
let resolved = resolve(path)?;
if roots.iter().any(|r| inside(&resolved, &r.resolved)) {
return Ok(());
}
let root = shown.display();
match access {
Access::Read => Err(format!("automation path rejected: outside the read root {root}: {}", path.display())),
Access::Write => Err(format!("automation path rejected: outside the write root {root}: {}", path.display())),
let shown: Vec<String> = roots.iter().map(|r| r.shown.display().to_string()).collect();
match (shown.as_slice(), access) {
([root], Access::Read) => Err(format!("automation path rejected: outside the read root {root}: {}", path.display())),
([root], Access::Write) => Err(format!("automation path rejected: outside the write root {root}: {}", path.display())),
(_, Access::Read) => Err(format!("automation path rejected: outside the read roots {}: {}", shown.join(", "), path.display())),
(_, Access::Write) => Err(format!("automation path rejected: outside the write roots {}: {}", shown.join(", "), path.display())),
}
}
}
@@ -279,7 +295,7 @@ mod tests {
}
std::fs::write(base.join("in/a.designcraft"), "x").unwrap();
std::fs::write(base.join("outside/secret.designcraft"), "x").unwrap();
let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap();
let roots = AutomationRoots::new(&[&base.join("in")], &[&base.join("out")]).unwrap().unwrap();
(base, roots)
}
@@ -339,18 +355,18 @@ mod tests {
#[test]
fn a_missing_root_is_an_error_and_none_is_no_confinement() {
let (base, _) = folders("missing");
let e = AutomationRoots::new(Some(&base.join("nope")), None).unwrap_err();
let e = AutomationRoots::new(&[&base.join("nope")], &[]).unwrap_err();
assert!(e.starts_with("cannot open the automation read root"), "{e}");
let e = AutomationRoots::new(None, Some(&base.join("in/a.designcraft"))).unwrap_err();
let e = AutomationRoots::new(&[], &[&base.join("in/a.designcraft")]).unwrap_err();
assert!(
e.starts_with("cannot open the automation write root")
&& e.ends_with(&std::io::Error::from(std::io::ErrorKind::NotADirectory).to_string()),
"{e}"
);
assert!(AutomationRoots::new(Some(Path::new("")), None).unwrap_err().starts_with("cannot open the automation read root ``"));
assert!(AutomationRoots::new(None, None).unwrap().is_none());
assert!(AutomationRoots::new(&[Path::new("")], &[]).unwrap_err().starts_with("cannot open the automation read root ``"));
assert!(AutomationRoots::new(&[], &[]).unwrap().is_none());
// One root alone grants only its own access.
let read_only = AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap();
let read_only = AutomationRoots::new(&[&base.join("in")], &[]).unwrap().unwrap();
let e = read_only.check(s(base.join("in/x.designcraft")), Access::Write).unwrap_err();
assert!(e.contains("write authority is absent"), "{e}");
let _ = std::fs::remove_dir_all(base);
@@ -387,11 +403,125 @@ mod tests {
assert_eq!(roots.check(s(base.join("in/alias/x.designcraft")), Access::Read), Ok(()));
// A root given through a link is the folder it leads to.
assert!(made(&base.join("in"), &base.join("in-link")));
let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap();
let linked = AutomationRoots::new(&[&base.join("in-link")], &[]).unwrap().unwrap();
assert_eq!(linked.check(s(base.join("in/a.designcraft")), Access::Read), Ok(()));
let _ = std::fs::remove_dir_all(base);
}
/// `in` and `shared` (read roots), `out` and `scratch` (write roots) beside `outside`, the
/// siblings `in2` and `scratch2` whose names start like a root's, and a file in their parent.
/// The folder is returned resolved (`/var` is `/private/var` on macOS), as the roots show it, so
/// a path that passes only because it was resolved is the case being tested and nothing else.
fn several(name: &str) -> (PathBuf, Arc<AutomationRoots>) {
let (base, _) = folders(name);
let base = std::fs::canonicalize(base).unwrap();
for d in ["shared", "scratch", "scratch2"] {
std::fs::create_dir_all(base.join(d)).unwrap();
}
for f in ["shared/b.designcraft", "in2/c.designcraft", "parent.designcraft"] {
std::fs::write(base.join(f), "x").unwrap();
}
let roots = AutomationRoots::new(&[&base.join("in"), &base.join("shared")], &[&base.join("out"), &base.join("scratch")]).unwrap().unwrap();
(base, roots)
}
/// #1552: several roots of a kind: a path inside any of them passes, one inside none is refused
/// naming them all, and read and write stay separate.
#[test]
fn several_roots_of_a_kind_each_grant_their_access() {
let (base, roots) = several("several");
assert_eq!(roots.check(s(base.join("in/a.designcraft")), Access::Read), Ok(()));
assert_eq!(roots.check(s(base.join("shared/b.designcraft")), Access::Read), Ok(()));
assert_eq!(roots.check(s(base.join("out/x.png")), Access::Write), Ok(()));
assert_eq!(roots.check(s(base.join("scratch/new/x.png")), Access::Write), Ok(()));
let e = roots.check(s(base.join("outside/secret.designcraft")), Access::Read).unwrap_err();
let listed = format!("outside the read roots {}, {}: ", s(base.join("in")), s(base.join("shared")));
assert!(e.starts_with("automation path rejected: ") && e.contains(&listed), "{e}");
assert!(roots.check(s(base.join("outside/x.png")), Access::Write).unwrap_err().contains("outside the write roots"));
// Two sibling roots don't make their parent a root.
assert!(roots.check(s(base.join("parent.designcraft")), Access::Read).unwrap_err().contains("outside the read roots"));
assert!(roots.check(s(base.clone()), Access::Read).is_err());
assert!(roots.check(s(base.join("x.png")), Access::Write).unwrap_err().contains("outside the write roots"));
// Read and write stay separate: a read root is not a write root, nor the other way round.
assert!(roots.check(s(base.join("shared/b.designcraft")), Access::Write).unwrap_err().contains("outside the write roots"));
assert!(roots.check(s(base.join("in/x.png")), Access::Write).is_err());
assert!(roots.check(s(base.join("scratch/x.png")), Access::Read).unwrap_err().contains("outside the read roots"));
assert!(roots.check(s(base.join("out")), Access::Read).is_err());
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn several_roots_compare_whole_folder_names() {
let (base, roots) = several("several-prefix");
assert!(roots.check(s(base.join("in2/c.designcraft")), Access::Read).unwrap_err().contains("outside the read roots"), "in2 is not inside in");
assert!(roots.check(format!("{}2/x.designcraft", s(base.join("shared"))), Access::Read).is_err());
assert!(
roots.check(s(base.join("scratch2/x.png")), Access::Write).unwrap_err().contains("outside the write roots"),
"scratch2 is not inside scratch"
);
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn parent_traversal_between_several_roots() {
let (base, roots) = several("several-dotdot");
let sep = std::path::MAIN_SEPARATOR;
let up = |root: &str, rest: &str| format!("{}{sep}..{sep}{rest}", s(base.join(root)));
// `..` out of one root is outside, also when it lands next to another root.
assert!(roots.check(up("shared", &format!("outside{sep}secret.designcraft")), Access::Read).unwrap_err().contains("outside the read roots"));
assert!(roots.check(up("shared", "parent.designcraft"), Access::Read).is_err());
assert!(roots.check(up("scratch", &format!("outside{sep}x.png")), Access::Write).unwrap_err().contains("outside the write roots"));
// `..` from one root into another of the same kind stays inside.
assert_eq!(roots.check(up("shared", &format!("in{sep}a.designcraft")), Access::Read), Ok(()));
assert_eq!(roots.check(up("scratch", &format!("out{sep}x.png")), Access::Write), Ok(()));
// ...but not into a root of the other kind.
assert!(roots.check(up("scratch", &format!("in{sep}x.png")), Access::Write).unwrap_err().contains("outside the write roots"));
assert!(roots.check(up("shared", &format!("out{sep}x.png")), Access::Read).unwrap_err().contains("outside the read roots"));
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn several_roots_are_each_checked_and_counted_once() {
let (base, _) = folders("several-open");
// A missing folder among several is an error, wherever it stands.
for read in [[base.join("in"), base.join("nope")], [base.join("nope"), base.join("in")]] {
let e = AutomationRoots::new(&[&read[0], &read[1]], &[]).unwrap_err();
assert!(e.starts_with("cannot open the automation read root") && e.contains("nope"), "{e}");
}
let e = AutomationRoots::new(&[&base.join("in")], &[&base.join("out"), &base.join("in/a.designcraft")]).unwrap_err();
assert!(e.starts_with("cannot open the automation write root"), "{e}");
// A folder given twice (here once through `..`) counts once.
let twice = AutomationRoots::new(&[&base.join("in"), &base.join("in/sub/.."), &base.join("in")], &[]).unwrap().unwrap();
let real = std::fs::canonicalize(&base).unwrap();
assert_eq!(twice.describe(), format!("read {}; write none", s(real.join("in"))));
let e = twice.check(s(base.join("outside/x")), Access::Read).unwrap_err();
assert!(e.contains("outside the read root ") && !e.contains("roots"), "{e}");
let _ = std::fs::remove_dir_all(base);
}
/// A link inside one root that leads into another root of the same kind stays inside; one that
/// leads out of all of them, or into a root of the other kind, is outside.
#[cfg(unix)]
#[test]
fn links_between_several_roots() {
let (base, roots) = several("several-links");
let link = |target: &str, at: &str| std::os::unix::fs::symlink(base.join(target), base.join(at)).unwrap();
link("shared/b.designcraft", "in/to-shared.designcraft");
link("outside/secret.designcraft", "shared/leak.designcraft");
link("", "shared/up");
link("out", "in/to-out");
link("outside", "scratch/away");
link("in", "scratch/to-in");
assert_eq!(roots.check(s(base.join("in/to-shared.designcraft")), Access::Read), Ok(()));
assert!(roots.check(s(base.join("shared/leak.designcraft")), Access::Read).unwrap_err().contains("outside the read roots"));
assert!(roots.check(s(base.join("shared/up/outside/secret.designcraft")), Access::Read).unwrap_err().contains("outside the read roots"));
assert!(roots.check(s(base.join("shared/up/parent.designcraft")), Access::Read).is_err());
assert!(roots.check(s(base.join("in/to-out/x.png")), Access::Read).unwrap_err().contains("outside the read roots"));
assert!(roots.check(s(base.join("scratch/away/x.png")), Access::Write).unwrap_err().contains("outside the write roots"));
assert!(roots.check(s(base.join("scratch/to-in/x.png")), Access::Write).unwrap_err().contains("outside the write roots"));
let _ = std::fs::remove_dir_all(base);
}
#[cfg(windows)]
#[test]
fn windows_ignores_case_and_refuses_devices() {
+1
View File
@@ -79,6 +79,7 @@ fn graphic_frame(d: &mut Document, sr: SpreadRef, r: Rect, asset: AssetId, px: (
let (nw, nh) = (px.0 as f64, px.1 as f64);
let k = (r.width() / nw).max(r.height() / nh);
it.content = Content::Graphic(Graphic {
layer_options: None,
asset,
size: (nw, nh),
xf: Affine::translate((r.x0 + (r.width() - nw * k) / 2.0, r.y0 + (r.height() - nh * k) / 2.0)) * Affine::scale(k),
+1 -1
View File
@@ -23,7 +23,7 @@ fn folders(name: &str) -> (PathBuf, Arc<AutomationRoots>) {
for d in ["in", "out", "outside"] {
std::fs::create_dir_all(base.join(d)).unwrap();
}
let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap();
let roots = AutomationRoots::new(&[&base.join("in")], &[&base.join("out")]).unwrap().unwrap();
(base, roots)
}
@@ -0,0 +1,128 @@
//! Per-placement artwork layer metadata must survive native saves and stay visibly unsupported.
use std::io::{Cursor, Read, Write};
use designcraft_doc::{Content, GRAPHIC_LAYER_RENDERING_WARNING};
use designcraft_engine::{DocState, Session};
use serde_json::json;
fn fixture() -> Vec<u8> {
let xml = r#"<Document><Layer Self="L" Name="Synthetic"/><Spread Self="sp"><Page Self="p" GeometricBounds="0 0 100 100"/>
<Rectangle Self="r" ItemLayer="L" ContentType="GraphicType"><Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>
<PathPointType Anchor="0 0"/><PathPointType Anchor="0 20"/><PathPointType Anchor="20 20"/><PathPointType Anchor="20 0"/>
</PathPointArray></GeometryPathType></PathGeometry></Properties><Image Self="im">
<Properties><GraphicBounds Left="0" Top="0" Right="20" Bottom="20"/></Properties>
<Link Self="lk" LinkResourceURI="file:/definitely-missing/synthetic.psd"/>
<GraphicLayerOption UpdateLinkOption="KeepOverrides">
<GraphicLayer Self="g0" Id="0" Name="Group"><GraphicLayer Self="g1" Id="1" Name="Hidden" OriginalVisibility="true" CurrentVisibility="false"/></GraphicLayer>
<GraphicLayer Self="g2" Id="2" Name="Shown" OriginalVisibility="false" CurrentVisibility="true"/>
</GraphicLayerOption></Image></Rectangle></Spread></Document>"#;
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
z.start_file("designmap.xml", zip::write::SimpleFileOptions::default()).unwrap();
z.write_all(xml.as_bytes()).unwrap();
z.finish().unwrap().into_inner()
}
#[test]
fn graphic_layer_options_survive_native_reopen_and_report_unsupported_output() {
let imported = designcraft_idml::import_idml_report(&fixture(), &|_| None).unwrap();
assert!(imported.warnings.iter().any(|w| w.contains(GRAPHIC_LAYER_RENDERING_WARNING)));
let doc = imported.document;
let options = |d: &designcraft_doc::Document| {
d.spreads[0]
.items
.iter()
.filter_map(|i| match &i.content {
Content::Graphic(g) => g.layer_options.clone(),
_ => None,
})
.collect::<Vec<_>>()
};
let expected = options(&doc);
let saved = designcraft_format::save(&doc).unwrap();
let mut z = zip::ZipArchive::new(Cursor::new(&saved)).unwrap();
let mut meta = String::new();
z.by_name("meta.json").unwrap().read_to_string(&mut meta).unwrap();
assert_eq!(serde_json::from_str::<serde_json::Value>(&meta).unwrap()["version"], 4, "old readers must not silently drop the settings");
let reopened = designcraft_format::load(&saved).unwrap();
assert_eq!(options(&reopened), expected);
let mut s = Session::new();
s.add_document(DocState::new(reopened, None));
let preflight = s.execute("preflight.run", &json!({})).unwrap();
assert!(preflight["issues"].as_array().unwrap().iter().any(|i| i["kind"] == "unsupportedGraphicLayers"));
let pdf = s.execute("file.exportPdf", &json!({})).unwrap();
assert!(pdf["warnings"].as_array().unwrap().iter().any(|w| w == GRAPHIC_LAYER_RENDERING_WARNING));
}
#[test]
fn graphic_layer_options_in_cells_require_the_new_native_reader() {
let mut doc = designcraft_idml::import_idml(&fixture()).unwrap();
let graphic = doc.spreads[0]
.items
.iter()
.find_map(|i| match &i.content {
Content::Graphic(g) => Some(g.clone()),
_ => None,
})
.unwrap();
// Keep the original spread graphic too: its warning must not hide the cell warning.
let layer = doc.default_layer();
let (_, story) = doc
.add_text_frame(designcraft_doc::SpreadRef::Doc(0), designcraft_geom::Rect::new(0.0, 0.0, 80.0, 80.0), layer, "Body", Default::default())
.unwrap();
let mut table = designcraft_doc::Table::new(1, 1, 1, 0, 0, 20.0);
table.cells[0].graphic = Some(graphic);
doc.story_mut(story).unwrap().insert_table(0, table);
assert!(doc.has_graphic_layer_options());
let reopened = designcraft_format::load(&designcraft_format::save(&doc).unwrap()).unwrap();
assert!(reopened.has_graphic_layer_visibility_overrides());
let mut s = Session::new();
s.add_document(DocState::new(reopened, None));
let preflight = s.execute("preflight.run", &json!({})).unwrap();
let warnings: Vec<_> = preflight["issues"].as_array().unwrap().iter().filter(|i| i["kind"] == "unsupportedGraphicLayers").collect();
assert!(warnings.iter().any(|i| i["item"].is_null()), "the cell needs an independent document-level warning");
assert!(warnings.iter().any(|i| !i["item"].is_null()), "the spread retains its item warning");
}
#[test]
fn a_graphic_layer_cell_alone_still_requires_the_new_native_reader() {
// The cell is the only thing carrying the settings: a save that only looked at spread items
// would choose format 3 and drop them.
let mut doc = designcraft_idml::import_idml(&fixture()).unwrap();
let graphic = doc.spreads[0]
.items
.iter()
.find_map(|i| match &i.content {
Content::Graphic(g) => Some(g.clone()),
_ => None,
})
.unwrap();
let layer = doc.default_layer();
let (_, story) = doc
.add_text_frame(designcraft_doc::SpreadRef::Doc(0), designcraft_geom::Rect::new(0.0, 0.0, 80.0, 80.0), layer, "Body", Default::default())
.unwrap();
// Only the cell keeps them: the spread's own placed graphic goes away.
std::sync::Arc::make_mut(&mut doc.spreads[0]).items.retain(|i| i.graphic().is_none());
let mut table = designcraft_doc::Table::new(1, 1, 1, 0, 0, 20.0);
table.cells[0].graphic = Some(graphic);
doc.story_mut(story).unwrap().insert_table(0, table);
assert!(doc.has_graphic_layer_options(), "the cell alone carries the settings");
let saved = designcraft_format::save(&doc).unwrap();
let mut z = zip::ZipArchive::new(Cursor::new(&saved)).unwrap();
let mut meta = String::new();
z.by_name("meta.json").unwrap().read_to_string(&mut meta).unwrap();
assert_eq!(serde_json::from_str::<serde_json::Value>(&meta).unwrap()["version"], 4, "a cell-only save needs the newer reader");
assert!(designcraft_format::load(&saved).unwrap().has_graphic_layer_visibility_overrides());
}
#[test]
fn pdf_layer_warning_survives_soft_effects_and_flattening() {
for flatten in [false, true] {
let doc = designcraft_idml::import_idml(&fixture()).unwrap();
let id = doc.spreads[0].items.iter().find(|i| i.graphic().is_some()).unwrap().id.0;
let mut session = Session::new();
session.add_document(DocState::new(doc, None));
session.execute("object.feather", &json!({"ids":[id], "width":2})).unwrap();
let out = session.execute("file.exportPdf", &json!({"flatten":flatten})).unwrap();
assert!(out["warnings"].as_array().unwrap().iter().any(|w| w == GRAPHIC_LAYER_RENDERING_WARNING), "{out}");
}
}
+5 -7
View File
@@ -118,11 +118,7 @@ fn exported(hidden: bool) -> Vec<u8> {
session.execute("object.layerOptions", &json!({"ids": [placed["id"]], "hidden": ["Red"]})).unwrap();
}
let report = session.execute("file.exportPdf", &json!({"compress": false})).unwrap();
let bytes = base64_decode(report["base64"].as_str().unwrap());
if hidden {
assert!(report["warnings"].to_string().contains("hidden layers"));
}
bytes
base64_decode(report["base64"].as_str().unwrap())
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
@@ -185,7 +181,8 @@ fn oversized_image_does_not_silently_disappear_from_layered_export() {
.unwrap();
session.execute("object.layerOptions", &json!({"ids": [placed["id"]], "hidden": ["Blue"]})).unwrap();
let before = session.execute("file.serialize", &json!({})).unwrap();
let result = session.execute("file.exportPdf", &json!({}));
// PDF/A-2b has no optional content: placed PDFs with hidden layers go out as images there.
let result = session.execute("file.exportPdf", &json!({"standard": "pdfa2b"}));
assert!(result.is_err(), "export must report the image omitted by the backend's resource limit");
assert_eq!(session.execute("file.serialize", &json!({})).unwrap(), before, "a failed export must leave the document unchanged");
let error = result.unwrap_err().to_string();
@@ -317,7 +314,8 @@ fn failed_content_decode_aborts_layered_export_without_changing_the_document() {
.unwrap();
session.execute("object.layerOptions", &json!({"ids": [placed["id"]], "hidden": ["Blue"]})).unwrap();
let before = session.execute("file.serialize", &json!({})).unwrap();
let error = session.execute("file.exportPdf", &json!({})).unwrap_err().to_string();
// PDF/A-2b has no optional content: placed PDFs with hidden layers go out as images there.
let error = session.execute("file.exportPdf", &json!({"standard": "pdfa2b"})).unwrap_err().to_string();
assert!(error.contains("broken-content.pdf") && error.contains("content"), "{filter}: {error}");
assert_eq!(session.execute("file.serialize", &json!({})).unwrap(), before, "{filter}");
}
+238
View File
@@ -0,0 +1,238 @@
#[path = "support/optional_content.rs"]
mod support;
#[path = "support/optional_content_fallback.rs"]
mod fallback;
#[path = "support/optional_content_xref.rs"]
mod xref;
use designcraft_compose::Cache;
use designcraft_pdf::{PdfOptions, Standard, export_pdf, export_pdf_with_report};
use support::{catalog, document, fixture, painted};
const DEFAULT: &str = "/BaseState/ON/OFF[6 3 R]";
const USAGE_APPLICATIONS: &str = "/BaseState/ON/ON[5 0 R]/OFF[6 3 R]/AS[<</Event/View/Category[/View]/OCGs[5 0 R 6 3 R]>><</Event/Print/Category[/Print]/OCGs[5 0 R 6 3 R]>><</Event/Export/Category[/Export]/OCGs[5 0 R 6 3 R]>>]";
#[test]
fn source_layer_dictionaries_are_readable() {
let pdf = hayro_syntax::Pdf::new(fixture(DEFAULT, "Hidden")).unwrap();
for id in [(5, 0), (6, 3)] {
assert!(pdf.xref().get::<hayro_syntax::object::Dict<'_>>(hayro_syntax::object::ObjectIdentifier::new(id.0, id.1)).is_some(), "{id:?}");
}
}
fn exported(config: &str, standard: Standard) -> Vec<u8> {
let doc = document(fixture(config, "Hidden"), &[&[]]);
let report = export_pdf_with_report(&doc, &Cache::new(), &PdfOptions { standard, created: Some(0), ..Default::default() }).unwrap();
assert!(report.warnings.is_empty(), "{:?}", report.warnings);
report.bytes
}
#[test]
fn source_default_off_renders_before_export() {
let bytes = fixture(DEFAULT, "Hidden");
painted(&bytes, &[true, false]);
}
#[test]
fn plain_export_registers_default_off_layers() {
let bytes = exported(DEFAULT, Standard::None);
assert_eq!(catalog(&bytes), [("Hidden".into(), true), ("Shown".into(), false)]);
}
#[test]
fn pdfx_export_preserves_default_off_catalog() {
let bytes = exported(DEFAULT, Standard::PdfX4);
assert_eq!(catalog(&bytes), [("Hidden".into(), true), ("Shown".into(), false)]);
assert!(designcraft_pdf::check_pdfx4(&bytes).is_empty());
}
#[test]
fn plain_export_paints_only_the_shown_layer() {
let bytes = exported(DEFAULT, Standard::None);
painted(&bytes, &[true, false]);
}
#[test]
fn pdfx_export_paints_only_the_shown_layer() {
let bytes = exported(DEFAULT, Standard::PdfX4);
painted(&bytes, &[true, false]);
}
#[test]
fn plain_export_preserves_defaults_with_usage_applications() {
let bytes = exported(USAGE_APPLICATIONS, Standard::None);
assert_eq!(catalog(&bytes), [("Hidden".into(), true), ("Shown".into(), false)]);
assert_vector(&bytes);
painted(&bytes, &[true, false]);
}
#[test]
fn pdfx_export_preserves_defaults_with_usage_applications() {
let bytes = exported(USAGE_APPLICATIONS, Standard::PdfX4);
assert_eq!(catalog(&bytes), [("Hidden".into(), true), ("Shown".into(), false)]);
assert!(designcraft_pdf::check_pdfx4(&bytes).is_empty());
assert_vector(&bytes);
painted(&bytes, &[true, false]);
}
#[test]
fn plain_export_draws_groups_without_catalog_properties() {
let doc = document(support::orphaned_fixture(), &[&[]]);
let bytes = export_pdf(&doc, &Cache::new(), &PdfOptions::default()).unwrap();
assert_eq!(catalog(&bytes), [("Hidden".into(), false), ("Shown".into(), false)]);
assert_vector(&bytes);
painted(&bytes, &[true, true]);
}
#[test]
fn pdfx_export_draws_groups_without_catalog_properties() {
let doc = document(support::orphaned_fixture(), &[&[]]);
let bytes = export_pdf(&doc, &Cache::new(), &PdfOptions { standard: Standard::PdfX4, ..Default::default() }).unwrap();
assert_eq!(catalog(&bytes), [("Hidden".into(), false), ("Shown".into(), false)]);
assert!(designcraft_pdf::check_pdfx4(&bytes).is_empty());
assert_vector(&bytes);
painted(&bytes, &[true, true]);
}
#[test]
fn base_off_with_on_exception_is_preserved() {
let bytes = exported("/BaseState/OFF/ON[5 0 R]", Standard::None);
assert_eq!(catalog(&bytes), [("Hidden".into(), true), ("Shown".into(), false)]);
painted(&bytes, &[true, false]);
}
#[test]
fn same_asset_placements_keep_opposing_layer_states() {
let doc = document(fixture(DEFAULT, "Hidden"), &[&[], &["Shown"]]);
let bytes = export_pdf(&doc, &Cache::new(), &PdfOptions::default()).unwrap();
assert_eq!(catalog(&bytes), [("Hidden".into(), false), ("Hidden".into(), true), ("Shown".into(), false), ("Shown".into(), true)]);
painted(&bytes, &[true, false, false, true]);
}
#[test]
fn duplicate_names_with_distinct_generations_keep_default_states() {
let doc = document(fixture(DEFAULT, "Shown"), &[&[]]);
let bytes = export_pdf(&doc, &Cache::new(), &PdfOptions::default()).unwrap();
assert_eq!(catalog(&bytes), [("Shown".into(), false), ("Shown".into(), true)]);
painted(&bytes, &[true, false]);
}
#[test]
fn unresolved_default_off_reference_fails_export() {
let doc = document(fixture("/OFF[99 0 R]", "Hidden"), &[&[]]);
let result = export_pdf(&doc, &Cache::new(), &PdfOptions::default());
assert!(result.is_err(), "an unreadable layer state must not print hidden artwork");
}
#[test]
fn unlayered_pdf_stays_vector_without_optional_content() {
let doc = document(support::unlayered_fixture(), &[&[]]);
let bytes = export_pdf(&doc, &Cache::new(), &PdfOptions::default()).unwrap();
let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
let root = pdf.xref().get::<hayro_syntax::object::Dict<'_>>(pdf.xref().root_id()).unwrap();
assert!(!root.contains_key(b"OCProperties"));
assert_vector(&bytes);
painted(&bytes, &[true, false]);
}
fn assert_vector(bytes: &[u8]) {
use hayro_syntax::object::{Name, Object};
let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).unwrap();
for object in pdf.objects() {
if let Object::Stream(stream) = object {
assert_ne!(stream.dict().get::<Name<'_>>(b"Subtype").as_ref().map(AsRef::as_ref), Some(b"Image".as_slice()));
}
}
}
#[test]
fn layer_options_command_preserves_vector_overrides_in_plain_and_pdfx() {
use designcraft_engine::{
Session,
cmd::{base64_decode, base64_encode},
};
use serde_json::json;
for standard in ["none", "x4"] {
let mut session = Session::new();
session.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1, "facingPages": false, "margins": 0})).unwrap();
session
.execute("file.place", &json!({"base64": base64_encode(&fixture(DEFAULT, "Hidden")), "name": "layers.pdf", "x": 0, "y": 0, "width": 100}))
.unwrap();
session.execute("object.layerOptions", &json!({"hidden": ["Shown"]})).unwrap();
let result = session.execute("file.exportPdf", &json!({"standard": standard})).unwrap();
let bytes = base64_decode(result["base64"].as_str().unwrap());
assert_eq!(catalog(&bytes), [("Hidden".into(), false), ("Shown".into(), true)]);
assert_vector(&bytes);
painted(&bytes, &[false, true]);
}
}
#[test]
fn exported_optional_content_survives_replacement_and_book_merge() {
let first = exported(DEFAULT, Standard::None);
let doc = document(first.clone(), &[&[]]);
let replaced = export_pdf(&doc, &Cache::new(), &PdfOptions::default()).unwrap();
let merged = designcraft_pdf::merge_pdfs(&[(first, vec![(100.0, 100.0)])], None).unwrap();
for bytes in [replaced, merged] {
assert_eq!(catalog(&bytes), [("Hidden".into(), true), ("Shown".into(), false)]);
painted(&bytes, &[true, false]);
}
}
#[test]
fn booklet_export_preserves_optional_content() {
let doc = document(fixture(DEFAULT, "Hidden"), &[&[]]);
let options = designcraft_pdf::BookletOptions { kind: designcraft_pdf::BookletKind::TwoUpConsecutive, ..Default::default() };
let report = designcraft_pdf::export_booklet(&doc, &Cache::new(), &options).unwrap();
assert!(report.warnings.is_empty());
assert_eq!(catalog(&report.bytes), [("Hidden".into(), true), ("Shown".into(), false)]);
assert_vector(&report.bytes);
painted(&report.bytes, &[true, false, false, false]);
}
#[test]
fn archival_export_still_rejects_unvalidated_placed_pdf() {
let doc = document(fixture(DEFAULT, "Hidden"), &[&[]]);
let error = export_pdf(&doc, &Cache::new(), &PdfOptions { standard: Standard::PdfA2b, ..Default::default() }).unwrap_err();
assert!(error.to_string().contains("EmbeddedPDF"), "{error}");
}
#[test]
fn source_xref_stream_preserves_layer_states() {
let doc = document(support::stream_fixture(DEFAULT), &[&[]]);
let bytes = export_pdf(&doc, &Cache::new(), &PdfOptions::default()).unwrap();
assert_eq!(catalog(&bytes), [("Hidden".into(), true), ("Shown".into(), false)]);
painted(&bytes, &[true, false]);
}
@@ -0,0 +1,174 @@
use std::sync::Arc;
use designcraft_doc::{Asset, AssetId, Content, Document, Graphic, Item, ItemId, Shape, SpreadRef, build::NewDocument};
use designcraft_geom::{Affine, Rect, shapes};
use hayro_syntax::{
Pdf,
object::{Array, Dict, Name, ObjectIdentifier},
};
pub fn fixture(config: &str, second_name: &str) -> Vec<u8> {
layered_fixture(Some(config), second_name)
}
pub fn orphaned_fixture() -> Vec<u8> {
layered_fixture(None, "Hidden")
}
fn layered_fixture(config: Option<&str>, second_name: &str) -> Vec<u8> {
let properties = config.map(|config| format!("/OCProperties<</OCGs[5 0 R 6 3 R]/D<<{config}>>>>")).unwrap_or_default();
properties_fixture(&properties, second_name)
}
pub fn properties_fixture(properties: &str, second_name: &str) -> Vec<u8> {
grouped_fixture(properties, Some(second_name))
}
pub fn nameless_fixture(config: &str) -> Vec<u8> {
grouped_fixture(&format!("/OCProperties<</OCGs[5 0 R 6 3 R]/D<<{config}>>>>"), None)
}
fn grouped_fixture(properties: &str, second_name: Option<&str>) -> Vec<u8> {
let content = "/OC /a BDC 1 0 0 0 k 0 0 50 100 re f EMC /OC /b BDC 0 1 0 0 k 50 0 50 100 re f EMC";
let name = second_name.map(|name| format!("/Name({name})")).unwrap_or_default();
let objects = [
format!("<</Type/Catalog/Pages 2 0 R{properties}>>"),
"<</Type/Pages/Kids[3 0 R]/Count 1>>".into(),
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Resources<</Properties<</a 5 0 R/b 6 3 R>>>>/Contents 4 0 R>>".into(),
format!("<</Length {}>>\nstream\n{content}\nendstream", content.len()),
"<</Type/OCG/Name(Shown)/Intent[/View/Design]/Usage<</Print<</PrintState/ON>>>>/Custom(keep \\(this\\) >>)>>".into(),
format!("<</Type/OCG{name}/Intent[/View/Design]/Usage<</Print<</PrintState/ON>>>>/Custom<00ff80>>>"),
];
assemble(&objects, &[2, 0, 0, 0, 0, 3])
}
pub fn unlayered_fixture() -> Vec<u8> {
let content = "1 0 0 0 k 0 0 50 100 re f";
assemble(
&[
"<</Type/Catalog/Pages 2 0 R>>".into(),
"<</Type/Pages/Kids[3 0 R]/Count 1>>".into(),
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Resources<<>>/Contents 4 0 R>>".into(),
format!("<</Length {}>>\nstream\n{content}\nendstream", content.len()),
],
&[2, 0, 0, 0],
)
}
pub fn stream_fixture(config: &str) -> Vec<u8> {
let mut bytes = fixture(config, "Hidden");
let tail = std::str::from_utf8(&bytes).unwrap();
let previous: usize = tail.rsplit("startxref").next().unwrap().split_whitespace().next().unwrap().parse().unwrap();
let offset = bytes.len();
let mut entry = vec![1];
entry.extend_from_slice(&u32::try_from(offset).unwrap().to_be_bytes());
entry.extend_from_slice(&0u16.to_be_bytes());
bytes.extend_from_slice(format!("7 0 obj\n<</Type/XRef/Size 8/Root 1 2 R/Prev {previous}/W[1 4 2]/Index[7 1]/Length 7>>\nstream\n").as_bytes());
bytes.extend_from_slice(&entry);
bytes.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{offset}\n%%EOF\n").as_bytes());
bytes
}
fn assemble(objects: &[String], generations: &[i32]) -> Vec<u8> {
let mut bytes = b"%PDF-1.5\n".to_vec();
let mut offsets = Vec::new();
for (index, body) in objects.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} {} obj\n{body}\nendobj\n", index + 1, generations[index]).as_bytes());
}
let xref = bytes.len();
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f\r\n", objects.len() + 1).as_bytes());
for (offset, generation) in offsets.iter().zip(generations) {
bytes.extend_from_slice(format!("{offset:010} {generation:05} n\r\n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<</Size {}/Root 1 2 R>>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
bytes
}
pub fn document(bytes: Vec<u8>, hidden_sets: &[&[&str]]) -> Document {
let width = 100.0 * f64::from(u32::try_from(hidden_sets.len()).unwrap());
let mut doc = Document::new(&NewDocument { width, height: 100.0, facing_pages: false, ..Default::default() });
let asset = AssetId(doc.alloc());
doc.add_asset(Asset {
id: asset,
name: "layers.pdf".into(),
mime: "application/pdf".into(),
data: Arc::new(bytes),
link: None,
pixels: None,
page: 0,
pdf_crop: Default::default(),
pdf_box: None,
});
for (index, hidden) in hidden_sets.iter().enumerate() {
let x = 100.0 * f64::from(u32::try_from(index).unwrap());
let mut item = Item::new(ItemId(doc.alloc()), doc.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)));
item.xf = Affine::translate((x, 0.0));
item.pdf_hidden_layers = hidden.iter().map(|name| (*name).to_owned()).collect();
item.content = Content::Graphic(Graphic {
asset,
size: (100.0, 100.0),
xf: Affine::IDENTITY,
clip: None,
auto_fit: Default::default(),
fit_align: 4,
crop: [0.0; 4],
wrap: Default::default(),
fill: Default::default(),
layer_options: None,
});
doc.insert_item(SpreadRef::Doc(0), item, None).unwrap();
}
doc
}
pub fn refs(dict: &Dict<'_>, key: &[u8]) -> Vec<ObjectIdentifier> {
dict.get::<Array<'_>>(key).unwrap().raw_iter().map(|value| value.as_obj_ref().unwrap().into()).collect()
}
pub fn catalog(bytes: &[u8]) -> Vec<(String, bool)> {
let pdf = Pdf::new(bytes.to_vec()).unwrap();
let root = pdf.xref().get::<Dict<'_>>(pdf.xref().root_id()).unwrap();
let properties = root.get::<Dict<'_>>(b"OCProperties").expect("export must register its embedded OCGs");
let groups = refs(&properties, b"OCGs");
let defaults = properties.get::<Dict<'_>>(b"D").unwrap();
assert!(defaults.get::<hayro_syntax::object::String<'_>>(b"Name").is_some());
assert_eq!(defaults.get::<Name<'_>>(b"BaseState").unwrap().as_ref(), b"ON");
assert!(!defaults.contains_key(b"AS"));
assert_eq!(refs(&defaults, b"Order"), groups);
let off = refs(&defaults, b"OFF");
assert!(off.iter().all(|id| groups.contains(id)));
let mut unique = groups.clone();
unique.sort();
unique.dedup();
assert_eq!(unique.len(), groups.len());
let mut result = Vec::new();
for id in groups {
let group = pdf.xref().get::<Dict<'_>>(id).unwrap();
assert!(group.contains_key(b"Usage"));
assert!(group.contains_key(b"Intent"));
assert!(group.contains_key(b"Custom"));
let custom = group.get::<hayro_syntax::object::String<'_>>(b"Custom").unwrap();
assert!([b"keep (this) >>".as_slice(), &[0, 255, 128]].contains(&custom.as_bytes()));
let intent = group.get::<Array<'_>>(b"Intent").unwrap();
assert_eq!(intent.iter::<Name<'_>>().map(|name| name.as_ref().to_vec()).collect::<Vec<_>>(), [b"View".to_vec(), b"Design".to_vec()]);
let usage = group.get::<Dict<'_>>(b"Usage").unwrap().get::<Dict<'_>>(b"Print").unwrap();
assert_eq!(usage.get::<Name<'_>>(b"PrintState").unwrap().as_ref(), b"ON");
let name = group.get::<hayro_syntax::object::String<'_>>(b"Name").unwrap();
result.push((String::from_utf8(name.as_bytes().to_vec()).unwrap(), off.contains(&id)));
}
result.sort();
result
}
pub fn painted(bytes: &[u8], expected: &[bool]) {
let width = u32::try_from(expected.len()).unwrap() * 50;
let image = designcraft_render::render_pdf_page(bytes, 0, width.max(100)).unwrap();
for (index, painted) in expected.iter().enumerate() {
let image_width = usize::from(image.width());
let x = (2 * index + 1) * image_width / (2 * expected.len());
let y = usize::from(image.height()) / 2;
let pixel = image.data()[y * image_width + x];
assert_eq!(pixel.a > 200, *painted, "half {index}: {pixel:?}");
}
}
@@ -0,0 +1,263 @@
use super::support::{catalog, document, nameless_fixture, painted, properties_fixture};
use designcraft_compose::Cache;
use designcraft_engine::{Session, cmd::base64_encode};
use designcraft_pdf::{PdfOptions, Standard, export_pdf, export_pdf_with_report};
use serde_json::json;
const CASES: &[&str] = &[
"/OCGs[5 0 R 6 3 R]",
"/OCGs[5 0 R 6 3 R]/D null",
"/OCGs[5 0 R 6 3 R]/D 42",
"/OCGs[5 0 R 6 3 R]/D 99 0 R",
"/OCGs[5 0 R 6 3 R]/D<</BaseState/Unchanged/OFF[6 3 R]>>",
"/OCGs[5 0 R 6 3 R]/D<</BaseState/Other/OFF[6 3 R]>>",
"/OCGs[5 0 R 6 3 R]/D<</BaseState 42/OFF[6 3 R]>>",
"/OCGs[5 0 R 6 3 R]/D<</BaseState/ON/ON 42>>",
"/OCGs[5 0 R 6 3 R]/D<</BaseState/ON/ON[null 5 0 R]>>",
];
fn incomplete_inventory(standard: &str, off: bool) {
let cases: &[&str] = if off {
&["/OCGs[5 0 R]/D<</BaseState/ON/OFF[6 3 R]>>", "/D<</ON[5 0 R]/OFF[6 3 R]>>"]
} else {
&["/OCGs[6 3 R]/D<</BaseState/OFF/ON[5 0 R]>>"]
};
for properties in cases {
let source = properties_fixture(&format!("/OCProperties<<{properties}>>"), "Hidden");
let mut session = Session::new();
session.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1, "facingPages": false, "margins": 0})).unwrap();
session.execute("file.place", &json!({"base64": base64_encode(&source), "name": "incomplete.pdf", "x": 0, "y": 0, "width": 100})).unwrap();
let result = session.execute("file.exportPdf", &json!({"standard": standard})).unwrap();
let bytes = designcraft_engine::cmd::base64_decode(result["base64"].as_str().unwrap());
assert_eq!(catalog(&bytes), [("Hidden".into(), true), ("Shown".into(), false)]);
painted(&bytes, &[true, false]);
super::assert_vector(&bytes);
let warnings = result["warnings"].as_array().unwrap();
assert_eq!(warnings.len(), 1);
let warning = warnings[0].as_str().unwrap();
assert!(warning.contains("incomplete.pdf") && warning.contains("layer list incomplete"), "{warning}");
assert!(!warning.contains("layer state not readable"), "{warning}");
if standard == "x4" {
assert!(designcraft_pdf::check_pdfx4(&bytes).is_empty());
}
}
}
#[test]
fn plain_incomplete_inventory_preserves_off() {
incomplete_inventory("none", true);
}
#[test]
fn pdfx_incomplete_inventory_preserves_off() {
incomplete_inventory("x4", true);
}
#[test]
fn plain_incomplete_inventory_preserves_on() {
incomplete_inventory("none", false);
}
#[test]
fn pdfx_incomplete_inventory_preserves_on() {
incomplete_inventory("x4", false);
}
#[test]
fn incomplete_inventory_reports_both_warnings_during_fallback() {
for standard in [Standard::None, Standard::PdfX4] {
let source = properties_fixture("/OCProperties<</OCGs[5 0 R]/D<</BaseState/Unchanged/OFF[6 3 R]>>>>", "Hidden");
let doc = document(source, &[&[]]);
let report = export_pdf_with_report(&doc, &Cache::new(), &PdfOptions { standard, ..Default::default() }).unwrap();
assert_eq!(report.warnings.len(), 1);
let warning = &report.warnings[0];
assert!(
warning.contains("layers.pdf") && warning.contains("layer list incomplete") && warning.contains("layer state not readable"),
"{warning}"
);
painted(&report.bytes, &[true, true]);
if standard == Standard::PdfX4 {
assert!(designcraft_pdf::check_pdfx4(&report.bytes).is_empty());
}
}
}
fn visible_recovery(source: Vec<u8>, standard: &str) {
let mut session = Session::new();
session.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1, "facingPages": false, "margins": 0})).unwrap();
session.execute("file.place", &json!({"base64": base64_encode(&source), "name": "visible-recovery.pdf", "x": 0, "y": 0, "width": 100})).unwrap();
let result = session.execute("file.exportPdf", &json!({"standard": standard})).unwrap();
let warnings = result["warnings"].as_array().unwrap();
assert_eq!(warnings.len(), 1);
assert!(warnings[0].as_str().unwrap().contains("visible-recovery.pdf"));
let bytes = designcraft_engine::cmd::base64_decode(result["base64"].as_str().unwrap());
let groups = catalog(&bytes);
assert_eq!(groups.len(), 2);
assert!(groups.iter().all(|(name, hidden)| !name.is_empty() && !hidden));
super::assert_vector(&bytes);
painted(&bytes, &[true, true]);
if standard == "x4" {
assert!(designcraft_pdf::check_pdfx4(&bytes).is_empty());
}
}
#[test]
fn plain_export_recovers_malformed_on_when_defaults_are_visible() {
visible_recovery(super::support::fixture("/BaseState/ON/ON 42", "Hidden"), "none");
}
#[test]
fn pdfx_export_recovers_malformed_on_when_defaults_are_visible() {
visible_recovery(super::support::fixture("/BaseState/ON/ON 42", "Hidden"), "x4");
}
#[test]
fn plain_export_names_visible_group_when_display_name_is_missing() {
visible_recovery(nameless_fixture("/BaseState/ON"), "none");
}
#[test]
fn pdfx_export_names_visible_group_when_display_name_is_missing() {
visible_recovery(nameless_fixture("/BaseState/ON"), "x4");
}
#[test]
fn missing_display_name_fails_when_an_override_could_match() {
for standard in [Standard::None, Standard::PdfX4] {
let doc = document(nameless_fixture("/BaseState/ON"), &[&["Shown"]]);
let result = export_pdf(&doc, &Cache::new(), &PdfOptions { standard, ..Default::default() });
assert!(result.is_err(), "a nameless group could match the explicit hidden name");
}
}
#[test]
fn malformed_on_fails_when_base_state_can_hide_groups() {
for standard in [Standard::None, Standard::PdfX4] {
let doc = document(super::support::fixture("/BaseState/OFF/ON 42", "Hidden"), &[&[]]);
let result = export_pdf(&doc, &Cache::new(), &PdfOptions { standard, ..Default::default() });
assert!(result.is_err(), "all-ON recovery would expose groups hidden by BaseState OFF");
}
}
#[test]
fn name_repair_preserves_readable_default_off_state() {
for standard in [Standard::None, Standard::PdfX4] {
let doc = document(nameless_fixture("/BaseState/ON/OFF[6 3 R]"), &[&[]]);
let report = export_pdf_with_report(&doc, &Cache::new(), &PdfOptions { standard, ..Default::default() }).unwrap();
assert_eq!(report.warnings.len(), 1);
assert!(report.warnings[0].contains("layers.pdf"));
assert_eq!(catalog(&report.bytes).iter().filter(|(_, hidden)| *hidden).count(), 1);
painted(&report.bytes, &[true, false]);
if standard == Standard::PdfX4 {
assert!(designcraft_pdf::check_pdfx4(&report.bytes).is_empty());
}
}
}
fn fallback_exports(standard: Standard) {
let mut failures = Vec::new();
for properties in CASES {
let source = properties_fixture(&format!("/OCProperties<<{properties}>>"), "Hidden");
let doc = document(source, &[&[], &["Shown"]]);
let report = match export_pdf_with_report(&doc, &Cache::new(), &PdfOptions { standard, created: Some(0), ..Default::default() }) {
Ok(report) => report,
Err(error) => {
failures.push(format!("{properties}: {error}"));
continue;
}
};
if report.warnings.len() != 1 {
failures.push(format!("{properties}: expected one fallback warning, got {:?}", report.warnings));
continue;
}
assert!(report.warnings[0].contains("layers.pdf"));
assert_eq!(catalog(&report.bytes), [("Hidden".into(), false), ("Hidden".into(), false), ("Shown".into(), false), ("Shown".into(), true)]);
super::assert_vector(&report.bytes);
painted(&report.bytes, &[true, true, false, true]);
if standard == Standard::PdfX4 {
assert!(designcraft_pdf::check_pdfx4(&report.bytes).is_empty());
}
}
assert!(failures.is_empty(), "fallback export failures: {failures:#?}");
}
#[test]
fn plain_fallback_shows_all_layers_and_preserves_frame_overrides() {
fallback_exports(Standard::None);
}
#[test]
fn pdfx_fallback_shows_all_layers_and_preserves_frame_overrides() {
fallback_exports(Standard::PdfX4);
}
#[test]
fn file_export_reports_fallback_with_filename_and_preserves_overrides() {
for properties in CASES {
for standard in ["none", "x4"] {
for hidden in [false, true] {
let source = properties_fixture(&format!("/OCProperties<<{properties}>>"), "Hidden");
let mut session = Session::new();
session.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1, "facingPages": false, "margins": 0})).unwrap();
session
.execute("file.place", &json!({"base64": base64_encode(&source), "name": "fallback.pdf", "x": 0, "y": 0, "width": 100}))
.unwrap();
if hidden {
session.execute("object.layerOptions", &json!({"hidden": ["Shown"]})).unwrap();
}
let result = session.execute("file.exportPdf", &json!({"standard": standard})).unwrap();
let warnings = result["warnings"].as_array().unwrap();
assert_eq!(warnings.len(), 1, "{properties}: {result}");
assert!(warnings[0].as_str().unwrap().contains("fallback.pdf"));
let bytes = designcraft_engine::cmd::base64_decode(result["base64"].as_str().unwrap());
assert_eq!(catalog(&bytes), [("Hidden".into(), false), ("Shown".into(), hidden)]);
painted(&bytes, &[!hidden, true]);
}
}
}
}
#[test]
fn contradictory_and_unresolved_states_fail_even_with_fallback_or_overrides() {
for properties in [
"/OCGs[5 0 R 6 3 R]/D<</ON[6 3 R]/OFF[6 3 R]>>",
"/OCGs[5 0 R]/D<</BaseState/Unchanged/ON[6 3 R]/OFF[6 3 R]>>",
"/OCGs[5 0 R 6 3 R]/D<</BaseState/Unchanged/OFF[99 0 R]>>",
"/OCGs[5 0 R 6 3 R]/D<</ON[99 0 R]>>",
"/OCGs[5 0 R 6 3 R 99 0 R]",
"/OCGs[5 0 R 6 3 R]/D<</OFF[2 0 R]>>",
"/OCGs[5 0 R 6 3 R]/D<</OFF[6 0 R]>>",
"/OCGs[5 0 R 6 3 R]/D<</OFF[null]>>",
"/OCGs[5 0 R 6 3 R]/D<</OFF 42>>",
"/OCGs[5 0 R 6 3 R]/D<</OFF 99 0 R>>",
"/OCGs[5 0 R 6 3 R]/D<</BaseState/ON/ON[null 99 0 R]>>",
"/OCGs[5 0 R 6 3 R]/D<</BaseState/ON/ON 99 0 R>>",
"/OCGs[5 0 R 6 3 R]/D<</BaseState/ON/ON 42/OFF[6 3 R]>>",
] {
for standard in [Standard::None, Standard::PdfX4] {
for hidden in [&[][..], &["Shown"][..]] {
let doc = document(properties_fixture(&format!("/OCProperties<<{properties}>>"), "Hidden"), &[hidden]);
let result = export_pdf(&doc, &Cache::new(), &PdfOptions { standard, ..Default::default() });
assert!(result.is_err(), "unsafe state accepted: {properties}");
}
}
}
}
@@ -0,0 +1,143 @@
use super::support::{catalog, document, fixture, nameless_fixture, painted};
use designcraft_compose::Cache;
use designcraft_doc::{AssetId, Content};
use designcraft_pdf::{BookletKind, BookletOptions, PdfOptions, Standard, export_booklet, export_pdf, export_pdf_with_report, merge_pdfs};
use std::sync::Arc;
fn broken_xref(bytes: Vec<u8>) -> Vec<u8> {
let text = String::from_utf8(bytes).unwrap();
let (body, _) = text.rsplit_once("startxref").unwrap();
let bytes = format!("{body}startxref\n1\n%%EOF\n").into_bytes();
let pdf = hayro_syntax::Pdf::new(bytes.clone()).expect("hayro must repair the synthetic broken xref");
assert_eq!(pdf.pages().len(), 1);
let root = pdf.xref().get::<hayro_syntax::object::Dict<'_>>(pdf.xref().root_id()).unwrap();
assert!(root.get::<hayro_syntax::object::Dict<'_>>(b"OCProperties").is_some());
bytes
}
fn all_visible(standard: Standard) {
let source = broken_xref(fixture("/BaseState/ON", "Hidden"));
painted(&source, &[true, true]);
let doc = document(source, &[&[]]);
let report = export_pdf_with_report(&doc, &Cache::new(), &PdfOptions { standard, ..Default::default() }).unwrap();
assert!(report.warnings.is_empty(), "{:?}", report.warnings);
assert_eq!(catalog(&report.bytes), [("Hidden".into(), false), ("Shown".into(), false)]);
super::assert_vector(&report.bytes);
painted(&report.bytes, &[true, true]);
if standard == Standard::PdfX4 {
assert!(designcraft_pdf::check_pdfx4(&report.bytes).is_empty());
}
}
#[test]
fn plain_export_accepts_repaired_xref_when_all_layers_are_visible() {
all_visible(Standard::None);
}
#[test]
fn pdfx_export_accepts_repaired_xref_when_all_layers_are_visible() {
all_visible(Standard::PdfX4);
}
#[test]
fn booklet_accepts_repaired_xref_when_all_layers_are_visible() {
let doc = document(broken_xref(fixture("/BaseState/ON", "Hidden")), &[&[]]);
let options = BookletOptions { kind: BookletKind::TwoUpConsecutive, ..Default::default() };
let report = export_booklet(&doc, &Cache::new(), &options).unwrap();
assert!(report.warnings.is_empty());
assert_eq!(catalog(&report.bytes), [("Hidden".into(), false), ("Shown".into(), false)]);
super::assert_vector(&report.bytes);
painted(&report.bytes, &[true, true, false, false]);
}
#[test]
fn book_merge_accepts_repaired_xref_when_all_layers_are_visible() {
let source = broken_xref(fixture("/BaseState/ON", "Hidden"));
let bytes = merge_pdfs(&[(source, vec![(100.0, 100.0)])], None).unwrap();
assert_eq!(catalog(&bytes), [("Hidden".into(), false), ("Shown".into(), false)]);
super::assert_vector(&bytes);
painted(&bytes, &[true, true]);
}
#[test]
fn repaired_xref_with_default_off_fails_with_named_damage_error() {
let doc = document(broken_xref(fixture("/BaseState/ON/OFF[6 3 R]", "Hidden")), &[&[]]);
for standard in [Standard::None, Standard::PdfX4] {
let error = export_pdf(&doc, &Cache::new(), &PdfOptions { standard, ..Default::default() }).unwrap_err().to_string();
assert!(error.contains("layers.pdf"), "{error}");
assert!(error.contains("cross-reference table is damaged"), "{error}");
assert!(!error.contains("optional-content state"), "{error}");
}
}
#[test]
fn repaired_xref_with_explicit_hidden_layer_fails_with_named_damage_error() {
let doc = document(broken_xref(fixture("/BaseState/ON", "Hidden")), &[&["Hidden"]]);
let error = export_pdf(&doc, &Cache::new(), &PdfOptions::default()).unwrap_err().to_string();
assert!(error.contains("layers.pdf") && error.contains("cross-reference table is damaged"), "{error}");
}
#[test]
fn repaired_xref_needing_generated_name_fails_with_named_damage_error() {
let doc = document(broken_xref(nameless_fixture("/BaseState/ON")), &[&[]]);
let error = export_pdf(&doc, &Cache::new(), &PdfOptions::default()).unwrap_err().to_string();
assert!(error.contains("layers.pdf") && error.contains("cross-reference table is damaged"), "{error}");
}
fn two_assets(first: Vec<u8>, second: Vec<u8>) -> designcraft_doc::Document {
let mut doc = document(first, &[&[], &[]]);
let original = doc.assets.values().next().unwrap().as_ref().clone();
Arc::make_mut(doc.assets.values_mut().next().unwrap()).name = "first.pdf".into();
let id = AssetId(doc.alloc());
let mut other = original;
other.id = id;
other.name = "second.pdf".into();
other.data = Arc::new(second);
doc.add_asset(other);
let spread = Arc::make_mut(doc.spreads.first_mut().unwrap());
let item = Arc::make_mut(spread.items.last_mut().unwrap());
let Content::Graphic(graphic) = &mut item.content else { panic!("fixture must have a graphic") };
graphic.asset = id;
doc
}
#[test]
fn error_identifies_unresolvable_pdf_among_two_placements() {
let doc = two_assets(fixture("/BaseState/ON", "Hidden"), fixture("/OFF[99 0 R]", "Hidden"));
let error = export_pdf(&doc, &Cache::new(), &PdfOptions::default()).unwrap_err().to_string();
assert_eq!(error, "PDF writer error: second.pdf: the placed PDF's optional-content state could not be preserved safely");
}
#[test]
fn first_placement_error_is_not_overwritten() {
let doc = two_assets(fixture("/OFF[99 0 R]", "Hidden"), fixture("/OFF[98 0 R]", "Hidden"));
let error = export_pdf(&doc, &Cache::new(), &PdfOptions::default()).unwrap_err().to_string();
assert!(error.contains("first.pdf"), "{error}");
assert!(!error.contains("second.pdf"), "{error}");
}
#[test]
fn repeated_recoverable_variant_reports_one_warning_and_paints_each_placement() {
let doc = document(fixture("/BaseState/Unchanged", "Hidden"), &[&[], &[]]);
let report = export_pdf_with_report(&doc, &Cache::new(), &PdfOptions::default()).unwrap();
assert_eq!(report.warnings.len(), 1);
assert!(report.warnings[0].contains("layers.pdf"));
painted(&report.bytes, &[true, true, true, true]);
}
+205 -31
View File
@@ -15,8 +15,8 @@ use std::io::Write;
use designcraft_doc::geom::{Affine, Rect};
use designcraft_doc::{
Align, AnchorPosition, AnchoredObject, CharAttrs, CharProps, Content, Document, ENDNOTE_REF, FOOTNOTE_REF, Item, ItemId, OBJECT_MARK, ParaProps,
SpreadRef, Story, StoryId, Styles, Table, story,
Align, AnchorPosition, AnchoredObject, CharAttrs, CharProps, Content, Document, ENDNOTE_REF, EpubSplit, FOOTNOTE_REF, Item, ItemId, OBJECT_MARK,
ParaProps, SpreadRef, Story, StoryId, Styles, Table, story,
};
#[derive(Clone, Debug)]
@@ -38,6 +38,8 @@ pub struct EpubOptions {
pub modified: Option<i64>,
/// Title of the navigation (table of contents); from `language` when absent.
pub toc_title: Option<String>,
/// Split Document: where content files start; the document's setting when absent.
pub split: Option<EpubSplit>,
}
/// A font file packaged with an EPUB: the face of `family` in `style` (`Bold Italic`…).
@@ -288,6 +290,7 @@ impl Default for EpubOptions {
fonts: Vec::new(),
modified: None,
toc_title: None,
split: None,
}
}
}
@@ -450,11 +453,12 @@ fn char_css(doc: &Document, c: &CharProps, base_size: f64, generics: &BTreeMap<S
}
_ => {}
}
if c.underline {
out.push_str("text-decoration: underline; ");
}
if c.strikethrough {
out.push_str("text-decoration: line-through; ");
// One declaration: a later `text-decoration` would replace the earlier one.
match (c.underline, c.strikethrough) {
(true, true) => out.push_str("text-decoration: underline line-through; "),
(true, false) => out.push_str("text-decoration: underline; "),
(false, true) => out.push_str("text-decoration: line-through; "),
(false, false) => {}
}
if let Some(h) = hex(doc, &c.fill, c.fill_tint) {
let _ = write!(out, "color: {h}; ");
@@ -668,6 +672,8 @@ struct StoryWriter<'a, 'f> {
/// Prefix of the `id`s the current story's headings get (`{prefix}-p{paragraph index}`, the
/// targets of the table of contents); only top-level paragraphs get one.
heading_ids: Option<String>,
/// Where EPUB content files start (see [`starts_chapter`]).
split: EpubSplit,
}
impl<'a, 'f> StoryWriter<'a, 'f> {
@@ -686,6 +692,7 @@ impl<'a, 'f> StoryWriter<'a, 'f> {
images: BTreeMap::new(),
no_alt: Vec::new(),
heading_ids: None,
split: doc.settings.epub_split.clone(),
}
}
@@ -724,6 +731,12 @@ impl<'a, 'f> StoryWriter<'a, 'f> {
let (tag, class) = export_mapping(&doc.styles, &pf.style, false, &self.pc);
let props = doc.styles.resolve_para(pf).0;
let rtl = props.direction == designcraft_doc::TextDirection::RightToLeft;
// Chapter files start only at top-level paragraphs, after any open list or
// preformatted block is closed, so each file stays balanced.
if depth == 0 && self.heading_ids.is_some() && starts_chapter(&self.split, &doc.styles, &pf.style) {
close_blocks(&mut list, &mut pre, out);
out.push_str(CHAPTER_MARK);
}
let continues_pre = tag == "pre" && pre.as_ref() == Some(&(class.clone(), rtl));
if !continues_pre && pre.take().is_some() {
out.push_str("</pre>\n");
@@ -740,11 +753,6 @@ impl<'a, 'f> StoryWriter<'a, 'f> {
}
list = list_tag;
}
// An EPUB chapter file starts before each top-level h1, outside any open list or
// preformatted block.
if depth == 0 && self.heading_ids.is_some() && heading_level(tag) == Some(1) {
out.push_str(CHAPTER_MARK);
}
if continues_pre {
out.push('\n');
} else {
@@ -1049,7 +1057,18 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String {
StoryWriter::new(doc, true, None).story(sid, None)
}
/// Where the EPUB body splits into chapter files: before each `h1`.
/// Whether an EPUB chapter file starts before a paragraph of `style` (Split Document): never
/// when it's off, before each paragraph of the one style it names, or before each paragraph of
/// a style whose Export Tagging has Split Document on.
fn starts_chapter(split: &EpubSplit, st: &Styles, style: &str) -> bool {
match split {
EpubSplit::Off => false,
EpubSplit::Style(name) => name == style,
EpubSplit::ExportTags => st.export_tag(style, false).is_some_and(|e| e.split),
}
}
/// Where the EPUB body splits into chapter files (see [`starts_chapter`]).
const CHAPTER_MARK: &str = "<!--dc:chapter-->";
/// The body split at its [`CHAPTER_MARK`]s into chapter bodies, each with its sections closed
@@ -1305,9 +1324,12 @@ struct Body<'a> {
/// The content in reading order as HTML sections and figures; `img_src` names each image
/// (a package path, or a data URI for single-file HTML).
fn body_html<'a>(doc: &'a Document, epub: bool, img_src: &dyn Fn(&str, &str, &[u8]) -> String) -> Body<'a> {
fn body_html<'a>(doc: &'a Document, epub: bool, split: Option<&EpubSplit>, img_src: &dyn Fn(&str, &str, &[u8]) -> String) -> Body<'a> {
let order = reading_order(doc);
let mut writer = StoryWriter::new(doc, epub, Some(img_src));
if let Some(split) = split {
writer.split = split.clone();
}
let mut body = String::new();
let mut toc = toc_entries(doc, &order);
for (k, b) in order.iter().enumerate() {
@@ -1354,7 +1376,7 @@ fn body_html<'a>(doc: &'a Document, epub: bool, img_src: &dyn Fn(&str, &str, &[u
/// Placed and anchored images (not video or sound) without alt text, by asset name: they are exported as
/// decorative (`alt=""`), which an export can report.
pub fn images_without_alt(doc: &Document) -> Vec<String> {
body_html(doc, true, &|name, _, _| name.to_string()).no_alt
body_html(doc, true, None, &|name, _, _| name.to_string()).no_alt
}
/// Options for [`export_html`].
@@ -1372,7 +1394,7 @@ pub fn export_html(doc: &Document, opts: &HtmlOptions) -> String {
let title = opts.title.clone().unwrap_or_else(|| doc.title.clone());
let language = opts.language.as_deref().unwrap_or("en");
let lang = language_tag(language);
let body = body_html(doc, false, &|_, mime, data| format!("data:{mime};base64,{}", base64(data))).html;
let body = body_html(doc, false, None, &|_, mime, data| format!("data:{mime};base64,{}", base64(data))).html;
format!(
"<!DOCTYPE html>\n<html lang=\"{lang}\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<meta name=\"generator\" content=\"DesignCraft\">\n<title>{}</title>\n<style>\nbody {{ max-width: 40em; margin: 2em auto; padding: 0 1em; }}\nfigure {{ margin: 1em 0; }} figure img {{ max-width: 100%; height: auto; }}\n{}</style>\n</head>\n<body>\n{body}</body>\n</html>\n",
xml_text(&title),
@@ -1397,9 +1419,9 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result<Vec<u8>, EpubEr
let title = opts.title.clone().unwrap_or_else(|| doc.title.clone());
let id = identifier(doc, opts, &title);
let modified = modified(doc, opts);
let Body { html: body, images, toc, .. } = body_html(doc, true, &|name, _, _| name.to_string());
let Body { html: body, images, toc, .. } = body_html(doc, true, opts.split.as_ref(), &|name, _, _| name.to_string());
let lang = language_tag(&opts.language);
// One file per chapter (split before each h1), so e-readers needn't load the whole book.
// One file per chapter (see `starts_chapter`); one file in all when Split Document is off.
let chapters: Vec<String> = split_chapters(&body)
.into_iter()
.map(|part| {
@@ -1531,6 +1553,7 @@ mod tests {
let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)));
item.alt_text = alt.into();
item.content = Content::Graphic(Graphic {
layer_options: None,
asset: aid,
size: (1.0, 1.0),
xf: Affine::IDENTITY,
@@ -1652,7 +1675,9 @@ mod tests {
chars: CharAttrs::default(),
shortcut: String::new(),
});
d.styles_mut().export_tags.insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), class: String::new() });
d.styles_mut()
.export_tags
.insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), class: String::new(), ..Default::default() });
}
let text = "Intro\nfirst\nsecond\none\ntwo\nx = 1\ny = 2\nEnd\n• typed";
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 300.0), lid, text, ParaFormat::default()).unwrap();
@@ -1695,7 +1720,9 @@ mod tests {
chars: CharAttrs::default(),
shortcut: String::new(),
});
d.styles_mut().export_tags.insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), class: String::new() });
d.styles_mut()
.export_tags
.insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), class: String::new(), ..Default::default() });
}
let text = "One\nBody\nOne point one\nMore\nTwo\nText";
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 300.0), lid, text, ParaFormat::default()).unwrap();
@@ -1714,12 +1741,12 @@ mod tests {
let nl = EpubOptions { language: "nl".into(), ..Default::default() };
let nav = read(&d, &nl, "OEBPS/nav.xhtml");
let link = |f: &str, t: &str, l: &str| format!("<a href=\"{f}#{t}\">{l}</a>");
// Each h1 starts a chapter file (#768).
// Split Document is off: every heading is in the one content file.
let want = format!(
"<h1>Inhoud</h1><ol><li>{}<ol><li>{}</li></ol></li><li>{}</li></ol>",
link("content.xhtml", "s0-p0", "One"),
link("content.xhtml", "s0-p2", "One point one"),
link("content2.xhtml", "s0-p4", "Two")
link("content.xhtml", "s0-p4", "Two")
);
assert!(nav.contains(&want), "{nav}");
assert!(!nav.contains("Text after the figure"), "{nav}");
@@ -1759,10 +1786,11 @@ mod tests {
);
}
/// #768: the book is split into one file per chapter, before each h1, with well-formed
/// sections; the spine and manifest list the files in order.
/// Split Document off (the default) gives one content file, h1s and all. Single Paragraph
/// Style starts a file before each paragraph of that style, with well-formed sections; the
/// spine and manifest list the files in order.
#[test]
fn chapters_are_split_at_h1() {
fn chapters_split_before_a_single_paragraph_style() {
let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() });
let lid = d.default_layer();
d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle {
@@ -1773,7 +1801,9 @@ mod tests {
chars: CharAttrs::default(),
shortcut: String::new(),
});
d.styles_mut().export_tags.insert("p:Chapter".into(), designcraft_doc::ExportTag { tag: "h1".into(), class: String::new() });
d.styles_mut()
.export_tags
.insert("p:Chapter".into(), designcraft_doc::ExportTag { tag: "h1".into(), class: String::new(), ..Default::default() });
// A title page without a heading, then a story that opens with a chapter and has a
// second one in the middle.
d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Title page", ParaFormat::default()).unwrap();
@@ -1783,6 +1813,15 @@ mod tests {
let st = d.story_mut(sid).unwrap();
st.paras[0].style = "Chapter".into();
st.paras[2].style = "Chapter".into();
let one_file = |d: &Document, opts: &EpubOptions| {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(d, opts).unwrap())).unwrap();
let mut c = String::new();
z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut c).unwrap();
z.by_name("OEBPS/content2.xhtml").is_err() && c.contains("Title page") && c.contains("Second text") && !c.contains(CHAPTER_MARK)
};
assert!(one_file(&d, &EpubOptions::default()), "off by default");
d.settings.epub_split = EpubSplit::Style("Chapter".into());
assert!(one_file(&d, &EpubOptions { split: Some(EpubSplit::Off), ..Default::default() }), "the export's own setting wins");
let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).unwrap())).unwrap();
let mut read = |path: &str| {
let mut s = String::new();
@@ -1808,6 +1847,61 @@ mod tests {
assert!(!html.contains(CHAPTER_MARK) && html.contains("Second text"));
}
/// Based on export tags, a style with Split Document on starts each chapter file and an h1
/// without it doesn't; navigation and endnote links point at the files their targets ended
/// up in.
#[test]
fn chapters_split_before_split_document_styles() {
let mut d = Document::new(&NewDocument::default());
d.settings.epub_split = EpubSplit::ExportTags;
let lid = d.default_layer();
for (name, tag, split) in [("Part", "p", true), ("Chapter", "h1", false)] {
d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle {
name: name.into(),
based_on: None,
next_style: None,
para: Default::default(),
chars: CharAttrs::default(),
shortcut: String::new(),
});
d.styles_mut().export_tags.insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), split, ..Default::default() });
}
d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Title page", ParaFormat::default()).unwrap();
let (_, sid) = d
.add_text_frame(
SpreadRef::Doc(0),
Rect::new(36.0, 200.0, 300.0, 600.0),
lid,
"Part One\nChapter A\ntext a\nPart Two\nChapter B\ntext b",
ParaFormat::default(),
)
.unwrap();
let st = d.story_mut(sid).unwrap();
for (i, style) in [(0, "Part"), (1, "Chapter"), (3, "Part"), (4, "Chapter")] {
st.paras[i].style = style.into();
}
// After "text a".
st.insert_endnote(25, "End note", ParaFormat::default());
let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).unwrap())).unwrap();
let mut read = |path: &str| {
let mut s = String::new();
z.by_name(path).unwrap().read_to_string(&mut s).unwrap();
s
};
let (c1, c2, c3) = (read("OEBPS/content.xhtml"), read("OEBPS/content2.xhtml"), read("OEBPS/content3.xhtml"));
assert!(c1.contains("Title page") && !c1.contains("Part One"), "{c1}");
assert!(c2.contains("Part One") && c2.contains("<h1 id=\"s1-p1\"") && !c2.contains("Part Two"), "the h1 stays with its part: {c2}");
assert!(c3.contains("Part Two") && c3.contains("<h1 id=\"s1-p4\"") && c3.contains("End note"), "{c3}");
for c in [&c1, &c2, &c3] {
assert_eq!(c.matches("<section").count(), c.matches("</section>").count(), "{c}");
}
let nav = read("OEBPS/nav.xhtml");
assert!(nav.contains("href=\"content2.xhtml#s1-p1\"") && nav.contains("href=\"content3.xhtml#s1-p4\""), "{nav}");
assert!(c2.contains("href=\"content3.xhtml#en-1\""), "{c2}");
assert!(c3.contains("href=\"content2.xhtml#enref-1\""), "the endnote links back across files: {c3}");
assert!(z.by_name("OEBPS/content4.xhtml").is_err());
}
/// Note links stay working across chapter files: endnotes end the book, in the last file.
#[test]
fn note_links_point_at_the_chapter_file_of_their_target() {
@@ -1821,7 +1915,9 @@ mod tests {
chars: CharAttrs::default(),
shortcut: String::new(),
});
d.styles_mut().export_tags.insert("p:Chapter".into(), designcraft_doc::ExportTag { tag: "h1".into(), class: String::new() });
d.styles_mut()
.export_tags
.insert("p:Chapter".into(), designcraft_doc::ExportTag { tag: "h1".into(), class: String::new(), ..Default::default() });
let (_, sid) = d
.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 400.0), lid, "One\nFirst text\nTwo\nSecond text", ParaFormat::default())
.unwrap();
@@ -1831,6 +1927,7 @@ mod tests {
// After "First": an endnote and a footnote.
st.insert_endnote(9, "End note", ParaFormat::default());
st.insert_note(9, "Foot note", ParaFormat::default());
d.settings.epub_split = EpubSplit::Style("Chapter".into());
let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).unwrap())).unwrap();
let mut read = |path: &str| {
let mut s = String::new();
@@ -1843,6 +1940,61 @@ mod tests {
assert!(last.contains("Second text") && last.contains("<a href=\"content.xhtml#enref-1\">"), "{last}");
}
/// A split before a list item or a preformatted line closes the open list or block first and
/// reopens it in the new file, so every file is balanced.
#[test]
fn chapter_files_split_inside_lists_and_pre_stay_balanced() {
let mut d = Document::new(&NewDocument::default());
d.settings.epub_split = EpubSplit::ExportTags;
let lid = d.default_layer();
for (name, tag) in [("Item", "li"), ("Code", "pre")] {
d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle {
name: name.into(),
based_on: None,
next_style: None,
para: Default::default(),
chars: CharAttrs::default(),
shortcut: String::new(),
});
d.styles_mut().export_tags.insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), split: true, ..Default::default() });
}
let (_, sid) = d
.add_text_frame(
SpreadRef::Doc(0),
Rect::new(36.0, 36.0, 300.0, 300.0),
lid,
"Intro
one
two
x = 1
y = 2
End",
ParaFormat::default(),
)
.unwrap();
let st = d.story_mut(sid).unwrap();
for (i, style) in [(1, "Item"), (2, "Item"), (3, "Code"), (4, "Code")] {
st.paras[i].style = style.into();
}
let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).unwrap())).unwrap();
let mut read = |path: &str| {
let mut s = String::new();
z.by_name(path).unwrap().read_to_string(&mut s).unwrap();
s
};
let files: Vec<String> =
["content", "content2", "content3", "content4", "content5"].iter().map(|f| read(&format!("OEBPS/{f}.xhtml"))).collect();
for (c, text) in files.iter().zip(["Intro", "one", "two", "x = 1", "y = 2"]) {
assert!(c.contains(text), "{text}: {c}");
for (open, close) in [("<ul", "</ul>"), ("<pre", "</pre>"), ("<section", "</section>")] {
assert_eq!(c.matches(open).count(), c.matches(close).count(), "{open}: {c}");
}
}
assert!(files[1].contains("<ul") && files[2].contains("<ul") && files[3].contains("<pre") && files[4].contains("<pre"));
assert!(files[4].contains("End"));
assert!(z.by_name("OEBPS/content6.xhtml").is_err());
}
#[test]
fn split_chapters_keeps_sections_well_formed() {
let m = CHAPTER_MARK;
@@ -1939,6 +2091,7 @@ mod tests {
let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(36.0, y, 72.0, y + 36.0)));
item.alt_text = alt.into();
item.content = Content::Graphic(Graphic {
layer_options: None,
asset: aid,
size: (1.0, 1.0),
xf: Affine::IDENTITY,
@@ -2033,9 +2186,10 @@ mod tests {
d.title = "Field Notes & <Edition>".into();
let lid = d.default_layer();
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "A&B <C> \"D\"", ParaFormat::default()).unwrap();
d.styles_mut()
.export_tags
.insert(format!("p:{}", story::BASIC_PARAGRAPH), designcraft_doc::ExportTag { tag: "h2".into(), class: "Primary \"Copy\"".into() });
d.styles_mut().export_tags.insert(
format!("p:{}", story::BASIC_PARAGRAPH),
designcraft_doc::ExportTag { tag: "h2".into(), class: "Primary \"Copy\"".into(), ..Default::default() },
);
if let Some(basic) = d.styles_mut().paragraph.iter_mut().find(|s| s.name == story::BASIC_PARAGRAPH) {
basic.chars.font_family = Some("Quoted \"Serif\" <Alt>".into());
}
@@ -2055,6 +2209,7 @@ mod tests {
let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(36.0, 120.0, 72.0, 156.0)));
item.alt_text = "Cover \"A\" & B".into();
item.content = Content::Graphic(Graphic {
layer_options: None,
asset: aid,
size: (1.0, 1.0),
xf: Affine::IDENTITY,
@@ -2169,7 +2324,9 @@ mod tests {
chars: CharAttrs::default(),
shortcut: String::new(),
});
d.styles_mut().export_tags.insert("p:Grouped".into(), designcraft_doc::ExportTag { tag: tag.into(), class: String::new() });
d.styles_mut()
.export_tags
.insert("p:Grouped".into(), designcraft_doc::ExportTag { tag: tag.into(), class: String::new(), ..Default::default() });
let sid = d.stories.keys().copied().find(|sid| d.story(*sid).is_some_and(|st| !st.notes.is_empty())).unwrap();
for p in &mut d.story_mut(sid).unwrap().paras {
p.style = "Grouped".into();
@@ -2231,4 +2388,21 @@ mod tests {
let last = "<li id=\"en-1\"><a href=\"#enref-1\">1</a> End note</li>";
assert_eq!(link_across_chapters(last, "content2.xhtml", &files), "<li id=\"en-1\"><a href=\"content.xhtml#enref-1\">1</a> End note</li>");
}
#[test]
fn char_css_joins_underline_and_strikethrough_in_one_declaration() {
let d = Document::new(&NewDocument::default());
let css = |u: bool, s: bool| {
let c = CharProps { underline: u, strikethrough: s, ..CharProps::default() };
let mut out = String::new();
char_css(&d, &c, 12.0, &BTreeMap::new(), &mut out);
out
};
let both = css(true, true);
assert_eq!(both.matches("text-decoration:").count(), 1, "{both}");
assert!(both.contains("text-decoration: underline line-through;"), "{both}");
assert!(css(true, false).contains("text-decoration: underline;"));
assert!(css(false, true).contains("text-decoration: line-through;"));
assert!(!css(false, false).contains("text-decoration"));
}
}
+2 -2
View File
@@ -39,8 +39,8 @@ fn fixture() -> Document {
fn map(doc: &mut Document, paragraph_tag: &str, paragraph_class: &str, character_tag: &str, character_class: &str) {
let tags = &mut doc.styles_mut().export_tags;
tags.insert("p:Chapter Title".into(), ExportTag { tag: paragraph_tag.into(), class: paragraph_class.into() });
tags.insert("c:Key Term".into(), ExportTag { tag: character_tag.into(), class: character_class.into() });
tags.insert("p:Chapter Title".into(), ExportTag { tag: paragraph_tag.into(), class: paragraph_class.into(), ..Default::default() });
tags.insert("c:Key Term".into(), ExportTag { tag: character_tag.into(), class: character_class.into(), ..Default::default() });
}
fn package(doc: &Document) -> (String, String) {
@@ -100,6 +100,7 @@ fn nested_and_item_rotations_are_combined_before_measuring_graphics() {
let image = ItemId(doc.alloc());
let mut item = Item::new(image, doc.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 10.0)));
item.content = Content::Graphic(Graphic {
layer_options: None,
asset: aid,
size: (1.0, 1.0),
xf: Affine::IDENTITY,
+130 -6
View File
@@ -22,8 +22,11 @@ use serde_json::{Value, json};
pub const MIME: &str = "application/vnd.designcraft+zip";
/// Latest reader capability. Version 2 pinned the Keep Lines Together and drop cap defaults;
/// version 3 adds rendering of retained EPS source assets.
pub const VERSION: u32 = 3;
/// version 3 adds rendering of retained EPS source assets; version 4 is one capability covering both
/// retained placed-graphic layer options and explicit all-on placed-PDF layer choices, so a version 4
/// reader understands both. Any later capability needs version 5. [`required_version`] is the only
/// place that picks a version.
pub const VERSION: u32 = 4;
#[derive(Debug, thiserror::Error)]
pub enum FormatError {
@@ -67,6 +70,21 @@ pub fn save_with_preview(doc: &Document, preview: Option<&[u8]>) -> Result<Vec<u
/// 4 GiB written without them; the margin covers deflate output that is larger than its input.
const LARGE_ENTRY: usize = (u32::MAX as usize).saturating_sub(64 << 20);
/// The oldest reader capability that keeps everything in `doc`. Documents that need nothing newer
/// keep their older requirement so existing readers still open them.
fn required_version(doc: &Document) -> u32 {
// Version 4 covers both of these: older readers ignore the unknown fields, so they would
// silently drop retained graphic layer choices, or read an explicit all-on PDF layer choice
// (empty hidden list) as "use the file's defaults". A nonempty hidden list stays readable.
if doc.has_graphic_layer_options() || doc.has_explicit_all_on_pdf_layers() {
return 4;
}
// Older readers would silently omit EPS artwork. Preview-only legacy assets and documents
// without EPS stay at 2 so their existing readers keep working; writing 1 would make a
// reader re-run the version 2 paragraph upgrade over current defaults.
if doc.assets.values().any(|a| designcraft_images::is_eps(&a.data)) { 3 } else { 2 }
}
fn save_with_large_entries_from(doc: &Document, preview: Option<&[u8]>, large_entry: usize) -> Result<Vec<u8>, FormatError> {
// JSON writes infinity and NaN as null, which no load accepts: the file would not open.
if let Some(id) = doc.non_finite_item() {
@@ -84,10 +102,7 @@ fn save_with_large_entries_from(doc: &Document, preview: Option<&[u8]>, large_en
z.start_file("mimetype", stored).map_err(io)?;
z.write_all(MIME.as_bytes()).map_err(|e| FormatError::Io(e.to_string()))?;
z.start_file("meta.json", deflate).map_err(io)?;
// Older readers would silently omit EPS artwork. Preview-only legacy assets and
// documents without EPS stay at 2 so their existing readers keep working; writing 1
// would make a reader re-run the version 2 paragraph upgrade over current defaults.
let version = if doc.assets.values().any(|a| designcraft_images::is_eps(&a.data)) { VERSION } else { 2 };
let version = required_version(doc);
let meta = json!({"format": "designcraft", "version": version, "generator": concat!("DesignCraft ", env!("CARGO_PKG_VERSION"))});
z.write_all(meta.to_string().as_bytes()).map_err(|e| FormatError::Io(e.to_string()))?;
if let Some(png) = preview {
@@ -404,6 +419,7 @@ mod tests {
let id = ItemId(d.alloc());
let mut it = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)));
it.content = Content::Graphic(Graphic {
layer_options: None,
asset,
size: (1.0, 1.0),
xf: Affine::translate((crop, crop)),
@@ -440,6 +456,95 @@ mod tests {
buf.into_inner()
}
#[test]
fn explicit_all_on_pdf_layers_require_a_capable_reader_only_when_needed() {
let mut d = Document::new(&NewDocument::default());
let aid = AssetId(d.alloc());
d.assets.insert(
aid,
Arc::new(Asset {
id: aid,
name: "art.pdf".into(),
mime: "application/pdf".into(),
data: Arc::new(b"%PDF-1.7".to_vec()),
..Default::default()
}),
);
let id = place(&mut d, aid, 0.0);
let version = |d: &Document| {
let bytes = save(d).unwrap();
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
let mut meta = String::new();
z.by_name("meta.json").unwrap().read_to_string(&mut meta).unwrap();
serde_json::from_str::<Value>(&meta).unwrap()["version"].as_u64().unwrap()
};
assert_eq!(version(&d), 2);
d.item_mut(id).unwrap().pdf_layer_override = true;
assert_eq!(version(&d), 4);
assert!(load(&save(&d).unwrap()).unwrap().item(id).unwrap().pdf_layer_override);
d.item_mut(id).unwrap().pdf_hidden_layers = vec!["Red".into()];
assert_eq!(version(&d), 2, "legacy nonempty overrides remain readable");
}
#[test]
fn one_version_4_reader_keeps_both_explicit_all_on_and_graphic_layer_options() {
use designcraft_doc::{Content, GraphicLayerOptions};
let mut d = Document::new(&NewDocument::default());
let pdf = AssetId(d.alloc());
d.assets.insert(
pdf,
Arc::new(Asset {
id: pdf,
name: "art.pdf".into(),
mime: "application/pdf".into(),
data: Arc::new(b"%PDF-1.7".to_vec()),
..Default::default()
}),
);
let eps = AssetId(d.alloc());
let source = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n%%EOF\n".to_vec();
d.assets.insert(
eps,
Arc::new(Asset { id: eps, name: "logo.eps".into(), mime: "application/postscript".into(), data: Arc::new(source), ..Default::default() }),
);
let all_on = place(&mut d, pdf, 0.0);
let layered = place(&mut d, eps, 0.0);
assert_eq!(required_version(&d), 3, "neither feature: EPS documents keep version 3");
let options = GraphicLayerOptions { layers: vec![[("Name".to_string(), "Art".to_string())].into()], ..Default::default() };
let set_options = |d: &mut Document, value: Option<GraphicLayerOptions>| {
let Some(Content::Graphic(g)) = d.item_mut(layered).map(|it| &mut it.content) else { panic!("placed graphic") };
g.layer_options = value;
};
set_options(&mut d, Some(options.clone()));
d.item_mut(all_on).unwrap().pdf_layer_override = true;
let bytes = save(&d).unwrap();
let mut z = zip::ZipArchive::new(Cursor::new(&bytes)).unwrap();
let mut meta = String::new();
z.by_name("meta.json").unwrap().read_to_string(&mut meta).unwrap();
assert_eq!(serde_json::from_str::<Value>(&meta).unwrap()["version"], 4);
let reopened = load(&bytes).unwrap();
let it = reopened.item(all_on).unwrap();
assert!(it.pdf_layer_override && it.pdf_hidden_layers.is_empty(), "explicit all-on survives");
assert_eq!(reopened.item(layered).unwrap().graphic().unwrap().layer_options.as_ref(), Some(&options), "graphic layer options survive");
assert_eq!(required_version(&reopened), 4);
// Either feature alone needs version 4; dropping both restores the older requirement.
set_options(&mut d, None);
assert_eq!(required_version(&d), 4, "explicit all-on alone");
d.item_mut(all_on).unwrap().pdf_layer_override = false;
assert_eq!(required_version(&d), 3);
set_options(&mut d, Some(options));
assert_eq!(required_version(&d), 4, "graphic layer options alone");
set_options(&mut d, None);
d.assets.remove(&eps);
d.remove_item(layered).unwrap();
assert_eq!(required_version(&d), 2, "neither feature and no EPS: version 2");
// An explicit all-on choice on a parent spread also needs the version 4 reader.
let mut parent_item = (*d.remove_item(all_on).unwrap()).clone();
parent_item.pdf_layer_override = true;
d.insert_item(SpreadRef::Parent(0), parent_item, None).unwrap();
assert_eq!(required_version(&d), 4, "parent-spread placements are not skipped");
}
#[test]
fn eps_source_requires_new_reader_but_preview_documents_keep_the_older_one() {
let mut d = Document::new(&NewDocument::default());
@@ -473,6 +578,25 @@ mod tests {
assert_eq!(version(&d), 2, "legacy EPS previews stay compatible");
}
#[test]
fn one_place_picks_the_oldest_reader_that_keeps_the_document() {
use designcraft_doc::{Content, GraphicLayerOptions};
let mut d = Document::new(&NewDocument::default());
assert_eq!(required_version(&d), 2, "plain documents keep the version 2 reader");
let aid = AssetId(d.alloc());
let eps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n%%EOF\n".to_vec();
d.assets.insert(
aid,
Arc::new(Asset { id: aid, name: "logo.eps".into(), mime: "application/postscript".into(), data: Arc::new(eps), ..Default::default() }),
);
let id = place(&mut d, aid, 0.0);
assert_eq!(required_version(&d), 3, "EPS sources alone keep the version 3 reader");
let Some(Content::Graphic(g)) = d.item_mut(id).map(|it| &mut it.content) else { panic!("placed graphic") };
g.layer_options = Some(GraphicLayerOptions { layers: vec![[("Name".to_string(), "Art".to_string())].into()], ..Default::default() });
assert_eq!(required_version(&d), 4, "retained graphic layer options need version 4 even with EPS");
assert_eq!(required_version(&load(&save(&d).unwrap()).unwrap()), 4);
}
#[test]
fn roundtrip_with_assets() {
let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() });
+18 -1
View File
@@ -281,9 +281,12 @@ impl PathData {
for el in bp.elements() {
match *el {
PathEl::MoveTo(p) => {
if let Some(sp) = cur.take()
if let Some(mut sp) = cur.take()
&& !sp.anchors.is_empty()
{
for a in &mut sp.anchors {
*a = Anchor::with_handles(a.p, a.h_in, a.h_out);
}
subpaths.push(sp);
}
cur = Some(SubPath { anchors: vec![Anchor::corner(p)], closed: false });
@@ -418,6 +421,20 @@ mod tests {
PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0), Point::new(0.0, 10.0)], true))
}
#[test]
fn from_bezpath_detects_smooth_on_every_open_subpath() {
let mut bp = BezPath::new();
for _ in 0..2 {
bp.move_to((0.0, 0.0));
bp.curve_to((0.0, 10.0), (10.0, 10.0), (10.0, 0.0));
bp.curve_to((10.0, -10.0), (20.0, -10.0), (20.0, 0.0));
}
let pd = PathData::from_bezpath(&bp);
assert_eq!(pd.subpaths.len(), 2);
assert_eq!(pd.subpaths[1].anchors[1].kind, AnchorKind::Smooth);
assert_eq!(pd.subpaths[0].anchors[1].kind, pd.subpaths[1].anchors[1].kind);
}
#[test]
fn anchors_serialize_compactly_and_read_v1_maps() {
let corner = Anchor::corner(Point::new(1.0, 2.0));
+83 -2
View File
@@ -20,6 +20,15 @@ const TOC_STYLE_ID: &str = "TOCStyle/Table of Contents";
const TOC_STYLE_NAME: &str = "Table of Contents";
const NO_CHAR_ID: &str = "CharacterStyle/$ID/[No character style]";
// Retained IDML attributes have unprefixed ASCII names. Native JSON can be
// edited independently: never let its map keys become XML syntax or Self IDs.
fn graphic_layer_attribute_name(key: &str) -> bool {
!key.is_empty()
&& key != "Self"
&& key != "xmlns"
&& key.bytes().enumerate().all(|(i, b)| b.is_ascii_alphabetic() || b == b'_' || (i > 0 && (b.is_ascii_digit() || matches!(b, b'.' | b'-'))))
}
/// Export options.
#[derive(Clone, Debug)]
pub struct ExportOptions {
@@ -778,6 +787,15 @@ impl<'a> Ex<'a> {
designcraft_doc::BlendSpace::Rgb => "RGB",
},
));
// Split Document: the style is a reference, a built-in one by its `$ID/` name; an empty
// one splits on the styles' export tags.
let (break_doc, split_style) = match &s.epub_split {
designcraft_doc::EpubSplit::Off => (false, names::style_name_out(PARA_BUILTINS, st::BASIC_PARAGRAPH)),
designcraft_doc::EpubSplit::ExportTags => (true, String::new()),
designcraft_doc::EpubSplit::Style(n) if PARA_BUILTINS.iter().any(|(o, _)| o == n) => (true, names::style_name_out(PARA_BUILTINS, n)),
designcraft_doc::EpubSplit::Style(n) => (true, names::style_self("ParagraphStyle", PARA_BUILTINS, n)),
};
root.push(El::new("EPubExportPreference").attr("BreakDocument", bool_s(break_doc)).attr("ParagraphStyleName", split_style));
let a = &s.advanced_type;
root.push(
El::new("TextPreference")
@@ -848,6 +866,9 @@ impl<'a> Ex<'a> {
let pa = with_list_tab(pa, || (styles.resolve_para_style(&ps.name).0, parent_props));
self.para_attrs(&mut el, &mut props, &pa, parent);
self.char_attrs(&mut el, &mut props, &ca);
if styles.export_tag(&ps.name, false).is_some_and(|t| t.split) {
el.set("SplitDocument", "true");
}
pg.insert(&ps.name, with_props(el, props));
}
let mut rp = El::new("RootParagraphStyleGroup").attr("Self", self.fresh());
@@ -1528,6 +1549,7 @@ impl<'a> Ex<'a> {
}
n!(keep_with_next, "KeepWithNext");
b!(keep_lines_together, "KeepLinesTogether");
b!(ignore_optical_margin, "IgnoreEdgeAlignment");
b!(keep_all_lines, "KeepAllLinesTogether");
n!(keep_first, "KeepFirstLines");
n!(keep_last, "KeepLastLines");
@@ -1949,6 +1971,15 @@ impl<'a> Ex<'a> {
El::new("BaselineFrameGridOption")
.attr("UseCustomBaselineFrameGrid", "true")
.attr("StartingOffsetForBaselineFrameGrid", num(g.start))
.attr(
"BaselineFrameGridRelativeOption",
match g.relative_to {
designcraft_doc::FrameGridRelative::TopOfPage => "TopOfPageOfBaselineFrameGridRelativeOption",
designcraft_doc::FrameGridRelative::TopMargin => "TopOfMarginOfBaselineFrameGridRelativeOption",
designcraft_doc::FrameGridRelative::TopOfFrame => "TopOfFrameOfBaselineFrameGridRelativeOption",
designcraft_doc::FrameGridRelative::TopInset => "TopOfInsetOfBaselineFrameGridRelativeOption",
},
)
.attr("BaselineFrameGridIncrement", num(g.increment)),
);
}
@@ -2061,6 +2092,53 @@ impl<'a> Ex<'a> {
if let Some(c) = &g.clip {
el.push(clipping_settings(c));
}
if let Some(options) = &g.layer_options {
let mut option = El::new("GraphicLayerOption");
for (key, value) in &options.attributes {
if graphic_layer_attribute_name(key) {
option.set(key, value);
}
}
// Only an ancestor whose subtree is still open can parent the next layer.
// Reopening a closed subtree would reorder native-edited lists on roundtrip.
let mut open = Vec::new();
let mut parents = Vec::with_capacity(options.layers.len());
for i in 0..options.layers.len() {
let parent = options.parents.get(i).copied().flatten();
if let Some(at) = parent.and_then(|p| open.iter().position(|id| *id == p)).filter(|at| *at < 239) {
open.truncate(at + 1);
parents.push(parent);
} else {
open.clear();
parents.push(None);
}
open.push(i);
}
let mut children: Vec<Vec<El>> = vec![Vec::new(); options.layers.len()];
let mut roots = Vec::new();
for (i, attributes) in options.layers.iter().enumerate().rev() {
let mut layer = El::new("GraphicLayer").attr("Self", self.fresh());
for (key, value) in attributes {
if graphic_layer_attribute_name(key) {
layer.set(key, value);
}
}
if let Some(nested) = children.get_mut(i) {
for child in std::mem::take(nested).into_iter().rev() {
layer.push(child);
}
}
if let Some(nested) = parents.get(i).copied().flatten().and_then(|parent| children.get_mut(parent)) {
nested.push(layer);
} else {
roots.push(layer);
}
}
for layer in roots.into_iter().rev() {
option.push(layer);
}
el.push(option);
}
let uri = match &asset.link {
Some(l) => path_to_uri(l),
None => format!("file:{}", asset.name),
@@ -2217,8 +2295,11 @@ impl<'a> Ex<'a> {
.attr("StoryTitle", "$ID/");
el.push(
El::new("StoryPreference")
.attr("OpticalMarginAlignment", "false")
.attr("OpticalMarginSize", "12")
.attr("OpticalMarginAlignment", bool_s(s.optical_margin.on))
.attr(
"OpticalMarginSize",
num(designcraft_doc::OpticalMargin::clamp_size(s.optical_margin.size).unwrap_or(designcraft_doc::OpticalMargin::DEFAULT_SIZE)),
)
.attr("FrameType", "TextFrameType")
.attr("StoryOrientation", if s.vertical { "Vertical" } else { "Horizontal" })
.attr(
+71
View File
@@ -682,6 +682,9 @@ impl<'r> Importer<'r> {
if let Some(e) = top.iter().find(|e| e.local() == "FootnoteOption") {
self.footnote_option(e);
}
if let Some(e) = top.iter().find(|e| e.local() == "EPubExportPreference") {
self.epub_split(e);
}
for e in top.iter().filter(|e| e.local() == "Layer") {
let id = LayerId(self.alloc());
let color = match e.prop_el("LayerColor") {
@@ -1187,6 +1190,14 @@ impl<'r> Importer<'r> {
Some(slot) => *slot = s,
None => self.styles.paragraph.push(s),
}
// Split Document (EPUB only): on the style, and on its EPUB export tag map. Files write
// `false` on every style, so either place saying `true` turns it on.
let split = e.get("SplitDocument") == Some("true")
|| e.elements()
.any(|m| m.local() == "StyleExportTagMap" && m.get("ExportType") == Some("EPUB") && m.get("SplitDocument") == Some("true"));
if split {
self.styles.export_tags.entry(format!("p:{name}")).or_default().split = true;
}
}
for (_, e) in &chars {
let name = names::style_name_in(CHAR_BUILTINS, e.get("Name").unwrap_or(""));
@@ -1461,6 +1472,21 @@ impl<'r> Importer<'r> {
}
}
/// Preferences › `EPubExportPreference`: Split Document (after styles: it can name one). An
/// empty style name splits on the styles' export tags; a style the file lacks splits nothing.
fn epub_split(&mut self, e: &El) {
if e.boolean("BreakDocument") != Some(true) {
return;
}
let name = e.get("ParagraphStyleName").unwrap_or("$ID/NormalParagraphStyle");
self.settings.epub_split = if name.trim().is_empty() {
designcraft_doc::EpubSplit::ExportTags
} else {
let style = self.para_style_ref(name);
if self.styles.para(&style).is_some() { designcraft_doc::EpubSplit::Style(style) } else { designcraft_doc::EpubSplit::Off }
};
}
/// Preferences › `FootnoteOption` (after styles: it names the footnote text and marker styles).
fn footnote_option(&mut self, e: &El) {
let mut o = designcraft_doc::FootnoteOptions::default();
@@ -1820,6 +1846,7 @@ impl<'r> Importer<'r> {
a.single_word_justify = e.prop("SingleWordJustification").and_then(|v| names::align_in(v.trim()));
a.keep_with_next = u("KeepWithNext");
a.keep_lines_together = e.boolean("KeepLinesTogether");
a.ignore_optical_margin = e.boolean("IgnoreEdgeAlignment");
a.keep_all_lines = e.boolean("KeepAllLinesTogether");
a.keep_first = u("KeepFirstLines");
a.keep_last = u("KeepLastLines");
@@ -2095,6 +2122,14 @@ impl<'r> Importer<'r> {
let format = self.xref_format_names.get(&fmt).cloned().unwrap_or_else(|| "Full Paragraph & Page Number".into());
xref_list.push(Arc::new(designcraft_doc::CrossRef { target: 0, format }));
}
let pref = e.find("StoryPreference");
let optical_margin = designcraft_doc::OpticalMargin {
on: pref.and_then(|p| p.boolean("OpticalMarginAlignment")).unwrap_or(false),
size: pref
.and_then(|p| p.num("OpticalMarginSize"))
.and_then(designcraft_doc::OpticalMargin::clamp_size)
.unwrap_or(designcraft_doc::OpticalMargin::DEFAULT_SIZE),
};
let mut st = Story {
id,
text,
@@ -2107,6 +2142,7 @@ impl<'r> Importer<'r> {
designcraft_doc::TextDirection::LeftToRight
},
vertical: e.find("StoryPreference").and_then(|p| p.get("StoryOrientation")) == Some("Vertical"),
optical_margin,
rev: 0,
tables,
notes,
@@ -2977,6 +3013,7 @@ impl<'r> Importer<'r> {
options.baseline_grid = Some(designcraft_doc::FrameBaselineGrid {
start: g.num("StartingOffsetForBaselineFrameGrid").unwrap_or(0.0),
increment: g.num("BaselineFrameGridIncrement").unwrap_or(12.0),
relative_to: g.get("BaselineFrameGridRelativeOption").map(frame_grid_relative).unwrap_or_default(),
});
}
it.content = Content::Text(TextFrame { story, options });
@@ -3045,6 +3082,26 @@ impl<'r> Importer<'r> {
}
fn graphic(&mut self, g: &El) -> Content {
let layer_options = g.find("GraphicLayerOption").map(|options| {
let attrs = |e: &El| e.attrs.iter().filter(|(key, _)| key != "Self").cloned().collect();
let mut out = designcraft_doc::GraphicLayerOptions { attributes: attrs(options), ..Default::default() };
let mut pending: Vec<_> = options.find_all("GraphicLayer").map(|e| (e, None)).collect();
pending.reverse();
while let Some((layer, parent)) = pending.pop() {
let index = out.layers.len();
out.layers.push(attrs(layer));
out.parents.push(parent);
let children: Vec<_> = layer.find_all("GraphicLayer").collect();
pending.extend(children.into_iter().rev().map(|e| (e, Some(index))));
}
if out.parents.iter().all(Option::is_none) {
out.parents.clear();
}
out
});
if layer_options.as_ref().is_some_and(designcraft_doc::GraphicLayerOptions::has_visibility_overrides) {
self.warnings.push(format!("graphic {}: {}", g.get("Self").unwrap_or("(unnamed)"), designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING));
}
let gxf = parse_affine(g.get("ItemTransform"));
let (l, t, r, b) = match g.prop_el("GraphicBounds") {
Some(gb) => {
@@ -3061,6 +3118,7 @@ impl<'r> Importer<'r> {
let uri = link.and_then(|k| k.get("LinkResourceURI")).map(uri_to_path);
let graphic = |asset, size| {
Content::Graphic(designcraft_doc::Graphic {
layer_options: layer_options.clone(),
asset,
size,
xf: gxf * Affine::translate((l, t)),
@@ -3518,6 +3576,19 @@ fn fit_columns(mut columns: Columns, margins: &Margins, page_width: f64) -> Colu
columns
}
/// A `BaselineFrameGridRelativeOption` value (`TopOfInsetOfBaselineFrameGridRelativeOption`, or a
/// short form such as `TopOfInset`); unknown values measure from the top of the page.
fn frame_grid_relative(v: &str) -> designcraft_doc::FrameGridRelative {
use designcraft_doc::FrameGridRelative as R;
let v = v.strip_suffix("OfBaselineFrameGridRelativeOption").unwrap_or(v);
match v {
"TopOfMargin" | "TopMargin" => R::TopMargin,
"TopOfFrame" => R::TopOfFrame,
"TopOfInset" | "TopInset" => R::TopInset,
_ => R::TopOfPage,
}
}
/// Text Frame Options from `TextFramePreference` elements: each attribute from the first that sets it.
fn text_frame_options(prefs: &[&El]) -> TextFrameOptions {
let e = Prefs(prefs);
+107
View File
@@ -300,6 +300,7 @@ fn indesign_text_defaults() -> Vec<AttrRow> {
"HyphenateLadderLimit": "3" => 3, "0" => 0, |r| r.0.hyph_limit;
"HyphenationZone": "36" => 36.0, "18" => 18.0, |r| r.0.hyph_zone;
"HyphenateCapitalizedWords": "true" => true, "false" => false, |r| r.0.hyph_capitalized;
"IgnoreEdgeAlignment": "false" => false, "true" => true, |r| r.0.ignore_optical_margin;
"HyphenateLastWord": "true" => true, "false" => false, |r| r.0.hyph_last_word;
"HyphenateAcrossColumns": "true" => true, "false" => false, |r| r.0.hyph_across_column;
"HyphenWeight": "5" => 0.5, "9" => 0.9, |r| r.0.hyph_weight;
@@ -484,6 +485,57 @@ fn text_color_rules_round_trip() {
assert_eq!(p.para.kashidas, Some(false));
}
fn optical_story(pref: &str) -> String {
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0">
<Story Self="s1">
<StoryPreference {pref} StoryOrientation="Horizontal" StoryDirection="LeftToRightDirection"/>
<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/Body">
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>“Hung” here.</Content><Br/></CharacterStyleRange>
</ParagraphStyleRange>
<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/Body" IgnoreEdgeAlignment="true">
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>“Flush” here.</Content></CharacterStyleRange>
</ParagraphStyleRange>
</Story>
</idPkg:Story>"#
)
}
/// Story ▸ Optical Margin Alignment and its size, and Ignore Optical Margin on paragraphs and
/// paragraph styles, survive import and export.
#[test]
fn story_optical_margin_round_trips() {
let story = |d: &Document| d.stories.values().find(|s| s.text.starts_with("“Hung”")).cloned().unwrap();
let d = import_idml_with(&fixture_with_story(&optical_story(r#"OpticalMarginAlignment="true" OpticalMarginSize="18""#)), &|_| None).unwrap();
let s = story(&d);
assert_eq!(s.optical_margin, designcraft_doc::OpticalMargin { on: true, size: 18.0 });
assert_eq!((s.paras[0].para.ignore_optical_margin, s.paras[1].para.ignore_optical_margin), (None, Some(true)));
let bytes = export_idml(&d);
let text = zip_text(&bytes, "Stories/");
assert!(text.contains(r#"OpticalMarginAlignment="true" OpticalMarginSize="18""#), "{text}");
assert!(text.contains(r#"IgnoreEdgeAlignment="true""#), "{text}");
let back = story(&import_idml(&bytes).unwrap());
assert_eq!(back.optical_margin, s.optical_margin);
assert_eq!(back.paras[1].para.ignore_optical_margin, Some(true));
// Sizes outside InDesign's range are clamped; malformed ones and an absent preference are the default.
for (pref, want) in [
(r#"OpticalMarginAlignment="true" OpticalMarginSize="1e9""#, (true, 1296.0)),
(r#"OpticalMarginAlignment="true" OpticalMarginSize="0""#, (true, 0.1)),
(r#"OpticalMarginAlignment="true" OpticalMarginSize="NaN""#, (true, 12.0)),
(r#"OpticalMarginAlignment="false""#, (false, 12.0)),
] {
let d = import_idml_with(&fixture_with_story(&optical_story(pref)), &|_| None).unwrap();
let om = story(&d).optical_margin;
assert_eq!((om.on, om.size), want, "{pref}");
}
// On a paragraph style.
let d = import_idml_with(&fixture_with_styles(&keep_styles(r#"IgnoreEdgeAlignment="true""#)), &|_| None).unwrap();
assert!(d.styles.resolve_para_style("Body").0.ignore_optical_margin);
let styles = zip_text(&export_idml(&d), "Resources/Styles.xml");
assert!(styles.contains(r#"IgnoreEdgeAlignment="true""#), "{styles}");
}
/// `[Basic Paragraph]` is InDesign's `$ID/NormalParagraphStyle`, both ways.
#[test]
fn basic_paragraph_is_normal_paragraph_style() {
@@ -4455,6 +4507,7 @@ fn exports_graphic_in_table_cell() {
});
let mut t = designcraft_doc::Table::new(d.alloc(), 2, 2, 0, 0, 200.0);
t.cell_mut(1, 0).unwrap().graphic = Some(designcraft_doc::Graphic {
layer_options: None,
asset: id,
size: (24.0, 24.0),
xf: designcraft_geom::Affine::IDENTITY,
@@ -4579,3 +4632,57 @@ fn style_names_with_a_literal_colon_survive_a_round_trip() {
assert_eq!(names::style_name_in(names::PARA_BUILTINS, &names::style_name_out(names::PARA_BUILTINS, n)), n);
}
}
/// Split Document (EPUB only) comes from the style, or from its EPUB export tag map, and is
/// written back on the style.
#[test]
fn split_document_round_trips() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<idPkg:Styles xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0">
<RootParagraphStyleGroup Self="rp">
<ParagraphStyle Self="ParagraphStyle/$ID/[No paragraph style]" Name="$ID/[No paragraph style]" PointSize="12" SplitDocument="false"/>
<ParagraphStyle Self="ParagraphStyle/Part" Name="Part" SplitDocument="true"/>
<ParagraphStyle Self="ParagraphStyle/Chapter" Name="Chapter" SplitDocument="false">
<StyleExportTagMap Self="uaStyleExportTagMapnEPUB" ExportType="EPUB" ExportTag="h1" ExportClass="" ExportAttributes="" SplitDocument="true" EmitCss="true"/>
</ParagraphStyle>
<ParagraphStyle Self="ParagraphStyle/Body" Name="Body" SplitDocument="false"/>
</RootParagraphStyleGroup>
</idPkg:Styles>"#;
let d = import_idml_with(&fixture_with_styles(xml), &|_| None).unwrap();
let split = |d: &designcraft_doc::Document, name: &str| d.styles.export_tag(name, false).is_some_and(|t| t.split);
assert!(split(&d, "Part") && split(&d, "Chapter") && !split(&d, "Body"));
let back = import_idml(&export_idml(&d)).unwrap();
assert!(split(&back, "Part") && split(&back, "Chapter") && !split(&back, "Body"));
}
/// EPUB Export Options › Split Document: off, one style (built in or not) or the styles' export
/// tags, read from the file's `EPubExportPreference` and written back.
#[test]
fn epub_split_document_round_trips() {
use designcraft_doc::EpubSplit;
let styles =
STYLES.replace("</RootParagraphStyleGroup>", r#"<ParagraphStyle Self="ParagraphStyle/Chapter" Name="Chapter"/></RootParagraphStyleGroup>"#);
assert!(styles.contains("ParagraphStyle/Chapter"), "the fixture has a paragraph style group");
for (attrs, want) in [
(r#"BreakDocument="false" ParagraphStyleName="ParagraphStyle/Chapter""#, EpubSplit::Off),
(r#"BreakDocument="true" ParagraphStyleName="ParagraphStyle/Chapter""#, EpubSplit::Style("Chapter".into())),
(r#"BreakDocument="true" ParagraphStyleName="ParagraphStyle/Text%3aBody""#, EpubSplit::Style("Text/Body".into())),
(r#"BreakDocument="true" ParagraphStyleName="$ID/NormalParagraphStyle""#, EpubSplit::Style(designcraft_doc::BASIC_PARAGRAPH.into())),
(r#"BreakDocument="true" ParagraphStyleName="""#, EpubSplit::ExportTags),
(r#"BreakDocument="true" ParagraphStyleName="ParagraphStyle/Missing""#, EpubSplit::Off),
] {
let prefs = PREFS.replace("</idPkg:Preferences>", &format!("<EPubExportPreference {attrs}/></idPkg:Preferences>"));
let file = zip_files(&[
("designmap.xml", DESIGNMAP),
("Resources/Graphic.xml", GRAPHIC),
("Resources/Styles.xml", &styles),
("Resources/Preferences.xml", &prefs),
("MasterSpreads/MasterSpread_m1.xml", MASTER),
("Spreads/Spread_sp1.xml", SPREAD),
("Stories/Story_s1.xml", STORY),
]);
let d = import_idml_with(&file, &|_| None).unwrap();
assert_eq!(d.settings.epub_split, want, "{attrs}");
assert_eq!(import_idml(&export_idml(&d)).unwrap().settings.epub_split, want, "{attrs}");
}
}
+123
View File
@@ -70,6 +70,129 @@ fn package(frames: &[String]) -> Vec<u8> {
])
}
#[test]
fn nested_graphic_layers_preserve_hierarchy_on_roundtrip() {
let option = r#"<GraphicLayerOption UpdateLinkOption="KeepOverrides">
<GraphicLayer Self="a" Id="1" Name="Group"><GraphicLayer Self="b" Id="2" Name="Nested" OriginalVisibility="true" CurrentVisibility="false"/></GraphicLayer>
<GraphicLayer Self="c" Id="3" Name="Sibling"/>
</GraphicLayerOption>"#;
let image = image(1, PHOTO, None).replace("</Image>", &format!("{option}</Image>"));
let d = import_idml(&package(&[frame(1, 0.0, 0.0, &image)])).unwrap();
let options = |d: &Document| d.spreads[0].items[0].graphic().unwrap().layer_options.clone().unwrap();
let expected = options(&d);
assert_eq!(expected.layers.len(), 3);
assert_eq!(expected.parents, [None, Some(0), None]);
assert!(expected.has_visibility_overrides());
let reopened = import_idml(&export_idml(&d)).unwrap();
assert_eq!(options(&reopened), expected);
}
#[test]
fn graphic_layer_options_survive_for_each_placement_of_a_shared_asset() {
let option = |n: usize, visible: &str| {
format!(
r#"<GraphicLayerOption UpdateLinkOption="KeepOverrides"><GraphicLayer Self="gl{n}" Id="7" Name="Synthetic layer" OriginalVisibility="true" CurrentVisibility="{visible}" Locked="true" HasPrintState="true" PrintState="false"/></GraphicLayerOption>"#
)
};
let frames = ["false", "true"]
.into_iter()
.enumerate()
.map(|(i, visible)| {
let n = i + 1;
frame(n, i as f64 * 100.0, 0.0, &image(n, PHOTO, None).replace("</Image>", &format!("{}</Image>", option(n, visible))))
})
.collect::<Vec<_>>();
let reads = Cell::new(0);
let loaded = import_idml_report_with_previews(
&package(&frames),
&|_| {
reads.set(reads.get() + 1);
Some(vec![42; 16])
},
&Default::default(),
)
.unwrap();
assert_eq!(reads.get(), 1);
assert_eq!(loaded.document.assets.len(), 1, "layer choices belong to placements, not shared image bytes");
let out = export_idml(&loaded.document);
let spreads = zip_text(&out, "Spreads/");
assert_eq!(spreads.matches("<GraphicLayer ").count(), 2, "both placements must retain their independent layer choices");
assert!(spreads.contains("CurrentVisibility=\"false\""));
assert!(spreads.contains("CurrentVisibility=\"true\""));
assert_eq!(spreads.matches("UpdateLinkOption=\"KeepOverrides\"").count(), 2);
assert_eq!(spreads.matches("PrintState=\"false\"").count(), 2);
assert!(loaded.warnings.iter().any(|w| w.contains("layer visibility")));
let reopened = import_idml(&out).unwrap();
let options = |d: &Document| {
d.spreads[0]
.items
.iter()
.filter_map(|i| match &i.content {
Content::Graphic(g) => g.layer_options.clone(),
_ => None,
})
.collect::<Vec<_>>()
};
assert_eq!(options(&loaded.document), options(&reopened));
let twice = zip_text(&export_idml(&reopened), "Spreads/");
assert_eq!(twice.matches("<GraphicLayer ").count(), 2);
}
#[test]
fn malformed_native_graphic_layer_parents_cannot_cycle_or_overflow_xml_depth() {
let mut d = import_idml(&package(&[frame(1, 0.0, 0.0, &image(1, PHOTO, None))])).unwrap();
let Content::Graphic(g) = &mut std::sync::Arc::make_mut(&mut std::sync::Arc::make_mut(&mut d.spreads[0]).items[0]).content else {
panic!("graphic")
};
let mut options = designcraft_doc::GraphicLayerOptions::default();
for i in 0..300usize {
options.layers.push(std::collections::BTreeMap::from([("Name".into(), format!("Layer {i}"))]));
options.parents.push(Some(i.saturating_sub(1)));
}
options.parents[1] = Some(usize::MAX);
g.layer_options = Some(options);
let reopened = import_idml(&export_idml(&d)).unwrap();
assert_eq!(reopened.spreads[0].items[0].graphic().unwrap().layer_options.as_ref().unwrap().layers.len(), 300);
}
#[test]
fn closed_graphic_layer_subtrees_cannot_reorder_native_layers() {
let mut d = import_idml(&package(&[frame(1, 0.0, 0.0, &image(1, PHOTO, None))])).unwrap();
let Content::Graphic(g) = &mut std::sync::Arc::make_mut(&mut std::sync::Arc::make_mut(&mut d.spreads[0]).items[0]).content else {
panic!("graphic")
};
g.layer_options = Some(designcraft_doc::GraphicLayerOptions {
layers: ["A", "B", "C", "D"].into_iter().map(|name| std::collections::BTreeMap::from([("Name".into(), name.into())])).collect(),
parents: vec![None, None, Some(0), Some(2)],
..Default::default()
});
let reopened = import_idml(&export_idml(&d)).unwrap();
let options = reopened.spreads[0].items[0].graphic().unwrap().layer_options.as_ref().unwrap();
assert_eq!(options.layers.iter().map(|a| a["Name"].as_str()).collect::<Vec<_>>(), ["A", "B", "C", "D"]);
assert_eq!(options.parents, [None, None, None, Some(2)]);
}
#[test]
fn graphic_layer_attributes_from_native_data_cannot_inject_xml_or_self_ids() {
let mut d = import_idml(&package(&[frame(1, 0.0, 0.0, &image(1, PHOTO, None))])).unwrap();
let Content::Graphic(g) = &mut std::sync::Arc::make_mut(&mut std::sync::Arc::make_mut(&mut d.spreads[0]).items[0]).content else {
panic!("graphic")
};
let attributes = std::collections::BTreeMap::from([
("Name".into(), "A < B & C".into()),
("Self".into(), "injected-id".into()),
("xmlns".into(), "urn:injected".into()),
("bad\" injected=\"true".into(), "invalid".into()),
]);
g.layer_options = Some(designcraft_doc::GraphicLayerOptions { attributes: attributes.clone(), layers: vec![attributes], ..Default::default() });
let out = export_idml(&d);
let xml = zip_text(&out, "Spreads/");
assert!(!xml.contains("injected-id") && !xml.contains("injected=") && !xml.contains("urn:injected"));
let back = import_idml(&out).unwrap();
let Content::Graphic(g) = &back.spreads[0].items[0].content else { panic!("graphic") };
assert_eq!(g.layer_options.as_ref().unwrap().layers[0]["Name"], "A < B & C");
}
/// (asset, crop, transform) of each graphic, left to right.
fn placements(d: &Document) -> Vec<(AssetId, [f64; 4], designcraft_geom::Affine)> {
let mut out: Vec<(f64, AssetId, [f64; 4], designcraft_geom::Affine)> = d.spreads[0]
+29 -2
View File
@@ -6,6 +6,12 @@ const NS: &str = r#"xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/pack
/// 14 pt increment; its four paragraphs align to the grid. The frame's top is the page's top, so
/// the grid lines are 6 + n·14 pt down from the frame top whatever the grid is measured from.
fn frame_grid_doc() -> Document {
frame_grid_doc_relative(None)
}
/// [`frame_grid_doc`] with `BaselineFrameGridRelativeOption` set to `relative` when given.
fn frame_grid_doc_relative(relative: Option<&str>) -> Document {
let relative = relative.map(|r| format!(r#" BaselineFrameGridRelativeOption="{r}""#)).unwrap_or_default();
let designmap = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Document {NS} Self="d" StoryList="s1" Name="grid.indd"><idPkg:Graphic src="Resources/Graphic.xml"/><idPkg:Styles src="Resources/Styles.xml"/><idPkg:Preferences src="Resources/Preferences.xml"/><Layer Self="L1" Name="Layer 1" Visible="true" Locked="false"/><idPkg:Spread src="Spreads/Spread_sp1.xml"/><idPkg:Story src="Stories/Story_s1.xml"/></Document>"#
@@ -17,7 +23,7 @@ fn frame_grid_doc() -> Document {
.collect();
let spread = format!(
r#"<idPkg:Spread {NS}><Spread Self="sp1" PageCount="1" ItemTransform="1 0 0 1 0 0"><Page Self="p1" GeometricBounds="0 0 600 400" ItemTransform="1 0 0 1 0 -300"/>
<TextFrame Self="t1" ParentStory="s1" PreviousTextFrame="n" NextTextFrame="n" ItemLayer="L1" ItemTransform="1 0 0 1 0 -300"><Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>{pts}</PathPointArray></GeometryPathType></PathGeometry></Properties><TextFramePreference InsetSpacing="0"/><BaselineFrameGridOption UseCustomBaselineFrameGrid="true" StartingOffsetForBaselineFrameGrid="6" BaselineFrameGridIncrement="14"/></TextFrame>
<TextFrame Self="t1" ParentStory="s1" PreviousTextFrame="n" NextTextFrame="n" ItemLayer="L1" ItemTransform="1 0 0 1 0 -300"><Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>{pts}</PathPointArray></GeometryPathType></PathGeometry></Properties><TextFramePreference InsetSpacing="0"/><BaselineFrameGridOption UseCustomBaselineFrameGrid="true" StartingOffsetForBaselineFrameGrid="6" BaselineFrameGridIncrement="14"{relative}/></TextFrame>
</Spread></idPkg:Spread>"#
);
let story = format!(
@@ -46,7 +52,7 @@ fn baselines(d: &Document) -> Vec<f64> {
fn custom_frame_grid_sets_lines_from_its_start_by_its_increment() {
let d = frame_grid_doc();
let grid = d.spreads[0].items[0].text_frame().unwrap().options.baseline_grid;
assert_eq!(grid, Some(designcraft_doc::FrameBaselineGrid { start: 6.0, increment: 14.0 }));
assert_eq!(grid, Some(designcraft_doc::FrameBaselineGrid::new(6.0, 14.0)));
let b = baselines(&d);
assert_eq!(b.len(), 4, "{b:?}");
for y in &b {
@@ -69,3 +75,24 @@ fn custom_frame_grid_round_trips() {
assert_eq!(grid(&back), grid(&d));
assert_eq!(baselines(&back), baselines(&d));
}
#[test]
fn custom_frame_grid_relative_to_round_trips() {
use designcraft_doc::FrameGridRelative as R;
let relative = |d: &Document| d.spreads[0].items[0].text_frame().unwrap().options.baseline_grid.map(|g| g.relative_to);
for (attr, want) in [
("TopOfPageOfBaselineFrameGridRelativeOption", R::TopOfPage),
("TopOfMarginOfBaselineFrameGridRelativeOption", R::TopMargin),
("TopOfFrameOfBaselineFrameGridRelativeOption", R::TopOfFrame),
("TopOfInsetOfBaselineFrameGridRelativeOption", R::TopInset),
("TopOfInset", R::TopInset),
("SomethingNew", R::TopOfPage),
] {
let d = frame_grid_doc_relative(Some(attr));
assert_eq!(relative(&d), Some(want), "{attr}");
let back = import_idml(&export_idml(&d)).unwrap();
assert_eq!(relative(&back), Some(want), "{attr} after export");
}
// A frame without the attribute measures from the top of the page.
assert_eq!(relative(&frame_grid_doc()), Some(R::TopOfPage));
}
+2 -2
View File
@@ -51,7 +51,7 @@ fn confined(tag: &str, read: bool, write: bool) -> (Server, PathBuf) {
std::fs::write(dir.join("tags.xml"), format!("<root><p>{text}</p></root>")).unwrap();
}
let (in_dir, out_dir) = (base.join("in"), base.join("out"));
let roots = AutomationRoots::new(read.then_some(in_dir.as_path()), write.then_some(out_dir.as_path())).unwrap();
let roots = AutomationRoots::new(read.then_some(in_dir.as_path()).as_slice(), write.then_some(out_dir.as_path()).as_slice()).unwrap();
let mut server = Server::new(Box::new(Headless::with_document().with_automation_roots(roots)));
ok(&mut server, "new_document", json!({}));
(server, base)
@@ -339,7 +339,7 @@ fn without_roots_nothing_is_confined() {
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
designcraft_file(&base.join("a.designcraft"), 3, "Anywhere");
let mut server = Server::new(Box::new(Headless::with_document().with_automation_roots(AutomationRoots::new(None, None).unwrap())));
let mut server = Server::new(Box::new(Headless::with_document().with_automation_roots(AutomationRoots::new(&[], &[]).unwrap())));
ok(&mut server, "open_document", json!({"path": s(&base.join("a.designcraft"))}));
assert_eq!(pages(&mut server), 3);
ok(&mut server, "save_document", json!({"path": s(&base.join("b.designcraft"))}));
+139 -16
View File
@@ -1,5 +1,9 @@
//! Document → PDF. The item walk mirrors `designcraft-render`'s `draw_spread` / `draw_item`.
#[cfg(test)]
#[path = "registration_tests.rs"]
pub(crate) mod registration_tests;
use std::collections::HashMap;
use std::sync::Arc;
@@ -188,6 +192,11 @@ fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions
#[cfg(test)]
image_conversions: 0,
pdfs: HashMap::new(),
variants: HashMap::new(),
has_optional_content: false,
#[cfg(test)]
pdf_preparations: 0,
layer_error: None,
version,
too_new: Vec::new(),
svgs: HashMap::new(),
@@ -246,6 +255,9 @@ fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions
pdf.set_tag_tree(ex.tag_tree());
}
ex.check_placed_versions()?;
if let Some(error) = ex.layer_error {
return Err(error);
}
let mut bytes = writer.finish().map_err(PdfError::from)?;
// Transparency blends in the document's blend space: CMYK for print (and always in PDF/X).
let mut warnings = ex.warnings;
@@ -254,6 +266,9 @@ fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions
// group that got past it, with its page.
crate::pdfx::cmyk_group_spaces(&mut bytes);
}
if ex.has_optional_content {
bytes = crate::optional_content::register(&bytes)?;
}
add_form_fields(&mut bytes, &mut warnings, doc, &sheets, opts);
if opts.standard == Standard::PdfX4 {
// Output intent and PDF/X identification, then check the result.
@@ -280,8 +295,11 @@ fn merge_pdfs_inner(parts: &[(Vec<u8>, Vec<(f32, f32)>)], title: Option<&str>) -
let mut writer = Writer::new(krilla::SerializeSettings::default(), metadata);
let pdf = writer.document_mut();
let mut pages = 0;
let mut has_optional_content = false;
for (bytes, sizes) in parts {
let src = krilla::pdf::Pdf::new(Arc::new(bytes.clone())).map_err(|e| PdfError::Write(format!("{e:?}")))?;
let variant = crate::optional_content::prepare(&Arc::new(bytes.clone()), None)?;
has_optional_content |= !sizes.is_empty() && variant.has_optional_content;
let src = krilla::pdf::Pdf::new(variant.data).map_err(|e| PdfError::Write(format!("{e:?}")))?;
let doc = krilla::pdf::PdfDocument::new(Arc::new(src));
for (i, &(w, h)) in sizes.iter().enumerate() {
let size = Size::from_wh(w.max(1.0), h.max(1.0)).ok_or(PdfError::NoPages)?;
@@ -298,7 +316,8 @@ fn merge_pdfs_inner(parts: &[(Vec<u8>, Vec<(f32, f32)>)], title: Option<&str>) -
if pages == 0 {
return Err(PdfError::NoPages);
}
writer.finish().map_err(PdfError::from)
let bytes = writer.finish().map_err(PdfError::from)?;
if has_optional_content { crate::optional_content::register(&bytes) } else { Ok(bytes) }
}
/// File › Print Booklet: how pages pair up on printer spreads.
@@ -359,6 +378,11 @@ fn export_booklet_inner(doc: &Document, cache: &Cache, opts: &BookletOptions) ->
#[cfg(test)]
image_conversions: 0,
pdfs: HashMap::new(),
variants: HashMap::new(),
has_optional_content: false,
#[cfg(test)]
pdf_preparations: 0,
layer_error: None,
// krilla's default.
version: PdfVersion::Pdf17,
too_new: Vec::new(),
@@ -408,10 +432,16 @@ fn export_booklet_inner(doc: &Document, cache: &Cache, opts: &BookletOptions) ->
page.finish();
}
ex.check_placed_versions()?;
if let Some(error) = ex.layer_error {
return Err(error);
}
let mut bytes = writer.finish().map_err(PdfError::from)?;
if doc.settings.blend_space == designcraft_doc::BlendSpace::Cmyk {
crate::pdfx::cmyk_group_spaces(&mut bytes);
}
if ex.has_optional_content {
bytes = crate::optional_content::register(&bytes)?;
}
let mut warnings = ex.warnings;
warnings.dedup();
Ok(ExportReport { bytes, pages: pairs.len(), warnings })
@@ -490,7 +520,12 @@ pub(crate) struct Exporter<'a> {
#[cfg(test)]
image_conversions: usize,
/// Placed PDFs (embedded as vector pages) and their page counts.
pdfs: HashMap<AssetId, Option<(krilla::pdf::PdfDocument, usize)>>,
pdfs: HashMap<(AssetId, Vec<hayro_syntax::object::ObjectIdentifier>), Option<(krilla::pdf::PdfDocument, usize)>>,
variants: HashMap<(AssetId, Option<Vec<String>>), crate::optional_content::Variant>,
has_optional_content: bool,
#[cfg(test)]
pdf_preparations: usize,
layer_error: Option<PdfError>,
/// The PDF version written; placed PDFs must not be newer.
version: PdfVersion,
/// Placed PDFs too new to embed in this version ("name is PDF 2.0"): the export fails.
@@ -973,7 +1008,10 @@ impl Exporter<'_> {
let g = &if it.media.is_some() { it.drawn_graphic(&self.doc.assets).unwrap_or_else(|| g.clone()) } else { g.clone() };
if let Some(p) = &path {
s.push_clip_path(p, &FillRule::NonZero);
self.graphic(s, g);
// An explicit all-on choice is `Some(&[])`, not the file's defaults: passing
// `None` would embed the source's default-hidden layers and skip the rewrite
// error that a stale choice must raise.
self.graphic(s, g, it.has_pdf_layer_override().then_some(it.pdf_hidden_layers.as_slice()));
s.pop();
}
}
@@ -1266,9 +1304,9 @@ impl Exporter<'_> {
/// `qpdf --force-version=1.6` does). A newer
/// file is left out and the export fails naming it ([`Exporter::check_placed_versions`]).
/// Returns the document with its page count.
fn placed_pdf(&mut self, asset: &designcraft_doc::Asset) -> Option<(krilla::pdf::PdfDocument, usize)> {
fn placed_pdf(&mut self, asset: &designcraft_doc::Asset, data: Arc<Vec<u8>>) -> Option<(krilla::pdf::PdfDocument, usize)> {
use hayro_syntax::PdfVersion as V;
let Ok(pdf) = krilla::pdf::Pdf::new(asset.data.clone()) else {
let Ok(pdf) = krilla::pdf::Pdf::new(data.clone()) else {
self.warn(format!("{}: can't read the placed PDF", asset.name));
return None;
};
@@ -1286,7 +1324,7 @@ impl Exporter<'_> {
}
if have == V::Pdf17
&& max == V::Pdf16
&& let bytes = relabel_pdf_header(&asset.data, b"%PDF-1.7", b"%PDF-1.6").unwrap_or_else(|| asset.data.to_vec())
&& let bytes = relabel_pdf_header(&data, b"%PDF-1.7", b"%PDF-1.6").unwrap_or_else(|| data.to_vec())
&& let Ok(pdf) = krilla::pdf::Pdf::new(Arc::new(relabel_catalog_version(bytes)))
&& pdf.version() <= max
{
@@ -1379,25 +1417,30 @@ impl Exporter<'_> {
img
}
/// Draw a placed graphic, inside its clipping path when it has one.
pub(crate) fn graphic(&mut self, s: &mut Surface, g: &designcraft_doc::Graphic) {
/// Draw a placed graphic, inside its clipping path when it has one. `hidden` is the
/// placed PDF's layer choice: `None` keeps the file's defaults, `Some(&[])` shows every layer.
/// Retained IDML graphic-layer options are not applied when drawing, so they warn here.
pub(crate) fn graphic(&mut self, s: &mut Surface, g: &designcraft_doc::Graphic, hidden: Option<&[String]>) {
if g.layer_options.as_ref().is_some_and(designcraft_doc::GraphicLayerOptions::has_visibility_overrides) {
self.warn(designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING);
}
match g.clip_region().map(|(region, even_odd)| (to_path(&region), even_odd)) {
Some((Some(clip), even_odd)) => {
s.push_clip_path(&clip, if even_odd { &FillRule::EvenOdd } else { &FillRule::NonZero });
self.graphic_unclipped(s, g);
self.graphic_unclipped(s, g, hidden);
s.pop();
}
// A degenerate outline shows nothing, or everything when inverted.
Some((None, even_odd)) => {
if even_odd {
self.graphic_unclipped(s, g);
self.graphic_unclipped(s, g, hidden);
}
}
None => self.graphic_unclipped(s, g),
None => self.graphic_unclipped(s, g, hidden),
}
}
fn graphic_unclipped(&mut self, s: &mut Surface, g: &designcraft_doc::Graphic) {
fn graphic_unclipped(&mut self, s: &mut Surface, g: &designcraft_doc::Graphic, hidden: Option<&[String]>) {
// Video / sound without a poster: a dark frame with a play mark.
if let Some(asset) = self.doc.assets.get(&g.asset)
&& designcraft_doc::media_kind(&asset.mime).is_some()
@@ -1431,11 +1474,39 @@ impl Exporter<'_> {
let [l, t, r, b] = shown.unwrap_or([0.0, 0.0, 1.0, 1.0]);
let (page_w, page_h) = (g.size.0 / (r - l), g.size.1 / (b - t));
let Some(size) = Size::from_wh(page_w.max(1e-3) as f32, page_h.max(1e-3) as f32) else { return };
let placed = match self.pdfs.get(&g.asset) {
let request = (g.asset, hidden.map(<[String]>::to_vec));
let variant = match self.variants.get(&request) {
Some(variant) => variant.clone(),
None => {
#[cfg(test)]
{
self.pdf_preparations += 1;
}
match crate::optional_content::prepare(&asset.data, hidden) {
Ok(variant) => {
self.variants.insert(request, variant.clone());
variant
}
Err(error) => {
let message = match error {
PdfError::Write(message) => message,
other => other.to_string(),
};
self.layer_error.get_or_insert(PdfError::Write(format!("{}: {message}", asset.name)));
return;
}
}
}
};
if let Some(warning) = variant.warning {
self.warn(format!("{}: {warning}", asset.name));
}
let key = (g.asset, variant.hidden);
let placed = match self.pdfs.get(&key) {
Some(p) => p.clone(),
None => {
let p = self.placed_pdf(asset);
self.pdfs.insert(g.asset, p.clone());
let p = self.placed_pdf(asset, variant.data);
self.pdfs.insert(key, p.clone());
p
}
};
@@ -1445,6 +1516,7 @@ impl Exporter<'_> {
self.warn(format!("{}: the placed PDF has no page {}", asset.name, u64::from(asset.page) + 1));
}
Some((placed, _)) => {
self.has_optional_content |= variant.has_optional_content;
s.push_transform(&tf(g.xf));
let clip = shown.and_then(|_| to_path(&Rect::new(0.0, 0.0, g.size.0, g.size.1).to_path(0.1)));
if let Some(clip) = &clip {
@@ -1892,6 +1964,10 @@ mod image_content_cache_tests {
images_by_content: ImageContentCache::default(),
image_conversions: 0,
pdfs: HashMap::new(),
variants: HashMap::new(),
has_optional_content: false,
pdf_preparations: 0,
layer_error: None,
version: PdfVersion::Pdf17,
too_new: Vec::new(),
svgs: HashMap::new(),
@@ -1906,6 +1982,52 @@ mod image_content_cache_tests {
}
}
#[test]
fn prepared_pdf_variants_reuse_parsing_by_asset_and_hidden_names() {
let mut doc = Document::new(&Default::default());
let cache = Cache::new();
let opts = PdfOptions::default();
// One layer, "Hidden", shown by default: hiding it needs a prepared copy, showing it doesn't.
let content = "/OC /a BDC 0 0 1 rg 0 0 100 100 re f EMC";
let source = registration_tests::assemble(&[
"<</Type/Catalog/Pages 2 0 R/OCProperties<</OCGs[5 0 R]>>>>".into(),
"<</Type/Pages/Kids[3 0 R]/Count 1>>".into(),
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Resources<</Properties<</a 5 0 R>>>>/Contents 4 0 R>>".into(),
format!("<</Length {}>>\nstream\n{content}\nendstream", content.len()),
"<</Type/OCG/Name(Hidden)>>".into(),
]);
let a = asset(&mut doc, source.clone(), "a.pdf");
let b = asset(&mut doc, source, "b.pdf");
let mut ex = exporter(&doc, &cache, &opts);
let mut writer = Writer::new(Default::default(), Default::default());
let mut page = writer.document_mut().start_page_with(PageSettings::new(Size::from_wh(100.0, 100.0).unwrap()));
let mut surface = page.surface();
for (id, hidden, count) in [(a, None, 1), (a, None, 1), (a, Some(vec!["Hidden".into()]), 2), (b, None, 3), (a, None, 3)] {
let graphic = designcraft_doc::Graphic {
asset: id,
size: (100.0, 100.0),
xf: Affine::IDENTITY,
clip: None,
auto_fit: Default::default(),
fit_align: 4,
crop: [0.0; 4],
wrap: Default::default(),
fill: Default::default(),
layer_options: None,
};
ex.graphic_unclipped(&mut surface, &graphic, hidden.as_deref());
assert!(ex.layer_error.is_none());
assert_eq!(ex.pdf_preparations, count, "count actual preparations, not writer resource deduplication");
}
assert_eq!(ex.variants.len(), 3);
// a, b, and a with its layer hidden (a copy of its own).
assert_eq!(ex.pdfs.len(), 3);
let variant = ex.variants.get(&(a, None)).unwrap();
assert!(Arc::ptr_eq(&variant.data, &doc.assets[&a].data));
surface.finish();
page.finish();
assert!(writer.finish().is_ok());
}
#[test]
fn exact_bytes_reuse_conversion_under_real_hash_collisions() {
use std::hash::{BuildHasher, Hasher};
let mut d = Document::new(&Default::default());
@@ -2175,6 +2297,7 @@ mod eps_tests {
designcraft_geom::shapes::rectangle(Rect::new(72.0, 72.0, 272.0, 172.0)),
);
item.content = Content::Graphic(designcraft_doc::Graphic {
layer_options: None,
asset: aid,
size: (2.0, 1.0),
xf: Affine::translate((72.0, 72.0)) * Affine::scale(100.0),
+1
View File
@@ -35,6 +35,7 @@ mod marks;
mod text;
mod forms;
mod optional_content;
mod pdfx;
mod transitions;
pub use export::{BookletKind, BookletOptions, booklet_pairs, export_booklet, export_pdf, export_pdf_with_report, merge_pdfs, sheet_spreads};
+461
View File
@@ -0,0 +1,461 @@
use hayro_syntax::Pdf;
use hayro_syntax::object::{Array, Dict, MaybeRef, Name, Object, ObjectIdentifier, Stream};
use hayro_syntax::reader::{Reader, ReaderExt};
use std::sync::Arc;
use crate::{PdfError, Result};
const MARKER: &[u8] = b"DesignCraftHiddenOCG";
fn invalid() -> PdfError {
PdfError::Write("the placed PDF's optional-content state could not be preserved safely".into())
}
/// A layer hidden in Object Layer Options that the placed file doesn't have (relinked, say):
/// exporting would be a guess at what was meant to stay hidden.
fn missing_layer() -> PdfError {
PdfError::Write("a layer hidden in Object Layer Options is not in the placed PDF; relink it or reset its layer options".into())
}
fn references(dict: &Dict<'_>, key: &[u8]) -> Result<Vec<ObjectIdentifier>> {
if !dict.contains_key(key) {
return Ok(Vec::new());
}
dict.get::<Array<'_>>(key).ok_or_else(invalid)?.raw_iter().map(|value| value.as_obj_ref().map(Into::into).ok_or_else(invalid)).collect()
}
fn edited(dict: &Dict<'_>, replacements: &[(&[u8], &[u8])]) -> Result<Vec<u8>> {
let data = dict.data();
let body = data.strip_prefix(b"<<").and_then(|d| d.strip_suffix(b">>")).ok_or_else(invalid)?;
let mut reader = Reader::new(body);
let mut out = b"<<".to_vec();
loop {
reader.skip_white_spaces_and_comments();
if reader.at_end() {
break;
}
let start = reader.offset();
let name = reader.read_without_context::<Name<'_>>().ok_or_else(invalid)?;
reader.skip_white_spaces_and_comments();
reader.skip::<MaybeRef<Object<'_>>>(false).ok_or_else(invalid)?;
if !replacements.iter().any(|(key, _)| *key == name.as_ref()) {
out.push(b'\n');
out.extend_from_slice(body.get(start..reader.offset()).ok_or_else(invalid)?);
}
}
for (key, value) in replacements {
if !value.is_empty() {
out.extend_from_slice(b"\n/");
out.extend_from_slice(key);
out.push(b' ');
out.extend_from_slice(value);
}
}
out.extend_from_slice(b"\n>>");
Ok(out)
}
/// The trailer of the cross-reference section at `offset`: a table's `trailer` dictionary or an
/// xref stream's own. A table is read entry by entry rather than searched for the keyword, so a
/// `trailer` in a comment can't stand in for the real one; reading is bounded either way.
fn trailer_at(bytes: &[u8], offset: usize) -> Option<Dict<'_>> {
const MAX_SECTION_BYTES: usize = 4 * 1024 * 1024;
let data = bytes.get(offset..)?;
let mut reader = Reader::new(data.get(..data.len().min(MAX_SECTION_BYTES))?);
reader.skip_white_spaces_and_comments();
if reader.forward_tag(b"xref").is_none() {
reader.read_without_context::<ObjectIdentifier>()?;
reader.skip_white_spaces_and_comments();
let stream = reader.read_without_context::<Stream<'_>>()?;
let is_xref = stream.dict().get::<Name<'_>>(b"Type").as_ref().map(AsRef::as_ref) == Some(b"XRef");
return is_xref.then(|| stream.dict().clone());
}
// Every pass consumes input or fails, and the input is bounded, so this ends.
loop {
reader.skip_white_spaces();
if reader.forward_tag(b"trailer").is_some() {
break;
}
reader.read_without_context::<u32>()?;
reader.skip_white_spaces();
let count = reader.read_without_context::<usize>()?;
if count > MAX_SECTION_BYTES / 18 {
return None;
}
for _ in 0..count {
reader.skip_white_spaces();
reader.read_without_context::<u32>()?;
reader.skip_white_spaces();
if reader.read_without_context::<u32>()? > 65535 {
return None;
}
reader.skip_white_spaces();
reader.forward_tag(b"n").or_else(|| reader.forward_tag(b"f"))?;
}
}
reader.skip_white_spaces_and_comments();
reader.read_without_context::<Dict<'_>>()
}
/// Whether `dict` has `key`: a literal `null` value is the same as no entry. A reference counts,
/// since a trailer has no context to resolve it in.
fn has_entry(dict: &Dict<'_>, key: &[u8]) -> bool {
dict.contains_key(key) && !matches!(dict.get_raw::<Object<'_>>(key), Some(MaybeRef::NotRef(Object::Null(_))))
}
fn append(bytes: &[u8], changes: &[(ObjectIdentifier, Vec<u8>)], root: ObjectIdentifier) -> Result<Vec<u8>> {
let damaged = || PdfError::Write("the placed PDF's cross-reference table is damaged; layer changes could not be preserved safely".into());
let at = crate::transitions::rfind(bytes, b"startxref").ok_or_else(damaged)?;
let tail = std::str::from_utf8(bytes.get(at..).ok_or_else(damaged)?).map_err(|_| damaged())?;
let prev = crate::transitions::int_after(tail, "startxref").ok_or_else(damaged)?;
let trailer = trailer_at(bytes, prev).ok_or_else(damaged)?;
// Never write decrypted objects into an encrypted file. An earlier update's trailer can
// carry the encryption, so follow the (bounded, acyclic) Prev chain to its end.
let mut offset = Some(prev);
let mut visited = std::collections::HashSet::new();
for _ in 0..32 {
let Some(at) = offset else { break };
if !visited.insert(at) {
return Err(damaged());
}
let section = trailer_at(bytes, at).ok_or_else(damaged)?;
if has_entry(&section, b"Encrypt") {
return Err(invalid());
}
offset = section.get::<usize>(b"Prev");
if offset.is_none() && has_entry(&section, b"Prev") {
return Err(damaged());
}
}
if offset.is_some() {
return Err(damaged());
}
trailer.get::<i32>(b"Size").filter(|n| *n > 0).ok_or_else(damaged)?;
let mut out = bytes.to_vec();
out.push(b'\n');
let mut offsets = Vec::new();
for (id, body) in changes {
offsets.push((*id, out.len()));
out.extend_from_slice(format!("{} {} obj\n", id.obj_number, id.gen_number).as_bytes());
out.extend_from_slice(body);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(b"xref\n");
offsets.sort_by_key(|(id, _)| *id);
for (id, offset) in offsets {
out.extend_from_slice(format!("{} 1\n{offset:010} {:05} n\r\n", id.obj_number, id.gen_number).as_bytes());
}
let root = format!("{} {} R", root.obj_number, root.gen_number);
let prev = prev.to_string();
let trailer = edited(
&trailer,
&[
(b"Root", root.as_bytes()),
(b"Prev", prev.as_bytes()),
(b"XRefStm", b""),
(b"Type", b""),
(b"W", b""),
(b"Index", b""),
(b"Length", b""),
(b"Filter", b""),
(b"DecodeParms", b""),
],
)?;
out.extend_from_slice(b"trailer\n");
out.extend_from_slice(&trailer);
out.extend_from_slice(format!("\nstartxref\n{xref}\n%%EOF\n").as_bytes());
Ok(out)
}
#[derive(Clone)]
pub(crate) struct Variant {
pub data: Arc<Vec<u8>>,
pub hidden: Vec<ObjectIdentifier>,
pub warning: Option<String>,
pub has_optional_content: bool,
}
/// The placed PDF with its layer choice applied: `None` keeps the file's own default
/// visibility, `Some(names)` shows every layer except `names` (so `Some(&[])` is all on).
pub(crate) fn prepare(bytes: &Arc<Vec<u8>>, choice: Option<&[String]>) -> Result<Variant> {
let hidden = choice.unwrap_or_default();
let pdf = Pdf::new(bytes.clone()).map_err(|_| invalid())?;
let xref = pdf.xref();
let root = xref.get::<Dict<'_>>(xref.root_id()).ok_or_else(invalid)?;
// A null value, literal or referenced, is the same as no entry.
if matches!(root.get::<Object<'_>>(b"OCProperties"), None | Some(Object::Null(_))) {
let has_optional_content = pdf
.objects()
.into_iter()
.any(|object| matches!(object, Object::Dict(dict) if dict.get::<Name<'_>>(b"Type").as_ref().map(AsRef::as_ref) == Some(b"OCG")));
if !hidden.is_empty() {
return Err(missing_layer());
}
return Ok(Variant { data: bytes.clone(), hidden: Vec::new(), warning: None, has_optional_content });
}
let properties = root.get::<Dict<'_>>(b"OCProperties").ok_or_else(invalid)?;
let mut groups = references(&properties, b"OCGs")?;
let config = properties.get::<Dict<'_>>(b"D");
let mut fallback = config.is_none();
let config = config.unwrap_or_default();
let base_off = match config.get::<Name<'_>>(b"BaseState").as_ref().map(AsRef::as_ref) {
None if !config.contains_key(b"BaseState") => false,
Some(b"ON") => false,
Some(b"OFF") => true,
None | Some(_) => {
fallback = true;
false
}
};
let off = references(&config, b"OFF")?;
let on = match references(&config, b"ON") {
Ok(on) => on,
Err(error) if !fallback && !base_off && off.is_empty() => {
let value = config.get::<Object<'_>>(b"ON").ok_or(error)?;
fallback = true;
value.into_array().map(|array| array.raw_iter().filter_map(|value| value.as_obj_ref().map(Into::into)).collect()).unwrap_or_default()
}
Err(error) => return Err(error),
};
if on.iter().any(|id| off.contains(id)) {
return Err(invalid());
}
let incomplete = on.iter().chain(&off).any(|id| !groups.contains(id));
groups.extend(on.iter().chain(&off).copied());
groups.sort();
groups.dedup();
// Aggregating applications use the default state, not /AS usage-event adjustments.
let mut changes = Vec::new();
let mut effective = Vec::new();
let mut renamed = false;
let mut found = vec![false; hidden.len()];
for id in groups {
let group = xref.get::<Dict<'_>>(id).ok_or_else(invalid)?;
if group.get::<Name<'_>>(b"Type").as_ref().map(AsRef::as_ref) != Some(b"OCG") {
return Err(invalid());
}
let name = group.get::<hayro_syntax::object::String<'_>>(b"Name");
let is_hidden = match (choice, name.as_ref()) {
(None, _) => !fallback && (off.contains(&id) || (base_off && !on.contains(&id))),
(Some([]), None) => false,
(Some(_), Some(name)) => {
let name = String::from_utf8_lossy(name.as_bytes());
let mut is_hidden = false;
for (n, found) in hidden.iter().zip(&mut found) {
if n == name.as_ref() {
(*found, is_hidden) = (true, true);
}
}
is_hidden
}
(Some(_), None) => return Err(invalid()),
};
if is_hidden {
effective.push(id);
}
// Unmarked groups are visible when registered in the output catalog. An explicit choice
// writes every group's state, so the catalog rewrite is attempted (and a file whose
// history cannot take one fails closed) even when the choice happens to change nothing.
if !is_hidden && name.is_some() && choice.is_none() {
continue;
}
let marker = is_hidden.to_string();
let body = match name {
Some(_) => edited(&group, &[(MARKER, marker.as_bytes())])?,
None => {
if group.contains_key(b"Name") && group.get::<Object<'_>>(b"Name").is_none() {
return Err(invalid());
}
renamed = true;
let label = format!("(Placed PDF layer {} {})", id.obj_number, id.gen_number);
edited(&group, &[(MARKER, marker.as_bytes()), (b"Name", label.as_bytes())])?
}
};
changes.push((id, body));
}
if found.contains(&false) {
return Err(missing_layer());
}
effective.sort();
effective.dedup();
let data = if changes.is_empty() { bytes.clone() } else { Arc::new(append(bytes, &changes, xref.root_id())?) };
let mut warnings = Vec::new();
if fallback {
warnings.push("layer state not readable, all layers shown unless hidden by Object Layer Options");
}
if incomplete {
warnings.push("layer list incomplete; layers named in the default configuration were added");
}
if renamed {
warnings.push("layer display name not readable; generated a name, visibility preserved");
}
let warning = if warnings.is_empty() { None } else { Some(warnings.join("; ")) };
Ok(Variant { data, hidden: effective, warning, has_optional_content: true })
}
pub(crate) fn register(bytes: &[u8]) -> Result<Vec<u8>> {
#[cfg(test)]
crate::export::registration_tests::REGISTRATIONS.with(|count| count.set(count.get() + 1));
let pdf = Pdf::new(bytes.to_vec()).map_err(|_| invalid())?;
let mut groups = Vec::new();
let mut changes = Vec::new();
for object in pdf.objects() {
let Object::Dict(dict) = object else { continue };
if dict.get::<Name<'_>>(b"Type").as_ref().map(AsRef::as_ref) != Some(b"OCG") {
continue;
}
// Resource-only OCGs without a catalog configuration are visible by default.
let hidden = if dict.contains_key(MARKER) { dict.get::<bool>(MARKER).ok_or_else(invalid)? } else { false };
let id = dict.obj_id().ok_or_else(invalid)?;
groups.push((id, hidden));
changes.push((id, edited(&dict, &[(MARKER, b"")])?));
}
if groups.is_empty() {
return Ok(bytes.to_vec());
}
groups.sort_by_key(|(id, _)| *id);
let refs = |hidden_only: bool| {
groups
.iter()
.filter(|(_, hidden)| !hidden_only || *hidden)
.map(|(id, _)| format!("{} {} R", id.obj_number, id.gen_number))
.collect::<Vec<_>>()
.join(" ")
};
let all = refs(false);
let properties = format!("<</OCGs[{all}]/D<</Name(Placed PDF layers)/BaseState/ON/OFF[{}]/Order[{all}]>>>>", refs(true));
let root = pdf.xref().root_id();
let catalog = pdf.xref().get::<Dict<'_>>(root).ok_or_else(invalid)?;
changes.push((root, edited(&catalog, &[(b"OCProperties", properties.as_bytes())])?));
append(bytes, &changes, root)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn visible_named_groups_keep_original_buffer_with_repaired_xref() {
let bytes = Arc::new(
b"%PDF-1.5\n1 0 obj\n<</Type/Catalog/Pages 2 0 R/OCProperties<</OCGs[4 0 R]/D<</BaseState/ON>>>>>>\nendobj\n\
2 0 obj\n<</Type/Pages/Kids[3 0 R]/Count 1>>\nendobj\n\
3 0 obj\n<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Resources<<>>>>\nendobj\n\
4 0 obj\n<</Type/OCG/Name(Visible)>>\nendobj\n\
trailer\n<</Size 5/Root 1 0 R>>\nstartxref\n1\n%%EOF\n"
.to_vec(),
);
assert!(Pdf::new(bytes.clone()).is_ok());
let variant = prepare(&bytes, None).unwrap();
assert!(Arc::ptr_eq(&bytes, &variant.data), "visible groups must not copy the complete PDF or require incremental writes");
assert!(variant.hidden.is_empty());
assert!(variant.warning.is_none());
}
/// One page with layer "Off", hidden by the file's default configuration. `trailer` is the
/// trailer dictionary; `previous` an earlier revision's trailer, chained by /Prev.
fn default_hidden(trailer: &str, previous: Option<&str>) -> Arc<Vec<u8>> {
let mut bytes = b"%PDF-1.5\n".to_vec();
let mut offsets = Vec::new();
for body in [
"<</Type/Catalog/Pages 2 0 R/OCProperties<</OCGs[4 0 R]/D<</OFF[4 0 R]>>>>>>",
"<</Type/Pages/Kids[3 0 R]/Count 1>>",
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Resources<<>>>>",
"<</Type/OCG/Name(Off)>>",
] {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", offsets.len()).as_bytes());
}
let table = |bytes: &mut Vec<u8>, trailer: &str| {
let at = bytes.len();
bytes.extend_from_slice(b"xref\n0 5\n0000000000 65535 f\r\n");
for offset in &offsets {
bytes.extend_from_slice(format!("{offset:010} 00000 n\r\n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n{trailer}\n").as_bytes());
at
};
let trailer = match previous {
Some(previous) => {
let at = table(&mut bytes, previous);
trailer.replace(">>", &format!("/Prev {at}>>"))
}
None => trailer.to_owned(),
};
let at = table(&mut bytes, &trailer);
bytes.extend_from_slice(format!("startxref\n{at}\n%%EOF\n").as_bytes());
Arc::new(bytes)
}
#[test]
fn an_explicit_choice_overrides_a_layer_the_file_hides() {
let bytes = default_hidden("<</Size 5/Root 1 0 R>>", None);
let defaults = prepare(&bytes, None).unwrap();
let all_on = prepare(&bytes, Some(&[])).unwrap();
let group = ObjectIdentifier::new(4, 0);
assert_eq!(defaults.hidden, vec![group], "file defaults keep the layer hidden");
assert!(all_on.hidden.is_empty(), "an explicit all-on choice shows it");
let registered = register(&all_on.data).unwrap();
let pdf = Pdf::new(registered).unwrap();
let catalog = pdf.xref().get::<Dict<'_>>(pdf.xref().root_id()).unwrap();
let config = catalog.get::<Dict<'_>>(b"OCProperties").unwrap().get::<Dict<'_>>(b"D").unwrap();
assert_eq!(references(&config, b"OFF").unwrap(), Vec::new());
}
/// The incremental writer alone, so a reader's own handling of the input can't decide the result.
fn update(bytes: &[u8]) -> Result<Vec<u8>> {
append(bytes, &[(ObjectIdentifier::new(4, 0), b"<</Type/OCG/Name(Off)>>".to_vec())], ObjectIdentifier::new(1, 0))
}
#[test]
fn a_null_encrypt_entry_is_no_encryption() {
for trailer in ["<</Size 5/Root 1 0 R/Encrypt null>>", "<</Size 5/Root 1 0 R>>"] {
assert!(update(&default_hidden(trailer, None)).is_ok(), "{trailer}");
}
for trailer in ["<</Size 5/Root 1 0 R/Encrypt 9 0 R>>", "<</Size 5/Root 1 0 R/Encrypt<</Filter/Standard>>>>"] {
assert!(update(&default_hidden(trailer, None)).is_err(), "{trailer}");
}
// End to end, a null entry still lets a layer choice be written.
assert!(prepare(&default_hidden("<</Size 5/Root 1 0 R/Encrypt null>>", None), None).is_ok());
}
#[test]
fn encryption_in_an_earlier_revision_is_found() {
let bytes = default_hidden("<</Size 5/Root 1 0 R>>", Some("<</Size 5/Root 1 0 R/Encrypt 9 0 R>>"));
assert!(update(&bytes).is_err());
let bytes = default_hidden("<</Size 5/Root 1 0 R>>", Some("<</Size 5/Root 1 0 R/Encrypt null>>"));
assert!(update(&bytes).is_ok());
}
#[test]
fn a_trailer_in_a_comment_does_not_hide_the_real_one() {
let bytes = b"xref\n% trailer <</Size 9/Encrypt null>>\n0 1\n0000000000 65535 f\r\ntrailer\n<</Size 9/Encrypt 12 0 R>>";
assert!(trailer_at(bytes, 0).is_none(), "a comment inside the table is not table syntax");
let bytes = b"xref\n0 1\n0000000000 65535 f\r\ntrailer\n<</Size 9/Encrypt 12 0 R>>";
assert!(has_entry(&trailer_at(bytes, 0).unwrap(), b"Encrypt"));
}
#[test]
fn an_unreadable_prev_is_damage_and_a_null_one_ends_the_chain() {
for value in ["(12)", "/Twelve", "-1", "[12]", "<<>>", "true", "9 0 R"] {
let bytes = default_hidden(&format!("<</Size 5/Root 1 0 R/Prev {value}>>"), None);
let error = update(&bytes).unwrap_err().to_string();
assert!(error.contains("cross-reference"), "{value}: {error}");
}
assert!(update(&default_hidden("<</Size 5/Root 1 0 R/Prev null>>", None)).is_ok());
}
#[test]
fn a_cyclic_prev_chain_is_damage() {
let mut bytes = default_hidden("<</Size 5/Root 1 0 R>>", None).to_vec();
let at = crate::transitions::rfind(&bytes, b"xref\n").unwrap();
let patched = String::from_utf8(bytes.clone()).unwrap().replacen("<</Size 5/Root 1 0 R>>", &format!("<</Size 5/Root 1 0 R/Prev {at}>>"), 1);
bytes = patched.into_bytes();
let error = update(&bytes).unwrap_err().to_string();
assert!(error.contains("cross-reference"), "{error}");
}
}
+1
View File
@@ -496,6 +496,7 @@ mod tests {
let r = Rect::new(at.0, at.1, at.0 + 100.0, at.1 + 50.0);
let mut it = Item::new(id, lid, Shape::Rectangle, designcraft_geom::shapes::rectangle(r));
it.content = Content::Graphic(Graphic {
layer_options: None,
asset,
size,
xf: Affine::translate(at) * Affine::scale(100.0 / size.0),
+224
View File
@@ -0,0 +1,224 @@
use super::*;
fn placed_document(source: Vec<u8>) -> Document {
use designcraft_doc::{Asset, Graphic, ItemId, Shape};
let mut doc = Document::new(&Default::default());
let asset = AssetId(doc.alloc());
doc.add_asset(Asset { id: asset, name: "placed.pdf".into(), mime: "application/pdf".into(), data: Arc::new(source), ..Default::default() });
let mut item =
Item::new(ItemId(doc.alloc()), doc.default_layer(), Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)));
item.content = Content::Graphic(Graphic {
asset,
size: (100.0, 100.0),
xf: Affine::IDENTITY,
clip: None,
auto_fit: Default::default(),
fit_align: 4,
crop: [0.0; 4],
wrap: Default::default(),
fill: Default::default(),
layer_options: None,
});
doc.insert_item(SpreadRef::Doc(0), item, None).unwrap();
doc
}
fn layered_source(catalog: bool) -> Vec<u8> {
let properties = if catalog { "/OCProperties<</OCGs[5 0 R]/D<</OFF[5 0 R]>>>>" } else { "" };
let content = b"/OC /a BDC 0.125 0.625 0.875 rg 0 0 100 100 re f EMC";
let objects = [
format!("<</Type/Catalog/Pages 2 0 R{properties}>>"),
"<</Type/Pages/Kids[3 0 R]/Count 1>>".into(),
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Resources<</Properties<</a 5 0 R>>>>/Contents 4 0 R>>".into(),
format!("<</Length {}>>\nstream\n{}\nendstream", content.len(), String::from_utf8_lossy(content)),
"<</Type/OCG/Name(Hidden)>>".into(),
];
assemble(&objects)
}
fn unlayered_source() -> Vec<u8> {
let content = "0.125 0.625 0.875 rg 0 0 100 100 re f";
assemble(&[
"<</Type/Catalog/Pages 2 0 R>>".into(),
"<</Type/Pages/Kids[3 0 R]/Count 1>>".into(),
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Resources<<>>/Contents 4 0 R>>".into(),
format!("<</Length {}>>\nstream\n{content}\nendstream", content.len()),
])
}
pub(super) fn assemble(objects: &[String]) -> Vec<u8> {
let mut bytes = b"%PDF-1.5\n".to_vec();
let mut offsets = Vec::new();
for (index, body) in objects.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", index + 1).as_bytes());
}
let xref = bytes.len();
let size = objects.len() + 1;
bytes.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f\r\n").as_bytes());
for offset in offsets {
bytes.extend_from_slice(format!("{offset:010} 00000 n\r\n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<</Size {size}/Root 1 0 R>>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
bytes
}
thread_local! {
pub(crate) static REGISTRATIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
fn registrations(action: impl FnOnce()) -> usize {
REGISTRATIONS.set(0);
action();
REGISTRATIONS.get()
}
fn check_vector_artwork(pdf: &hayro_syntax::Pdf) {
use hayro_syntax::object::{Name, Object};
let mut found = false;
for object in pdf.objects() {
let Object::Stream(stream) = object else { continue };
let subtype = stream.dict().get::<Name<'_>>(b"Subtype");
assert_ne!(subtype.as_ref().map(AsRef::as_ref), Some(b"Image".as_slice()), "placed artwork must remain vector");
if subtype.as_ref().map(AsRef::as_ref) == Some(b"Form".as_slice()) {
let content = stream.decoded().unwrap();
let content = String::from_utf8_lossy(&content);
found |= content.contains("0.125 0.625 0.875 rg") && content.contains("0 0 100 100 re f");
}
}
assert!(found, "placed rectangle and its distinctive fill colour must survive in a vector Form XObject");
}
fn check_unlayered_pdf(bytes: &[u8], pages: usize, artwork: bool) {
use hayro_syntax::object::Dict;
let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).unwrap();
assert_eq!(pdf.pages().len(), pages);
let catalog = pdf.xref().get::<Dict<'_>>(pdf.xref().root_id()).unwrap();
assert!(!catalog.contains_key(b"OCProperties"), "unlayered output must not acquire optional content");
if artwork {
check_vector_artwork(&pdf);
}
}
fn check_unlayered(doc: &Document, artwork: bool) {
let cache = Cache::new();
let mut bytes = Vec::new();
assert_eq!(
registrations(|| {
bytes = export_pdf(doc, &cache, &PdfOptions::default()).unwrap();
check_unlayered_pdf(&bytes, 1, artwork);
}),
0
);
assert_eq!(
registrations(|| {
let report = export_booklet(doc, &cache, &BookletOptions::default()).unwrap();
check_unlayered_pdf(&report.bytes, 2, artwork);
}),
0
);
assert_eq!(
registrations(|| {
let merged = merge_pdfs(&[(bytes, vec![(100.0, 100.0)])], None).unwrap();
check_unlayered_pdf(&merged, 1, artwork);
}),
0
);
}
#[test]
fn no_placed_pdfs_skip_optional_content_registration() {
check_unlayered(&Document::new(&Default::default()), false);
}
#[test]
fn unlayered_placed_pdf_skips_optional_content_registration() {
check_unlayered(&placed_document(unlayered_source()), true);
}
#[test]
fn null_optional_content_properties_are_no_optional_content() {
let content = "0.125 0.625 0.875 rg 0 0 100 100 re f";
for (properties, extra) in [("/OCProperties null", None), ("/OCProperties 5 0 R", Some("null"))] {
let mut objects = vec![
format!("<</Type/Catalog/Pages 2 0 R{properties}>>"),
"<</Type/Pages/Kids[3 0 R]/Count 1>>".into(),
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Resources<<>>/Contents 4 0 R>>".into(),
format!("<</Length {}>>\nstream\n{content}\nendstream", content.len()),
];
objects.extend(extra.map(String::from));
let doc = placed_document(assemble(&objects));
for standard in [Standard::None, Standard::PdfX4] {
let bytes = export_pdf(&doc, &Cache::new(), &PdfOptions { standard, ..Default::default() }).unwrap();
check_unlayered_pdf(&bytes, 1, true);
}
}
}
#[test]
fn layered_and_resource_only_inputs_register_optional_content() {
for hidden in [true, false] {
let source = layered_source(hidden);
let doc = placed_document(source.clone());
let cache = Cache::new();
let mut exported = Vec::new();
assert_eq!(
registrations(|| {
exported = export_pdf(&doc, &cache, &PdfOptions::default()).unwrap();
}),
1
);
check_registered(&exported, hidden);
assert_eq!(
registrations(|| {
let report = export_booklet(&doc, &cache, &BookletOptions::default()).unwrap();
check_registered(&report.bytes, hidden);
}),
1
);
for input in [source, exported] {
assert_eq!(
registrations(|| {
let merged = merge_pdfs(&[(input, vec![(100.0, 100.0)])], None).unwrap();
check_registered(&merged, hidden);
}),
1
);
}
}
}
fn check_registered(bytes: &[u8], hidden: bool) {
use hayro_syntax::object::{Array, Dict, Name, Object, ObjectIdentifier};
let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).unwrap();
check_vector_artwork(&pdf);
let xref = pdf.xref();
let catalog = xref.get::<Dict<'_>>(xref.root_id()).unwrap();
let properties = catalog.get::<Dict<'_>>(b"OCProperties").unwrap();
let groups = properties.get::<Array<'_>>(b"OCGs").unwrap();
assert_eq!(groups.raw_iter().count(), 1);
let group_id: ObjectIdentifier = groups.raw_iter().next().unwrap().as_obj_ref().unwrap().into();
let group = xref.get::<Dict<'_>>(group_id).unwrap();
assert_eq!(group.get::<Name<'_>>(b"Type").unwrap().as_ref(), b"OCG");
assert_eq!(group.get::<hayro_syntax::object::String<'_>>(b"Name").unwrap().as_bytes(), b"Hidden");
let config = properties.get::<Dict<'_>>(b"D").unwrap();
if hidden {
let off = config.get::<Array<'_>>(b"OFF").unwrap();
assert_eq!(off.raw_iter().count(), 1);
let off_id: ObjectIdentifier = off.raw_iter().next().unwrap().as_obj_ref().unwrap().into();
assert_eq!(off_id, group_id, "OFF must reference the Hidden OCG listed in OCGs");
} else if config.contains_key(b"OFF") {
assert_eq!(config.get::<Array<'_>>(b"OFF").unwrap().raw_iter().count(), 0);
}
// Superseded revisions may retain the marker; readers use only the objects resolved by the current xref.
for object in pdf.objects() {
let Object::Dict(dict) = object else { continue };
if dict.get::<Name<'_>>(b"Type").as_ref().map(AsRef::as_ref) == Some(b"OCG") {
assert!(!dict.contains_key(b"DesignCraftHiddenOCG"), "internal marker remains in resolved OCG {:?}", dict.obj_id());
}
}
}
+1
View File
@@ -257,6 +257,7 @@ fn place(d: &mut Document, data: Vec<u8>, px: (u32, u32)) {
let id = ItemId(d.alloc());
let mut it = Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(72.0, 72.0, 272.0, 172.0)));
it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
layer_options: None,
asset,
size: (px.0 as f64, px.1 as f64),
xf: designcraft_geom::Affine::translate((72.0, 72.0)) * designcraft_geom::Affine::scale(50.0),
+1 -1
View File
@@ -309,7 +309,7 @@ impl Exporter<'_> {
if let Some(clip) = to_path(&c.clip.to_path(0.1)) {
s.push_clip_path(&clip, &krilla::paint::FillRule::NonZero);
s.push_transform(&tf(Affine::translate((c.clip.x0, c.clip.y0))));
self.graphic(s, g);
self.graphic(s, g, None);
s.pop();
s.pop();
}
@@ -30,6 +30,7 @@ fn place_named(d: &mut Document, data: Vec<u8>, x: f64, name: &str, mime: &str,
let mut item = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(x, 36.0, x + 20.0, 56.0)));
item.stroke.weight = 0.0;
item.content = Content::Graphic(Graphic {
layer_options: None,
asset: aid,
size: (20.0, 20.0),
xf: Affine::translate((x, 36.0)),
+1
View File
@@ -212,6 +212,7 @@ fn pictures_are_cropped_to_their_frames() {
it.fill = Fill::none();
// 200 × 100 image at 1:1, its left quarter cut off by the frame.
it.content = Content::Graphic(Graphic {
layer_options: None,
asset,
size: (200.0, 100.0),
xf: Affine::translate((50.0, 100.0)),
+29 -16
View File
@@ -654,13 +654,10 @@ impl Renderer {
let g = &if it.media.is_some() { it.drawn_graphic(&f.doc.assets).unwrap_or_else(|| g.clone()) } else { g.clone() };
ctx.set_transform(f.view * xf);
ctx.push_clip_layer(bp);
if !it.pdf_hidden_layers.is_empty() {
HIDDEN_LAYERS.with(|h| *h.borrow_mut() = it.pdf_hidden_layers.clone());
}
let hidden = it.has_pdf_layer_override().then(|| it.pdf_hidden_layers.clone());
let previous = HIDDEN_LAYERS.with(|h| h.replace(hidden));
self.draw_graphic(ctx, f, g, xf);
if !it.pdf_hidden_layers.is_empty() {
HIDDEN_LAYERS.with(|h| h.borrow_mut().clear());
}
HIDDEN_LAYERS.with(|h| h.replace(previous));
ctx.pop_layer();
}
// Items pasted into the frame, clipped by it.
@@ -880,12 +877,14 @@ impl Renderer {
let on_screen = (f.view * xf * g.xf).determinant().abs().sqrt();
let on_screen = if f.opts.quality == DisplayQuality::Typical { on_screen.min(1.0) } else { on_screen };
let hidden = HIDDEN_LAYERS.with(|h| h.borrow().clone());
let data = if !hidden.is_empty() && is_pdf(&asset.data) {
// Stale persisted/relinked choices must not reveal hidden source content.
let Some(variant) = pdf_layers::layered(&asset.data, &hidden) else { return };
variant
} else {
asset.data.clone()
let data = match hidden.as_deref().filter(|_| is_pdf(&asset.data)) {
// An explicit choice (including all-on) that cannot be rewritten fails closed:
// stale persisted/relinked choices must not reveal hidden source content.
Some(hidden) => {
let Some(variant) = pdf_layers::layered(&asset.data, hidden) else { return };
variant
}
None => asset.data.clone(),
};
// A placed PDF cropped to a box: the whole page, placed so that the box spans graphic space.
let [l, t, r, b] = shown_box(asset).unwrap_or([0.0, 0.0, 1.0, 1.0]);
@@ -1025,7 +1024,7 @@ fn rect_overlaps(a: Rect, b: Rect) -> bool {
thread_local! {
/// Object Layer Options of the placed PDF being drawn.
static HIDDEN_LAYERS: std::cell::RefCell<Vec<String>> = const { std::cell::RefCell::new(Vec::new()) };
static HIDDEN_LAYERS: std::cell::RefCell<Option<Vec<String>>> = const { std::cell::RefCell::new(None) };
/// Appearance of Black for the render on this thread: 100% K as pure black.
static RICH_BLACK: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
/// Separations Preview plate for the render on this thread.
@@ -1296,11 +1295,23 @@ pub fn pdf_page_png(bytes: &[u8], page: usize, max_side: u32, hidden: &[String],
/// Rasterize a placed page for export, preserving available interpreter diagnostics.
/// This cannot detect backend approximations that do not emit an interpreter warning.
pub fn pdf_page_png_checked(bytes: &[u8], page: usize, max_side: u32, hidden: &[String], shown: Option<[f64; 4]>) -> Result<Vec<u8>, PdfRasterError> {
pdf_page_png_options_checked(bytes, page, max_side, (!hidden.is_empty()).then_some(hidden), shown)
}
/// Rasterize with an explicit visibility override: `Some(&[])` enables every layer;
/// `None` retains file defaults. The legacy entry point keeps its empty-list semantics.
pub fn pdf_page_png_options_checked(
bytes: &[u8],
page: usize,
max_side: u32,
hidden: Option<&[String]>,
shown: Option<[f64; 4]>,
) -> Result<Vec<u8>, PdfRasterError> {
check_pdf_input_size(bytes.len())?;
let data = if hidden.is_empty() {
std::borrow::Cow::Borrowed(bytes)
} else {
let data = if let Some(hidden) = hidden {
std::borrow::Cow::Owned(pdf_hide_layers(bytes, hidden).ok_or(PdfRasterError::LayerRewrite)?)
} else {
std::borrow::Cow::Borrowed(bytes)
};
let raster = render_pdf_raster(&data, page, max_side)?;
if raster.diagnostics.image_decode_failed {
@@ -1984,6 +1995,7 @@ mod tests {
let mut it =
Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)));
it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
layer_options: None,
asset: aid,
size: (100.0, 100.0),
xf: Affine::translate((100.0, 100.0)),
@@ -2038,6 +2050,7 @@ mod tests {
let mut it =
Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)));
it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
layer_options: None,
asset: aid,
size: (100.0, 100.0),
xf: Affine::translate((100.0, 100.0)),
+1
View File
@@ -51,6 +51,7 @@ fn shown(file: Vec<u8>, fill: Fill) -> Vec<[u8; 4]> {
it.fill = Fill::swatch(CYAN);
it.stroke = Stroke::default();
it.content = Content::Graphic(Graphic {
layer_options: None,
asset: aid,
size: (150.0, 50.0),
xf: Affine::translate((100.0, 100.0)),
+1
View File
@@ -78,6 +78,7 @@ fn placed(size: f64, pdf: Vec<u8>) -> Rendered {
let id = ItemId(d.alloc());
let mut item = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, size, size)));
item.content = Content::Graphic(Graphic {
layer_options: None,
asset,
size: (size, size),
xf: Affine::IDENTITY,
+27 -3
View File
@@ -186,8 +186,8 @@ fn run_attrs(rpr: Option<&El>) -> CharAttrs {
if let Some(s) = r.on("strike") {
a.strikethrough = Some(s);
}
// Half-points; sizes outside the engine's 0.1–1296 pt range (or non-finite) are dropped.
if let Some(pt) = r.val("sz").and_then(|v| v.parse::<f64>().ok()).map(|sz| sz / 2.0).filter(|pt| (0.1..=1296.0).contains(pt)) {
// Half-points; a size the engine cannot mean is dropped (#1593).
if let Some(pt) = r.val("sz").and_then(|v| v.parse::<f64>().ok()).map(|sz| sz / 2.0).and_then(designcraft_doc::attrs::imported_type_size) {
a.size = Some(pt);
}
if let Some(f) = r.child("rFonts").and_then(|f| f.attr("ascii").or(f.attr("hAnsi"))) {
@@ -210,7 +210,8 @@ fn run_attrs(rpr: Option<&El>) -> CharAttrs {
fn para_attrs(ppr: Option<&El>) -> ParaAttrs {
let mut a = ParaAttrs::default();
let Some(p) = ppr else { return a };
let tw = |v: Option<&str>| v.and_then(|v| v.parse::<f64>().ok()).filter(|v| v.is_finite()).map(|v| v / 20.0);
// Twips, clamped: a file can hold 1e300, which became a 5e298 pt indent (#1593).
let tw = |v: Option<&str>| v.and_then(|v| v.parse::<f64>().ok()).map(|v| v / 20.0).and_then(designcraft_doc::attrs::imported_measure);
if let Some(sp) = p.child("spacing") {
a.space_before = tw(sp.attr("before"));
a.space_after = tw(sp.attr("after"));
@@ -633,6 +634,29 @@ mod tests {
assert_eq!(part_capped(&mut zip, "word/missing.xml", 4096).unwrap(), None);
}
/// A DOCX can hold any number where a measure belongs: `1e300` twips became a 5e298 pt
/// indent, and `w:sz` of `1e300` a 5e299 pt type size. Measures are clamped to the widest
/// page and a size the engine cannot mean is dropped (#1593).
#[test]
fn a_measure_or_size_a_file_cannot_mean_is_bounded() {
use designcraft_doc::attrs::MAX_MEASURE;
let doc = format!(
r#"<w:document {W}><w:body><w:p><w:pPr>
<w:spacing w:before="1e300" w:after="-1e300"/><w:ind w:left="1e300" w:firstLine="-1e300"/>
</w:pPr><w:r><w:rPr><w:sz w:val="1e300"/></w:rPr><w:t>big</w:t></w:r></w:p></w:body></w:document>"#
);
let i = import(&docx(&doc, "", "")).unwrap();
let p = &i.story.paras[0].para;
for (what, v) in
[("space before", p.space_before), ("space after", p.space_after), ("left indent", p.left_indent), ("first line", p.first_line_indent)]
{
let v = v.expect("the value is kept, bounded");
assert!(v.is_finite() && v.abs() <= MAX_MEASURE, "{what} is within the widest page: {v}");
}
let size = i.story.char_format_at(i.story.text.find("big").unwrap() + 1).over.size;
assert_eq!(size, None, "a size the engine cannot mean is dropped, so the style's size stands");
}
#[test]
fn imports_styles_runs_footnotes_and_tables() {
let styles = format!(
+2 -1
View File
@@ -129,7 +129,8 @@ fn number_style(fmt: &str) -> Format {
}
fn parse_level(e: &El) -> Level {
let tw = |v: Option<&str>| v.and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite()).map(|v| v / 20.0);
// Twips, clamped as elsewhere: a level's indent is a measure, not any number (#1593).
let tw = |v: Option<&str>| v.and_then(|v| v.trim().parse::<f64>().ok()).map(|v| v / 20.0).and_then(designcraft_doc::attrs::imported_measure);
let ind = e.child("pPr").and_then(|p| p.child("ind"));
Level {
format: e.val("numFmt").map_or(Format::Number(NumberStyle::Arabic), |f| number_style(&f)),
+1 -1
View File
@@ -229,7 +229,7 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
"strike" => restyle(&|s| s.strike = on),
// Half-points; sizes outside the engine's 0.1–1296 pt range fall back to the default.
"fs" => restyle(&|s| {
s.size = param.map(|p| p as f64 / 2.0).filter(|pt| (0.1..=1296.0).contains(pt));
s.size = param.map(|p| p as f64 / 2.0).and_then(designcraft_doc::attrs::imported_type_size);
}),
"plain" => restyle(&|s| {
s.bold = false;
+21 -1
View File
@@ -201,7 +201,9 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
}
"cFont" => fmt.over.font_family = (!value.is_empty()).then(|| value.to_string()),
"cTypeface" => fmt.over.font_style = (!value.is_empty()).then(|| value.to_string()),
"cSize" => fmt.over.size = value.trim().parse().ok().filter(|s: &f64| s.is_finite()),
// A size the engine cannot mean (0, negative, 1e300) is dropped, so the text
// keeps its style's size (#1593).
"cSize" => fmt.over.size = value.trim().parse().ok().and_then(designcraft_doc::attrs::imported_type_size),
"DefineParaStyle" | "DefineCharStyle" => {
let nm = value.split('=').next().unwrap_or("").trim().to_string();
let list = if name == "DefineParaStyle" { &mut para_styles } else { &mut char_styles };
@@ -276,6 +278,24 @@ mod tests {
assert_eq!(import("<ASCII-WIN>\r\n<ParaStyle:Body>A<0xD83D>B<0xDE00>C".as_bytes()).unwrap().story.text, "ABC");
}
/// A size does not have to be non-finite to be nonsense: a file can hold `1e300`, `0` or a
/// negative, and tagged text took all three and set them on the text (#1593). They are now
/// dropped, as RTF and DOCX already dropped them, so the text keeps its style's size.
#[test]
fn a_char_size_the_engine_cannot_mean_is_dropped() {
for v in ["1e300", "-5", "0", "1297", "0.09"] {
let src = format!("<ASCII-WIN>\r\n<ParaStyle:Body><cSize:{v}>big<cSize:>");
let i = import(src.as_bytes()).unwrap();
let size = i.story.char_format_at(i.story.text.find("big").unwrap() + 1).over.size;
assert_eq!(size, None, "cSize {v} is not a size the engine can mean");
}
// A size inside the range still arrives.
let src = "<ASCII-WIN>\r\n<ParaStyle:Body><cSize:18>big<cSize:>";
let i = import(src.as_bytes()).unwrap();
let size = i.story.char_format_at(i.story.text.find("big").unwrap() + 1).over.size;
assert_eq!(size, Some(18.0));
}
/// `f64::from_str` accepts "NaN" and "inf"; a non-finite size must not reach the story.
#[test]
fn non_finite_char_size_is_ignored() {
+4
View File
@@ -27,6 +27,7 @@ fn color_theme_tool_requests_a_palette_without_editing_the_image() {
let mut d = Document::new(&NewDocument::default());
let id = add_rect(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0));
d.item_mut(id).unwrap().content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
layer_options: None,
asset: designcraft_doc::AssetId(1),
size: (100.0, 100.0),
xf: designcraft_geom::Affine::IDENTITY,
@@ -1108,6 +1109,7 @@ fn graphic_frame(doc: &mut Document) -> ItemId {
let id = ItemId(doc.alloc());
let mut item = Item::new(id, doc.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)));
item.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
layer_options: None,
asset: designcraft_doc::AssetId(1),
size: (200.0, 100.0),
xf: designcraft_geom::Affine::translate((50.0, 100.0)),
@@ -1502,6 +1504,7 @@ fn eyedropper_samples_a_placed_image_and_matches_other_objects() {
let photo = ItemId(d.alloc());
let mut it = Item::new(photo, lid, Shape::Rectangle, shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)));
it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
layer_options: None,
asset: designcraft_doc::AssetId(1),
size: (100.0, 100.0),
xf: designcraft_geom::Affine::translate((0.0, 0.0)),
@@ -1855,6 +1858,7 @@ fn eyedropper_ignores_inactive_states_of_a_multi_state_object() {
let raster = {
let mut it = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(area));
it.content = Content::Graphic(Graphic {
layer_options: None,
asset: AssetId(1),
size: (100.0, 100.0),
xf: designcraft_geom::Affine::translate((0.0, 0.0)),
+87 -3
View File
@@ -238,8 +238,14 @@ pub fn current_parent_page(app: &DesignApp) -> Option<(usize, usize)> {
Some((pi, qi))
}
/// Scroll so absolute page `abs` is centred (keeps zoom).
/// Scroll so absolute page `abs` is centred (keeps zoom). A jump Go Back can return from.
pub fn go_to_page(app: &mut DesignApp, abs: usize) {
note_jump(app, false);
centre_page(app, abs);
note_jump(app, true);
}
fn centre_page(app: &mut DesignApp, abs: usize) {
let Some(st) = app.session.active() else { return };
let layout = CanvasLayout::new(&st.doc, false);
let Some(pr) = layout.page_rect(&st.doc, abs) else { return };
@@ -249,6 +255,65 @@ pub fn go_to_page(app: &mut DesignApp, abs: usize) {
}
}
/// The document's pages by id, in page order.
fn page_ids(doc: &Document) -> Vec<designcraft_doc::ids::PageId> {
doc.spreads.iter().flat_map(|s| s.pages.iter().map(|p| p.id)).collect()
}
/// The page in view and the view centre's offset from that page's centre. Parent pages have none,
/// nor has a view still waiting to be fitted (leaving parent pages for a document page).
pub(crate) fn page_in_view(app: &DesignApp) -> Option<(designcraft_doc::ids::PageId, DVec2)> {
let st = app.session.active().filter(|st| !st.editing_parents)?;
let (v, r) = (app.view().filter(|v| v.fitted)?, app.canvas_rect?);
let abs = current_page(app)?;
let id = st.doc.page(abs)?.id;
let pr = CanvasLayout::new(&st.doc, false).page_rect(&st.doc, abs)?;
let c = Point::new(v.origin.x + r.width() as f64 / 2.0 / v.zoom, v.origin.y + r.height() as f64 / 2.0 / v.zoom);
Some((id, c - pr.center()))
}
/// Layout ▸ Go Back / Go Forward record jumps (Go to Page, the page and spread commands, the Pages
/// panel), not scrolling: where the view is when a jump starts, and where the jump took it.
fn note_jump(app: &mut DesignApp, to: bool) {
let Some((id, offset)) = page_in_view(app) else { return };
if let Some(h) = app.page_history_mut() {
if to { h.visit(id, offset) } else { h.leave(id, offset) }
}
}
/// Whether Layout ▸ Go Back (`step` < 0) or Go Forward has a page to go to from the view as it is.
pub fn can_go_through_history(app: &DesignApp, step: isize) -> bool {
let Some(st) = app.session.active().filter(|st| !st.editing_parents) else { return false };
// A view still waiting to be fitted is refitted next frame, which would replace the destination.
if !app.view().is_some_and(|v| v.fitted) {
return false;
}
let ids = page_ids(&st.doc);
let Some(mut h) = app.page_history().cloned() else { return false };
if let Some((id, offset)) = page_in_view(app) {
h.leave(id, offset);
}
h.can_go(step, |id| ids.contains(&id))
}
/// Layout ▸ Go Back (`step` < 0) or Go Forward: show the page viewed before or after this one, where
/// the view was on it (zoom kept).
pub fn go_through_history(app: &mut DesignApp, step: isize) {
if !app.view().is_some_and(|v| v.fitted) {
return;
}
note_jump(app, false);
let Some(ids) = app.session.active().filter(|st| !st.editing_parents).map(|st| page_ids(&st.doc)) else { return };
let Some((id, offset)) = app.page_history_mut().and_then(|h| h.go(step, |id| ids.contains(&id))) else { return };
let Some(st) = app.session.active() else { return };
let Some(pr) = ids.iter().position(|p| *p == id).and_then(|abs| CanvasLayout::new(&st.doc, false).page_rect(&st.doc, abs)) else { return };
let Some(rect) = app.canvas_rect else { return };
let c = pr.center() + offset;
if let Some(v) = app.view_mut() {
v.origin = Point::new(c.x - rect.width() as f64 / 2.0 / v.zoom, c.y - rect.height() as f64 / 2.0 / v.zoom);
}
}
/// Presentation: the spread in view, centred and as large as `rect` allows.
fn present_fit(app: &mut DesignApp, rect: Rect) {
let Some(st) = app.session.active() else { return };
@@ -268,10 +333,29 @@ fn present_fit(app: &mut DesignApp, rect: Rect) {
/// Centre the spread `delta` spreads after (or before) the one in view (keeps zoom); stops at the
/// first and last spread. Parent spreads while they are being edited.
pub fn step_spread(app: &mut DesignApp, delta: isize) {
// At the first or last spread only the view moves, within the spread: no jump for Go Back.
let jump = spread_step(app, delta).is_some_and(|(cur, to)| cur != to);
if jump {
note_jump(app, false);
}
centre_spread(app, delta);
if jump {
note_jump(app, true);
}
}
/// The slot in view and the one `delta` spreads after (or before) it, stopping at the ends.
fn spread_step(app: &DesignApp, delta: isize) -> Option<(usize, usize)> {
let st = app.session.active()?;
let layout = CanvasLayout::new(&st.doc, st.editing_parents);
let cur = current_slot(app, &layout)?;
Some((cur, cur.saturating_add_signed(delta).min(layout.slots.len().saturating_sub(1))))
}
fn centre_spread(app: &mut DesignApp, delta: isize) {
let Some((_, to)) = spread_step(app, delta) else { return };
let Some(st) = app.session.active() else { return };
let layout = CanvasLayout::new(&st.doc, st.editing_parents);
let Some(cur) = current_slot(app, &layout) else { return };
let to = cur.saturating_add_signed(delta).min(layout.slots.len().saturating_sub(1));
let Some(b) = layout.slots.get(to).map(|s| s.bounds) else { return };
let Some(rect) = app.canvas_rect else { return };
if let Some(v) = app.view_mut() {
+1 -1
View File
@@ -579,7 +579,7 @@ const TRACKING: &[f64] = &[-100.0, -75.0, -50.0, -25.0, -10.0, -5.0, 0.0, 5.0, 1
const PARA_MODE: &str = "ctl_text_para_mode";
/// An icon and a field after it, as the Control panel lays its fields out.
fn icon_field(ui: &mut egui::Ui, icon: &str, tip: &str, field: NumField) -> Option<f64> {
pub(crate) fn icon_field(ui: &mut egui::Ui, icon: &str, tip: &str, field: NumField) -> Option<f64> {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover());
icons::paint(ui.painter(), r, icon, t.icon);
+390 -7
View File
@@ -32,6 +32,7 @@ impl Dialog {
let defaults = match id {
"newDocument" => crate::new_document::defaults(),
"goToPage" => json!({"page": "1"}),
"epubExport" => json!({"split": false, "mode": "style", "style": designcraft_doc::BASIC_PARAGRAPH}),
"qrCode" => json!({"type": "url", "content": "https://", "color": "[Black]"}),
"insertTable" => json!({"bodyRows": 4, "columns": 4, "headerRows": 0, "footerRows": 0}),
"insertXref" => json!({"linkTo": "paragraph", "style": "", "target": "", "format": ""}),
@@ -353,6 +354,63 @@ pub fn open_pdf_export(app: &mut DesignApp) {
app.ui.dialog = Some(Dialog::new("pdfExport", fields));
}
/// File › Export EPUB…: the EPUB Export Options dialog, seeded from the document's Split
/// Document setting.
pub fn open_epub_export(app: &mut DesignApp) {
use designcraft_doc::EpubSplit;
let split = app.session.active().map(|st| st.doc.settings.epub_split.clone()).unwrap_or_default();
let fields = match split {
EpubSplit::Off => json!({}),
EpubSplit::Style(name) => json!({"split": true, "mode": "style", "style": name}),
EpubSplit::ExportTags => json!({"split": true, "mode": "exportTags"}),
};
app.ui.dialog = Some(Dialog::new("epubExport", fields));
}
/// The Split Document setting the EPUB Export Options dialog's fields describe.
fn epub_export_split(d: &Dialog) -> designcraft_doc::EpubSplit {
use designcraft_doc::EpubSplit;
if !d.b("split") {
EpubSplit::Off
} else if d.s("mode") == "exportTags" {
EpubSplit::ExportTags
} else {
EpubSplit::Style(d.s("style"))
}
}
fn epub_export(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
let lang = app.ui.language.as_str();
let styles: Vec<String> = app
.session
.active()
.map(|st| st.doc.styles.paragraph.iter().map(|s| s.name.clone()).filter(|n| n != designcraft_doc::NO_PARA_STYLE).collect())
.unwrap_or_default();
check(ui, d, "split", crate::i18n::tr(lang, "Split Document"));
ui.add_enabled_ui(d.b("split"), |ui| {
ui.indent("epub_split", |ui| {
let mode = d.s("mode");
ui.horizontal(|ui| {
if ui.radio(mode != "exportTags", crate::rtl::widget(ui, crate::i18n::tr(lang, "Single Paragraph Style"))).clicked() {
d.fields.insert("mode".into(), json!("style"));
}
let cur = d.s("style");
egui::ComboBox::from_id_salt("epub_split_style").selected_text(crate::rtl::widget(ui, &cur)).width(180.0).show_ui(ui, |ui| {
for name in &styles {
if ui.selectable_label(cur == *name, crate::rtl::widget(ui, name)).clicked() {
d.fields.insert("style".into(), json!(name));
d.fields.insert("mode".into(), json!("style"));
}
}
});
});
if ui.radio(mode == "exportTags", crate::rtl::widget(ui, crate::i18n::tr(lang, "Based on Paragraph Style Export Tags"))).clicked() {
d.fields.insert("mode".into(), json!("exportTags"));
}
});
});
}
fn print_dialog(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
let printers: Vec<String> = d
.fields
@@ -527,6 +585,15 @@ fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) {
_ => ((inner - o.gutter * (n - 1.0)) / n).max(0.0),
};
let kind = |v: Value| v.as_str().unwrap_or_default().to_string();
// A frame without a custom baseline grid shows the document grid's start and increment.
let grid = o.baseline_grid.unwrap_or_else(|| {
let g = app.session.active().map(|s| s.doc.settings.baseline_grid.clone()).unwrap_or_default();
let relative_to = match g.relative_to {
designcraft_doc::GridRelative::TopOfPage => designcraft_doc::FrameGridRelative::TopOfPage,
designcraft_doc::GridRelative::TopMargin => designcraft_doc::FrameGridRelative::TopMargin,
};
designcraft_doc::FrameBaselineGrid { start: g.start, increment: g.increment, relative_to }
});
let shown = json!({
"columns": o.columns, "gutter": fm(o.gutter),
"columnsKind": kind(serde_json::to_value(o.columns_kind).unwrap_or_default()), "columnWidth": fm(width),
@@ -542,6 +609,8 @@ fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) {
"useMinimumHeight": o.auto_size_use_min_height, "minimumHeight": fm(o.auto_size_min_height),
"useMinimumWidth": o.auto_size_use_min_width, "minimumWidth": fm(o.auto_size_min_width),
"noLineBreaks": o.auto_size_no_line_breaks,
"customGrid": o.baseline_grid.is_some(), "gridStart": fm(grid.start), "gridIncrement": fm(grid.increment),
"gridRelativeTo": grid.relative_to.name(),
});
if let Value::Object(m) = &shown {
for (k, v) in m {
@@ -593,6 +662,28 @@ fn text_frame_options_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog)
text_field(ui, d, "firstBaselineMin", 70.0);
ui.end_row();
});
ui.add_space(8.0);
crate::rtl::label(ui, egui::RichText::new(tr("Baseline Grid")).font(semibold(12.0)));
check(ui, d, "customGrid", tr("Use Custom Baseline Grid"));
ui.add_enabled_ui(d.b("customGrid"), |ui| {
egui::Grid::new("tfo_grid").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
crate::rtl::label(ui, tr("Start:"));
text_field(ui, d, "gridStart", 70.0);
ui.end_row();
crate::rtl::label(ui, tr("Relative To:"));
translated_combo(
&lang,
ui,
d,
"gridRelativeTo",
&[("page", "Top of Page"), ("margin", "Top Margin"), ("frame", "Top of Frame"), ("inset", "Top Inset")],
);
ui.end_row();
crate::rtl::label(ui, tr("Increment Every:"));
text_field(ui, d, "gridIncrement", 70.0);
ui.end_row();
});
});
}
"autosize" => auto_size(&lang, ui, d),
"rules" => column_rules(app, ui, d),
@@ -857,6 +948,34 @@ fn confirm_text_frame_options(app: &mut DesignApp, d: &mut Dialog) -> Result<Val
p.insert(key.into(), json!(d.m(key).filter(|v| *v >= 0.0).ok_or_else(|| bad(what))?));
}
}
// The custom baseline grid: off, or the fields the user changed (all of them when it is
// turned on, since it shows the document grid's).
let grid_keys = ["customGrid", "gridStart", "gridIncrement", "gridRelativeTo"];
if grid_keys.iter().any(|k| edited(d, k)) {
if d.b("customGrid") {
let all = edited(d, "customGrid");
let mut g = Map::new();
if all || edited(d, "gridStart") {
g.insert("start".into(), json!(d.m("gridStart").ok_or_else(|| bad("the baseline grid start is not a measure"))?));
}
if all || edited(d, "gridIncrement") {
g.insert(
"increment".into(),
json!(
d.m("gridIncrement")
.filter(|v| *v > 0.0)
.ok_or_else(|| bad("the baseline grid increment must be a measure greater than zero"))?
),
);
}
if all || edited(d, "gridRelativeTo") {
g.insert("relativeTo".into(), json!(d.s("gridRelativeTo")));
}
p.insert("baselineGrid".into(), Value::Object(g));
} else {
p.insert("baselineGrid".into(), Value::Null);
}
}
if p.is_empty() {
return Ok(Value::Null);
}
@@ -2090,6 +2209,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
"print" => crate::i18n::tr(&app.ui.language, "Print"),
"pdfImport" => crate::i18n::tr(&app.ui.language, "Place PDF"),
"pdfExport" => crate::i18n::tr(&app.ui.language, "Export PDF"),
"epubExport" => crate::i18n::tr(&app.ui.language, "EPUB Export Options"),
"colorSettings" => crate::i18n::tr(&app.ui.language, "Color Settings"),
"layerOptions" => crate::i18n::tr(&app.ui.language, "Object Layer Options"),
"keyboardShortcuts" => crate::i18n::tr(&app.ui.language, "Keyboard Shortcuts"),
@@ -2431,8 +2551,15 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
&& let Some(o) = l.as_object_mut()
{
o.insert("visible".into(), json!(on));
d.fields.insert("useDefaults".into(), json!(false));
}
}
if ui.button(crate::i18n::tr(&app.ui.language, "Use File Defaults")).clicked() {
for layer in &mut layers {
layer["visible"] = layer["default"].clone();
}
d.fields.insert("useDefaults".into(), json!(true));
}
d.fields.insert("layers".into(), json!(layers));
}
"colorSettings" => {
@@ -2828,6 +2955,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
}
"numberingSection" => crate::section_options::body(app, ui, &mut d),
"pdfExport" => pdf_export(app, ui, &mut d),
"epubExport" => epub_export(app, ui, &mut d),
"alert" => {
let file = d.s("file");
if !file.is_empty() {
@@ -3307,7 +3435,12 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
}
"layerOptions" => {
let hidden: Vec<Value> = d.fields.get("layers").and_then(Value::as_array).map(|a| a.iter().filter(|l| l["visible"] == false).map(|l| l["name"].clone()).collect()).unwrap_or_default();
app.run("object.layerOptions", json!({"hidden": hidden}))
// A dialog opened without the menu's fields has no `useDefaults` at all: confirming it
// with no layer unticked resets file defaults, as it did before all-on existed, rather
// than making every layer explicitly visible. Only an explicit `false` means all-on.
let use_defaults = d.fields.get("useDefaults").and_then(Value::as_bool).unwrap_or(hidden.is_empty());
let hidden = if use_defaults { Vec::new() } else { hidden };
app.run("object.layerOptions", json!({"hidden": hidden, "useDefaults": use_defaults}))
}
"colorSettings" => {
let r = app.run("color.settings", json!({"rgb": d.s("rgb"), "cmyk": d.s("cmyk"), "intent": d.s("intent"), "bpc": d.b("bpc")}))?;
@@ -3353,6 +3486,15 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
app.status(crate::i18n::tr(&app.ui.language, "Sent {count} page(s) to {printer}").replace("{count}", &r["pages"].to_string()).replace("{printer}", printer));
Ok(r)
}
"epubExport" => {
let split = epub_export_split(&d);
let stored = app.session.active().map(|st| st.doc.settings.epub_split.clone());
if stored.as_ref() != Some(&split) {
let v = serde_json::to_value(&split).map_err(|e| e.to_string())?;
app.run("document.epubSplit", json!({"split": v}))?;
}
crate::menus::export_epub(app, &json!({}))
}
"pdfExport" => {
// Remember the last-used options; `pages` and `title` are per-job and not persisted.
app.ui.pdf_export.preset = d.s("preset");
@@ -3750,9 +3892,9 @@ fn confirm_paragraph_style_options(app: &mut DesignApp, d: &mut Dialog) -> Resul
}
let (para, chars) = style_option_maps(d);
let based = d.s("basedOn");
let rename = d.s("rename");
let rename = d.s("rename").trim().to_string();
let (r, style) = if d.b("new") {
let mut params = json!({"name": if rename.trim().is_empty() { "Paragraph Style 1" } else { rename.trim() }, "para": para, "chars": chars});
let mut params = json!({"name": if rename.is_empty() { "Paragraph Style 1" } else { rename.as_str() }, "para": para, "chars": chars});
if !based.is_empty() && based != designcraft_doc::NO_PARA_STYLE {
params["basedOn"] = json!(based);
}
@@ -3764,11 +3906,17 @@ fn confirm_paragraph_style_options(app: &mut DesignApp, d: &mut Dialog) -> Resul
if !based.is_empty() {
params["basedOn"] = if based == designcraft_doc::NO_PARA_STYLE { Value::Null } else { json!(based) };
}
if !rename.is_empty() && rename != name {
let renaming = !rename.is_empty() && rename != name;
if renaming {
params["rename"] = json!(rename);
}
let style = if !rename.is_empty() && rename != name { rename } else { name };
(app.run("style.paragraph.edit", params)?, style)
let r = app.run("style.paragraph.edit", params).inspect_err(|_| {
// A refused name is shown next to the name field.
if renaming {
d.fields.insert("section".into(), json!("general"));
}
})?;
(r, if renaming { rename } else { name })
};
// The style now exists under `style`: if a later step fails, the dialog stays open on it and
// a second OK edits it rather than creating another one.
@@ -3778,7 +3926,7 @@ fn confirm_paragraph_style_options(app: &mut DesignApp, d: &mut Dialog) -> Resul
if d.fields.contains_key("x.tag") {
let tag = d.s("x.tag");
let tag = if tag == "[Automatic]" { String::new() } else { tag };
app.run("style.exportTag", json!({"style": style, "tag": tag, "class": d.s("x.class")}))?;
app.run("style.exportTag", json!({"style": style, "tag": tag, "class": d.s("x.class"), "split": d.b("x.split")}))?;
}
Ok(r)
}
@@ -3929,6 +4077,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
if !d.fields.contains_key("x.tag") {
d.fields.insert("x.tag".into(), json!(if et.tag.is_empty() { "[Automatic]" } else { et.tag.as_str() }));
d.fields.insert("x.class".into(), json!(et.class));
d.fields.insert("x.split".into(), json!(et.split));
}
ui.horizontal(|ui| {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Tag:"));
@@ -3948,6 +4097,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Class:"));
text_field(ui, d, "x.class", 160.0);
});
check(ui, d, "x.split", crate::i18n::tr(&app.ui.language, "Split Document (EPUB only)"));
}
"nested" => drop_caps_and_nested_styles(&app.ui.language, ui, d, &pv, &cnames),
"grep" => grep_styles(&app.ui.language, ui, d, &pv, &cnames),
@@ -3981,7 +4131,32 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
}
ui.end_row();
}
// The CJK Grid Settings section edits the same `p.gridAlign` field.
use designcraft_doc::GridAlign;
const GRID_ALIGN: [(GridAlign, &str); 3] =
[(GridAlign::None, "None"), (GridAlign::AllLines, "All Lines"), (GridAlign::FirstLineOnly, "First Line Only")];
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Align to Grid:"));
let g: GridAlign = serde_json::from_value(cur(d, "p.gridAlign", &pv["gridAlign"])).unwrap_or_default();
let shown = GRID_ALIGN.iter().find(|o| o.0 == g).map_or("", |o| o.1);
egui::ComboBox::from_id_salt("psgridalign")
.selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, shown)))
.width(240.0)
.show_ui(ui, |ui| {
for (v, label) in GRID_ALIGN {
if ui.selectable_label(v == g, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked()
&& let Ok(v) = serde_json::to_value(v)
{
d.fields.insert("p.gridAlign".into(), v);
}
}
});
ui.end_row();
});
ui.add_space(6.0);
let mut ignore = cur(d, "p.ignoreOpticalMargin", &pv["ignoreOpticalMargin"]).as_bool().unwrap_or(false);
if ui.checkbox(&mut ignore, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Ignore Optical Margin"))).changed() {
d.fields.insert("p.ignoreOpticalMargin".into(), json!(ignore));
}
}
"tabs" => style_tabs(&app.ui.language, ui, d, &pv, &doc),
"rules" => paragraph_rules(app, ui, d, "p.", &pv),
@@ -7157,6 +7332,29 @@ mod tests {
assert_eq!(app.session.doc().unwrap().doc, doc);
}
/// A dialog opened without the menu's fields has no `useDefaults` field at all. Confirming it
/// with no layer unticked must keep the file's defaults, not choose every layer explicitly:
/// an implicit all-on choice is rejected for a PDF without layers, so the command's error
/// tells the two apart.
#[test]
fn layer_options_confirm_without_the_menu_fields_keeps_file_defaults() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 100, "height": 100, "pages": 1})).unwrap();
let pdf = app.run("file.exportPdf", json!({})).unwrap()["base64"].clone();
let placed = app.run("file.place", json!({"base64": pdf, "name": "plain.pdf", "x": 0, "y": 0, "width": 100})).unwrap();
let id = designcraft_doc::ItemId(placed["id"].as_u64().unwrap());
app.run("selection.set", json!({"ids": [placed["id"]]})).unwrap();
app.ui.dialog = Some(Dialog::new("layerOptions", json!({})));
confirm(&mut app).unwrap();
assert!(!app.session.doc().unwrap().doc.item(id).unwrap().has_pdf_layer_override(), "an untouched dialog keeps the file defaults");
// The menu's own dialog still asks for an explicit all-on choice when that is the state
// it was opened in; a layerless PDF refuses it rather than accepting an empty override.
app.ui.dialog = Some(Dialog::new("layerOptions", json!({"useDefaults": false, "layers": []})));
let err = confirm(&mut app).unwrap_err();
assert!(err.contains("could not be changed"), "{err}");
assert!(!app.session.doc().unwrap().doc.item(id).unwrap().has_pdf_layer_override(), "a layerless PDF never takes an explicit choice");
}
#[test]
fn paragraph_style_settings_compare_with_the_chosen_parent() {
let mut styles = designcraft_doc::Styles::default();
@@ -7790,6 +7988,35 @@ mod tests {
assert_eq!(props.number_label(3), "III)\t");
}
#[test]
fn paragraph_style_options_ignore_optical_margin_is_set_on_ok() {
use egui_kittest::{Harness, kittest::Queryable};
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
app.run("style.paragraph.create", json!({"name": "Flush"})).unwrap();
app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Flush", "section": "indents"})));
let mut h = Harness::builder().with_size(egui::vec2(1000.0, 800.0)).build_ui_state(
|ui, app: &mut DesignApp| {
let key = egui::Id::new("ignore_optical_margin_fonts");
if ui.data(|d| d.get_temp::<bool>(key)).is_none() {
crate::theme::install_fonts(ui.ctx(), "");
ui.data_mut(|d| d.insert_temp(key, true));
return;
}
show(app, ui.ctx());
},
app,
);
h.run_steps(3);
h.get_by_label("Ignore Optical Margin").click();
h.run_steps(3);
h.get_by_label(" OK ").click();
h.run_steps(3);
let app = h.state();
assert!(app.ui.dialog.is_none());
assert_eq!(app.session.doc().unwrap().doc.styles.para("Flush").unwrap().para.ignore_optical_margin, Some(true));
}
#[test]
fn paragraph_style_options_keep_a_bad_grep_pattern_open_with_the_error() {
let (mut app, _) = ruled_paragraph();
@@ -7805,6 +8032,26 @@ mod tests {
draw(&mut app, &ctx);
}
#[test]
fn paragraph_style_options_show_a_refused_rename_on_general_and_trim_a_new_name() {
let (mut app, _) = ruled_paragraph();
let ctx = egui::Context::default();
app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled", "section": "rules"})));
draw(&mut app, &ctx);
app.ui.dialog.as_mut().unwrap().fields.insert("rename".into(), json!("Ruled Child"));
let e = confirm(&mut app).unwrap_err();
let d = app.ui.dialog.clone().expect("the dialog stays open");
assert_eq!(d.s("section"), "general", "the name field is on General");
assert!(d.s("status").contains("already exists"), "{e}");
app.ui.dialog.as_mut().unwrap().fields.insert("rename".into(), json!(" Fresh "));
app.ui.dialog.as_mut().unwrap().fields.insert("x.tag".into(), json!("h3"));
confirm(&mut app).unwrap();
let st = &app.session.doc().unwrap().doc.styles;
assert!(st.para("Fresh").is_some());
assert_eq!(st.export_tag("Fresh", false).map(|t| t.tag.as_str()), Some("h3"));
}
/// A frame with two paragraphs of [Basic Paragraph]; the first has a local Word Spacing minimum.
fn two_paragraphs() -> (DesignApp, Value) {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
@@ -8303,6 +8550,53 @@ mod tests {
assert_eq!((opts(&app).columns_kind, opts(&app).column_width), (designcraft_doc::ColumnsKind::FixedNumber, 120.0));
}
#[test]
fn baseline_grid_increment_error_explains_the_positive_measure_rule() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let r = app.run("frame.create", json!({"rect": [36, 36, 336, 236], "content": "text", "text": "Hello"})).unwrap();
let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap());
for increment in ["0", "-1 pt", "not a measure"] {
let mut d = Dialog::new("textFrameOptions", json!({}));
seed_text_frame_options(&app, &mut d);
d.fields.insert("customGrid".into(), json!(true));
d.fields.insert("gridIncrement".into(), json!(increment));
app.ui.dialog = Some(d);
let error = confirm(&mut app).unwrap_err().to_string();
assert!(error.contains("the baseline grid increment must be a measure greater than zero"), "{increment}: {error}");
assert!(app.session.doc().unwrap().doc.item(id).unwrap().text_frame().unwrap().options.baseline_grid.is_none());
}
}
#[test]
fn text_frame_options_set_a_custom_baseline_grid_relative_to() {
use designcraft_doc::{FrameBaselineGrid, FrameGridRelative};
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let r = app.run("frame.create", json!({"rect": [36, 36, 336, 236], "content": "text", "text": "Hello"})).unwrap();
let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap());
let grid = |app: &DesignApp| app.session.doc().unwrap().doc.item(id).unwrap().text_frame().unwrap().options.baseline_grid;
let open = |app: &mut DesignApp, fields: &[(&str, Value)]| {
let mut d = Dialog::new("textFrameOptions", json!({}));
seed_text_frame_options(app, &mut d);
for (k, v) in fields {
d.fields.insert((*k).into(), v.clone());
}
app.ui.dialog = Some(d);
confirm(app)
};
app.run("selection.set", json!({"ids": [id.0]})).unwrap();
// Turned on, the grid takes the shown start and increment (the document grid's: 3p0 and 1p0).
open(&mut app, &[("customGrid", json!(true)), ("gridRelativeTo", json!("frame"))]).unwrap();
assert_eq!(grid(&app), Some(FrameBaselineGrid { start: 36.0, increment: 12.0, relative_to: FrameGridRelative::TopOfFrame }));
// The dialog shows the frame's grid; changing Relative To keeps its start and increment.
open(&mut app, &[("gridStart", json!("0p10"))]).unwrap();
open(&mut app, &[("gridRelativeTo", json!("inset"))]).unwrap();
assert_eq!(grid(&app), Some(FrameBaselineGrid { start: 10.0, increment: 12.0, relative_to: FrameGridRelative::TopInset }));
open(&mut app, &[("customGrid", json!(false))]).unwrap();
assert_eq!(grid(&app), None);
}
#[test]
fn text_frame_options_change_only_what_was_edited() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
@@ -9658,6 +9952,31 @@ mod tests {
assert_eq!(st.doc.styles.para(&name).unwrap().para.align, Some(designcraft_doc::Align::Center));
}
#[test]
fn paragraph_style_options_align_to_grid_shows_and_sets_the_style_grid_alignment() {
use designcraft_doc::GridAlign;
use egui_kittest::kittest::Queryable;
let mut app = test_app();
app.session.execute("style.paragraph.create", &json!({"name": "Grid Body", "para": {"gridAlign": "firstLineOnly"}})).unwrap();
app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Grid Body", "section": "indents"})));
let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0));
let grid = |h: &egui_kittest::Harness<'static, crate::test_window::Window>| {
h.state().app.session.active().unwrap().doc.styles.para("Grid Body").unwrap().para.grid_align
};
h.run_steps(4);
h.get_by_value("First Line Only");
confirm(&mut h.state_mut().app).unwrap();
assert_eq!(grid(&h), Some(GridAlign::FirstLineOnly), "OK without edits keeps the style's alignment");
h.state_mut().app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Grid Body", "section": "indents"})));
h.run_steps(4);
h.get_by_value("First Line Only").click();
h.run_steps(3);
h.get_by_label("All Lines").click();
h.run_steps(3);
confirm(&mut h.state_mut().app).unwrap();
assert_eq!(grid(&h), Some(GridAlign::AllLines));
}
#[test]
fn delete_paragraph_style_dialog_removes_the_style_and_reassigns_text() {
let mut app = test_app();
@@ -9722,6 +10041,70 @@ mod tests {
assert!(st.doc.styles.char_style(&name).is_none());
}
/// File › Export EPUB… opens EPUB Export Options from the document's setting; OK stores a
/// changed Split Document as one undo step, and an unchanged one adds none.
#[test]
fn epub_export_passes_its_options_to_the_engine() {
use std::io::Read;
use std::sync::{Arc, Mutex};
let mut app = test_app();
app.session.execute("style.paragraph.create", &json!({"name": "Chapter"})).unwrap();
let r = app.session.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "One\nTwo"})).unwrap();
let sid = r["story"].as_u64().unwrap();
for anchor in [0, 4] {
app.session.execute("text.select", &json!({"story": sid, "anchor": anchor, "focus": anchor})).unwrap();
app.session.execute("style.paragraph.apply", &json!({"name": "Chapter"})).unwrap();
}
let written: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
let sink = written.clone();
app.services.write = Some(Box::new(move |_: &str, bytes: &[u8]| {
*sink.lock().unwrap() = bytes.to_vec();
Ok(())
}));
crate::menus::export_epub(&mut app, &json!({"path": "book.epub", "split": {"style": "Chapter"}})).unwrap();
let bytes = written.lock().unwrap().clone();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut opf = String::new();
z.by_name("OEBPS/content.opf").unwrap().read_to_string(&mut opf).unwrap();
assert!(opf.contains("properties=\"cover-image\""), "the first page is the cover: {opf}");
assert!(z.by_name("OEBPS/content2.xhtml").is_ok(), "the split override reaches the export");
assert_eq!(app.session.active().unwrap().doc.settings.epub_split, designcraft_doc::EpubSplit::Off, "an override is not stored");
crate::menus::export_epub(&mut app, &json!({"path": "book.epub", "cover": false})).unwrap();
let bytes = written.lock().unwrap().clone();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut opf = String::new();
z.by_name("OEBPS/content.opf").unwrap().read_to_string(&mut opf).unwrap();
assert!(!opf.contains("cover-image"), "a caller's cover: false stays: {opf}");
}
#[test]
fn epub_export_options_store_split_document() {
use designcraft_doc::EpubSplit;
let mut app = test_app();
app.session.execute("style.paragraph.create", &json!({"name": "Chapter"})).unwrap();
let undo_steps = |app: &crate::DesignApp| app.session.active().map(|st| st.history.undo.len());
app.run("app.exportEpub", json!({})).unwrap();
let d = app.ui.dialog.as_mut().unwrap();
assert_eq!((d.id.as_str(), d.b("split"), d.s("style").as_str()), ("epubExport", false, designcraft_doc::BASIC_PARAGRAPH));
d.fields.insert("split".into(), json!(true));
d.fields.insert("style".into(), json!("Chapter"));
let before = undo_steps(&app);
confirm(&mut app).unwrap();
assert_eq!(app.session.active().unwrap().doc.settings.epub_split, EpubSplit::Style("Chapter".into()));
assert_eq!(undo_steps(&app), before.map(|n| n + 1));
open_epub_export(&mut app);
assert_eq!(app.ui.dialog.as_ref().map(|d| (d.b("split"), d.s("mode"), d.s("style"))), Some((true, "style".into(), "Chapter".into())));
confirm(&mut app).unwrap();
assert_eq!(undo_steps(&app), before.map(|n| n + 1), "unchanged: no undo step");
open_epub_export(&mut app);
app.ui.dialog.as_mut().unwrap().fields.insert("mode".into(), json!("exportTags"));
confirm(&mut app).unwrap();
assert_eq!(app.session.active().unwrap().doc.settings.epub_split, EpubSplit::ExportTags);
open_epub_export(&mut app);
cancel(&mut app);
assert_eq!(app.session.active().unwrap().doc.settings.epub_split, EpubSplit::ExportTags, "Cancel stores nothing");
}
#[test]
fn pdf_export_defaults() {
let d = Dialog::new("pdfExport", json!({}));
+2
View File
@@ -23,6 +23,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[
("typography", "Typography", "panel-typography"),
("glyphs", "Glyphs", "panel-character"),
("paragraph", "Paragraph", "panel-paragraph"),
("story", "Story", "panel-text-frame"),
("textWrap", "Text Wrap", "panel-wrap"),
("table", "Table", "panel-table"),
("align", "Align", "panel-align"),
@@ -285,6 +286,7 @@ pub fn panel_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str) {
"typography" => panels::typography::show(app, ui),
"glyphs" => panels::glyphs::show(app, ui),
"paragraph" => panels::properties::paragraph_panel(app, ui),
"story" => panels::story::show(app, ui),
"textWrap" => panels::properties::wrap_panel(app, ui),
"align" => panels::properties::align_panel(app, ui),
"pathfinder" => panels::properties::pathfinder_panel(app, ui),
+57 -11
View File
@@ -116,10 +116,17 @@ fn is_on(fx: &Value, page: &str) -> bool {
/// The `object.effects` params the dialog stands for.
pub fn params(d: &Dialog) -> Value {
json!({"ids": d.fields.get("ids").cloned().unwrap_or(json!([])), "effects": d.fields.get("fx").cloned().unwrap_or(json!({})),
"opacity": d.fields.get("opacity").and_then(Value::as_f64).unwrap_or(1.0), "blend": d.fields.get("blend").and_then(Value::as_str).unwrap_or("Normal")})
let mut p = json!({"ids": d.fields.get("ids").cloned().unwrap_or(json!([])), "effects": d.fields.get("fx").cloned().unwrap_or(json!({})),
"opacity": d.fields.get("opacity").and_then(Value::as_f64).unwrap_or(1.0), "blend": d.fields.get("blend").and_then(Value::as_str).unwrap_or("Normal")});
if let Some(a) = d.fields.get(GLOBAL_LIGHT).and_then(Value::as_f64) {
p["globalLight"] = json!(a);
}
p
}
/// The global light angle as changed in the dialog (an Angle field with Use Global Light on).
const GLOBAL_LIGHT: &str = "globalLight";
/// Apply: the preview goes and the settings are made once, as one undo step.
pub fn confirm(app: &mut DesignApp, d: &Dialog) -> Result<Value, String> {
if d.fields.contains_key(PREVIEWED) {
@@ -202,18 +209,28 @@ fn percent(ui: &mut egui::Ui, d: &mut Dialog, path: &[&str]) {
}
}
fn angle(ui: &mut egui::Ui, d: &mut Dialog, path: &[&str], enabled: bool, global: f64) {
let mut v = if enabled { num(d, path) } else { global };
/// The effect's own angle at `path`, or with Use Global Light (`own` false) the document's
/// global light angle: editing it then changes that one, for every effect that uses it. The dial
/// and the field both edit it.
fn angle(ui: &mut egui::Ui, d: &mut Dialog, path: &[&str], own: bool, global: f64) -> egui::Response {
let mut v = if own { num(d, path) } else { global };
let put = |d: &mut Dialog, v: f64| {
if own {
set(d, path, json!(v));
} else {
d.fields.insert(GLOBAL_LIGHT.into(), json!(v));
}
};
ui.horizontal(|ui| {
if let Some(a) = angle_dial(ui, v, enabled) {
if let Some(a) = angle_dial(ui, v, true) {
v = a;
set(d, path, json!(v));
put(d, v);
}
let r = ui.add_enabled(enabled, egui::DragValue::new(&mut v).range(-180.0..=180.0).suffix("°").max_decimals(1));
if r.changed() {
set(d, path, json!(v));
if ui.add(egui::DragValue::new(&mut v).range(-180.0..=180.0).suffix("°").max_decimals(1)).changed() {
put(d, v);
}
});
})
.response
}
/// InDesign's angle dial: a circle with a radius pointing where the light comes from (0° to the
@@ -296,6 +313,7 @@ fn dial_angle(c: egui::Pos2, p: egui::Pos2) -> Option<f64> {
let d = p - c;
// Straight left is 180° (not −180°, which a negative zero would give).
(d.length() > 2.0).then(|| f64::from((-d.y).atan2(d.x)).to_degrees().round()).map(|a| if a <= -180.0 { 180.0 } else { a })
r
}
fn flag(ui: &mut egui::Ui, d: &mut Dialog, path: &[&str], label: &str) {
@@ -361,7 +379,9 @@ fn page_ui(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, page: &str) {
let lang = app.ui.language.clone();
let tr = |s: &str| crate::i18n::tr(&lang, s).to_string();
let unit = app.session.active().map_or(Unit::Points, |st| st.doc.settings.horizontal_units);
let global = app.session.active().map_or(120.0, |st| st.doc.settings.global_light);
// The global light angle as the dialog will set it (an edit in it shows at once).
let global =
d.fields.get(GLOBAL_LIGHT).and_then(Value::as_f64).unwrap_or_else(|| app.session.active().map_or(120.0, |st| st.doc.settings.global_light));
let row = |ui: &mut egui::Ui, l: &str| crate::rtl::label(ui, tr(l));
crate::rtl::label(ui, egui::RichText::new(tr(PAGES.iter().find(|p| p.0 == page).map_or("Transparency", |p| p.1))).font(semibold(14.0)));
let grid = |id: &str| egui::Grid::new(("fx_grid", id.to_string())).num_columns(4).spacing([8.0, 6.0]);
@@ -745,6 +765,32 @@ mod tests {
texts
}
/// With Use Global Light on, the Angle is the document's global light and stays editable:
/// a change there is applied to the document with the effects (it was greyed out).
#[test]
fn the_angle_with_global_light_sets_the_global_light() {
let (mut app, _) = app_with_frame();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx, "en");
crate::menus::activate(&mut app, "object.dropShadow", &Value::Null);
let d = app.ui.dialog.as_mut().unwrap();
set(d, &["dropShadow", "globalLight"], json!(true));
frame(&mut app, &ctx);
// The field shows the global angle and is enabled: a DragValue that isn't disabled.
let global = app.session.doc().unwrap().doc.settings.global_light;
let mut d = app.ui.dialog.clone().unwrap();
let ui_ctx = egui::Context::default();
let mut enabled = false;
let mut out = ui_ctx.run_ui(egui::RawInput::default(), |ui| {
enabled = angle(ui, &mut d, &["dropShadow", "angle"], false, global).enabled();
});
out.textures_delta.clear();
assert!(enabled, "the Angle stays editable with Use Global Light");
d.fields.insert(GLOBAL_LIGHT.into(), json!(15.0));
confirm(&mut app, &d).unwrap();
assert_eq!(app.session.doc().unwrap().doc.settings.global_light, 15.0);
}
#[test]
fn set_makes_the_path_and_ignores_an_empty_one() {
let mut d = Dialog::new(ID, json!({"fx": {}}));
+15
View File
@@ -200,6 +200,8 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Previous Page", ["Vorherige Seite", "Page précédente", "Página anterior", "前のページ", "上一页"]),
("Next Page", ["Nächste Seite", "Page suivante", "Página siguiente", "次のページ", "下一页"]),
("Last Page", ["Letzte Seite", "Dernière page", "Última página", "最後のページ", "最后一页"]),
("Go Back", ["Zurück gehen", "Revenir", "Retroceder", "前に戻る", "后退"]),
("Go Forward", ["Vorwärts gehen", "Avancer", "Avanzar", "次に進む", "前进"]),
(
"Close Tab Group",
["Registerkartengruppe schließen", "Fermer le groupe d'onglets", "Cerrar grupo de fichas", "タブグループを閉じる", "关闭选项卡组"],
@@ -1774,6 +1776,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
"Convert to Form Field",
["Convert to Form Field", "Convertir en champ de formulaire", "Convert to Form Field", "Convert to Form Field", "转换为表单域"],
),
("Convert to Note", ["Convert to Note", "Convertir en note", "Convert to Note", "Convert to Note", "转换为注释"]),
("Conveyor", ["Conveyor", "Convoyeur", "Conveyor", "Conveyor", "传送带"]),
("Copy Link(s) To…", ["Copy Link(s) To…", "Copier les liens vers…", "Copy Link(s) To…", "Copy Link(s) To…", "复制链接至…"]),
("Corner Options…", ["Corner Options…", "Options d'arrondi…", "Corner Options…", "Corner Options…", "角选项…"]),
@@ -2025,6 +2028,16 @@ const TABLE: &[(&str, [&str; 5])] = &[
),
("Insert Pages…", ["Insert Pages…", "Insérer des pages…", "Insert Pages…", "Insert Pages…", "插入页面…"]),
("Insert Variable", ["Insert Variable", "Insérer une variable", "Insert Variable", "Insert Variable", "插入变量"]),
(
"Convert Variable to Text",
[
"Variable in Text konvertieren",
"Convertir la variable en texte",
"Convertir variable en texto",
"変数をテキストに変換",
"将变量转换为文本",
],
),
("Inspect Document", ["Inspect Document", "Inspecter le document", "Inspect Document", "Inspect Document", "检查文档"]),
(
"Interface Color Theme",
@@ -2084,6 +2097,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
("New Table Style…", ["New Table Style…", "Nouveau style de tableau…", "New Table Style…", "New Table Style…", "新建表样式…"]),
("Next Cell", ["Next Cell", "Cellule suivante", "Next Cell", "Next Cell", "下一个单元格"]),
("Next Document", ["Next Document", "Document suivant", "Next Document", "Next Document", "下一个文档"]),
("Next Note", ["Next Note", "Note suivante", "Next Note", "Next Note", "下一个注释"]),
("Next Object Above", ["Next Object Above", "Objet suivant au-dessus", "Next Object Above", "Next Object Above", "上方下一个对象"]),
("Next Object Below", ["Next Object Below", "Objet suivant en dessous", "Next Object Below", "Next Object Below", "下方下一个对象"]),
(
@@ -2162,6 +2176,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Preflight Document", ["Preflight Document", "Contrôle en amont du document", "Preflight Document", "Preflight Document", "预检文档"]),
("Previous Cell", ["Previous Cell", "Cellule précédente", "Previous Cell", "Previous Cell", "上一个单元格"]),
("Previous Document", ["Previous Document", "Document précédent", "Previous Document", "Previous Document", "上一个文档"]),
("Previous Note", ["Previous Note", "Note précédente", "Previous Note", "Previous Note", "上一个注释"]),
(
"Previous Object in Group",
["Previous Object in Group", "Objet précédent du groupe", "Previous Object in Group", "Previous Object in Group", "组中的上一个对象"],
+6
View File
@@ -207,6 +207,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Previous Page", "الصفحة السابقة"),
("First Page", "الصفحة الأولى"),
("Last Page", "الصفحة الأخيرة"),
("Go Back", "رجوع"),
("Go Forward", "تقدم"),
("Font", "خط"),
("Size", "الحجم"),
("Character", "حرف"),
@@ -1378,6 +1380,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Convert Text to Table", "تحويل النص إلى جدول"),
("Convert to Button", "تحويل إلى زر"),
("Convert to Form Field", "تحويل إلى حقل نموذج"),
("Convert to Note", "تحويل إلى ملاحظة"),
("Copy Link(s) To…", "نسخ الروابط إلى…"),
("Corner Options…", "خيارات الزوايا…"),
("Create Alternate Layout…", "إنشاء تخطيط بديل…"),
@@ -1448,6 +1451,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Insert Math Expression…", "إدراج تعبير رياضي…"),
("Insert Pages…", "إدراج صفحات…"),
("Insert Variable", "إدراج متغير"),
("Convert Variable to Text", "تحويل المتغير إلى نص"),
("Inspect Document", "فحص المستند"),
("Interface Color Theme", "نسق ألوان الواجهة"),
("Join the ArtCraft Discord…", "الانضمام إلى Discord الخاص بـ ArtCraft…"),
@@ -1481,6 +1485,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("New Table Style…", "نمط جدول جديد…"),
("Next Cell", "الخلية التالية"),
("Next Document", "المستند التالي"),
("Next Note", "الملاحظة التالية"),
("Next Object Above", "الكائن التالي أعلى"),
("Next Object Below", "الكائن التالي أسفل"),
("Next Object in Group", "الكائن التالي في المجموعة"),
@@ -1509,6 +1514,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Preflight Document", "فحص المستند قبل الطباعة"),
("Previous Cell", "الخلية السابقة"),
("Previous Document", "المستند السابق"),
("Previous Note", "الملاحظة السابقة"),
("Previous Object in Group", "الكائن السابق في المجموعة"),
("Printers", "طابعات"),
("Proof Colors", "ألوان المعاينة الطباعية"),
+6
View File
@@ -507,6 +507,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Convert Text to Table", "Převést text na tabulku"),
("Convert to Button", "Převést na tlačítko"),
("Convert to Form Field", "Převést na pole formuláře"),
("Convert to Note", "Převést na poznámku"),
("Conveyor", "Dopravník"),
("Copy Link(s) To…", "Kopírovat propojení do…"),
("Corner Options…", "Možnosti rohů…"),
@@ -597,6 +598,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Insert Math Expression…", "Vložit matematický výraz…"),
("Insert Pages…", "Vložit stránky…"),
("Insert Variable", "Vložit proměnnou"),
("Convert Variable to Text", "Převést proměnnou na text"),
("Inspect Document", "Prozkoumat dokument"),
("Interface Color Theme", "Barevný motiv rozhraní"),
("Join the ArtCraft Discord…", "Připojit se k Discordu ArtCraft…"),
@@ -632,6 +634,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("New Table Style…", "Nový styl tabulky…"),
("Next Cell", "Další buňka"),
("Next Document", "Další dokument"),
("Next Note", "Další poznámka"),
("Next Object Above", "Další objekt nad"),
("Next Object Below", "Další objekt pod"),
("Next Object in Group", "Další objekt ve skupině"),
@@ -665,6 +668,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Preflight Document", "Předtisková kontrola dokumentu"),
("Previous Cell", "Předchozí buňka"),
("Previous Document", "Předchozí dokument"),
("Previous Note", "Předchozí poznámka"),
("Previous Object in Group", "Předchozí objekt ve skupině"),
("Primary Text Frame", "Primární textový rámeček"),
("Print Booklet…", "Tisk brožury…"),
@@ -1480,6 +1484,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Previous Page", "Předchozí stránka"),
("First Page", "První stránka"),
("Last Page", "Poslední stránka"),
("Go Back", "Přejít zpět"),
("Go Forward", "Přejít vpřed"),
("Object Styles", "Styly objektů"),
("Row", "Řádek"),
("Column", "Sloupec"),
+4
View File
@@ -544,6 +544,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Convert Text to Table", "Text in Tabelle umwandeln"),
("Convert to Button", "In Schaltfläche umwandeln"),
("Convert to Form Field", "In Formularfeld umwandeln"),
("Convert to Note", "In Notiz umwandeln"),
("Conveyor", "Förderband"),
("Copy Link(s) To…", "Verknüpfung(en) kopieren nach …"),
("Corner Options…", "Eckenoptionen …"),
@@ -634,6 +635,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Insert Math Expression…", "Mathematischen Ausdruck einfügen …"),
("Insert Pages…", "Seiten einfügen …"),
("Insert Variable", "Variable einfügen"),
("Convert Variable to Text", "Variable in Text konvertieren"),
("Inspect Document", "Dokument untersuchen"),
("Interface Color Theme", "Farbschema der Oberfläche"),
("Join the ArtCraft Discord…", "Dem ArtCraft-Discord beitreten …"),
@@ -669,6 +671,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("New Table Style…", "Neues Tabellenformat …"),
("Next Cell", "Nächste Zelle"),
("Next Document", "Nächstes Dokument"),
("Next Note", "Nächste Notiz"),
("Next Object Above", "Nächstes Objekt darüber"),
("Next Object Below", "Nächstes Objekt darunter"),
("Next Object in Group", "Nächstes Objekt in der Gruppe"),
@@ -702,6 +705,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Preflight Document", "Preflight für Dokument"),
("Previous Cell", "Vorherige Zelle"),
("Previous Document", "Vorheriges Dokument"),
("Previous Note", "Vorherige Notiz"),
("Previous Object in Group", "Vorheriges Objekt in der Gruppe"),
("Primary Text Frame", "Primärer Textrahmen"),
("Print Booklet…", "Broschüre drucken …"),
+6
View File
@@ -635,6 +635,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Convert Text to Table", "Converti testo in tabella"),
("Convert to Button", "Converti in pulsante"),
("Convert to Form Field", "Converti in campo modulo"),
("Convert to Note", "Converti in nota"),
("Conveyor", "Nastro"),
("Copy Link(s) To…", "Copia collegamenti in…"),
("Corner Options…", "Opzioni angoli…"),
@@ -725,6 +726,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Insert Math Expression…", "Inserisci espressione matematica…"),
("Insert Pages…", "Inserisci pagine…"),
("Insert Variable", "Inserisci variabile"),
("Convert Variable to Text", "Converti variabile in testo"),
("Inspect Document", "Ispeziona documento"),
("Interface Color Theme", "Tema colore dell'interfaccia"),
("Join the ArtCraft Discord…", "Unisciti al Discord di ArtCraft…"),
@@ -760,6 +762,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("New Table Style…", "Nuovo stile tabella…"),
("Next Cell", "Cella successiva"),
("Next Document", "Documento successivo"),
("Next Note", "Nota successiva"),
("Next Object Above", "Oggetto successivo sopra"),
("Next Object Below", "Oggetto successivo sotto"),
("Next Object in Group", "Oggetto successivo nel gruppo"),
@@ -793,6 +796,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Preflight Document", "Verifica preliminare documento"),
("Previous Cell", "Cella precedente"),
("Previous Document", "Documento precedente"),
("Previous Note", "Nota precedente"),
("Previous Object in Group", "Oggetto precedente nel gruppo"),
("Primary Text Frame", "Cornice di testo principale"),
("Print Booklet…", "Stampa opuscolo…"),
@@ -1654,6 +1658,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Previous Page", "Pagina precedente"),
("First Page", "Prima pagina"),
("Last Page", "Ultima pagina"),
("Go Back", "Torna indietro"),
("Go Forward", "Vai avanti"),
("Object Styles", "Stili oggetto"),
("Row", "Riga"),
("Column", "Colonna"),
+4
View File
@@ -736,6 +736,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Convert Text to Table", "テキストを表に変換"),
("Convert to Button", "ボタンに変換"),
("Convert to Form Field", "フォームフィールドに変換"),
("Convert to Note", "注釈に変換"),
("Conveyor", "コンベヤー"),
("Copy Link(s) To…", "リンクを指定先にコピー…"),
("Corner Options…", "角のオプション…"),
@@ -826,6 +827,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Insert Math Expression…", "数式を挿入…"),
("Insert Pages…", "ページを挿入…"),
("Insert Variable", "変数を挿入"),
("Convert Variable to Text", "変数をテキストに変換"),
("Inspect Document", "ドキュメントを調査"),
("Interface Color Theme", "インターフェイスのカラーテーマ"),
("Join the ArtCraft Discord…", "ArtCraft の Discord に参加…"),
@@ -861,6 +863,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("New Table Style…", "新規表スタイル…"),
("Next Cell", "次のセル"),
("Next Document", "次のドキュメント"),
("Next Note", "次の注釈"),
("Next Object Above", "1つ前面のオブジェクト"),
("Next Object Below", "1つ背面のオブジェクト"),
("Next Object in Group", "グループ内の次のオブジェクト"),
@@ -894,6 +897,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Preflight Document", "ドキュメントのプリフライト"),
("Previous Cell", "前のセル"),
("Previous Document", "前のドキュメント"),
("Previous Note", "前の注釈"),
("Previous Object in Group", "グループ内の前のオブジェクト"),
("Primary Text Frame", "主テキストフレーム"),
("Print Booklet…", "小冊子をプリント…"),
+6
View File
@@ -562,6 +562,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Convert Text to Table", "ტექსტის ცხრილად გარდაქმნა"),
("Convert to Button", "ღილაკად გარდაქმნა"),
("Convert to Form Field", "ფორმის ველად გარდაქმნა"),
("Convert to Note", "შენიშვნად გარდაქმნა"),
("Conveyor", "კონვეიერი"),
("Copy Link(s) To…", "ბმული ფაილების კოპირება…"),
("Corner Options…", "კუთხეების პარამეტრები…"),
@@ -653,6 +654,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Insert Math Expression…", "მათემატიკური გამოსახულების ჩასმა…"),
("Insert Pages…", "გვერდების ჩასმა…"),
("Insert Variable", "ცვლადის ჩასმა"),
("Convert Variable to Text", "ცვლადის ტექსტად გარდაქმნა"),
("Inspect Document", "დოკუმენტის შემოწმება"),
("Interface Color Theme", "ინტერფეისის ფერთა თემა"),
("Join the ArtCraft Discord…", "შემოუერთდით ArtCraft-ის Discord-ს…"),
@@ -688,6 +690,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("New Table Style…", "ცხრილის ახალი სტილი…"),
("Next Cell", "შემდეგი უჯრა"),
("Next Document", "შემდეგი დოკუმენტი"),
("Next Note", "შემდეგი შენიშვნა"),
("Next Object Above", "შემდეგი ობიექტი ზემოთ"),
("Next Object Below", "შემდეგი ობიექტი ქვემოთ"),
("Next Object in Group", "ჯგუფის შემდეგი ობიექტი"),
@@ -721,6 +724,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Preflight Document", "დოკუმენტის წინასწარი შემოწმება"),
("Previous Cell", "წინა უჯრა"),
("Previous Document", "წინა დოკუმენტი"),
("Previous Note", "წინა შენიშვნა"),
("Previous Object in Group", "ჯგუფის წინა ობიექტი"),
("Primary Text Frame", "ძირითადი ტექსტური ჩარჩო"),
("Print Booklet…", "ბროშურის დაბეჭდვა…"),
@@ -1586,6 +1590,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Previous Page", "წინა გვერდი"),
("First Page", "პირველი გვერდი"),
("Last Page", "ბოლო გვერდი"),
("Go Back", "უკან დაბრუნება"),
("Go Forward", "წინ გადასვლა"),
("Object Styles", "ობიექტის სტილები"),
("Row", "სტრიქონი"),
("Rulers", "სახაზავები"),
+6
View File
@@ -514,6 +514,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Convert Text to Table", "Zamień tekst na tabelę"),
("Convert to Button", "Zamień na przycisk"),
("Convert to Form Field", "Zamień na pole formularza"),
("Convert to Note", "Zamień na notatkę"),
("Conveyor", "Przenośnik"),
("Copy Link(s) To…", "Kopiuj łącza do…"),
("Corner Options…", "Opcje narożników…"),
@@ -604,6 +605,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Insert Math Expression…", "Wstaw wyrażenie matematyczne…"),
("Insert Pages…", "Wstaw strony…"),
("Insert Variable", "Wstaw zmienną"),
("Convert Variable to Text", "Zamień zmienną na tekst"),
("Inspect Document", "Sprawdź dokument"),
("Interface Color Theme", "Motyw kolorystyczny interfejsu"),
("Join the ArtCraft Discord…", "Dołącz do Discorda ArtCraft…"),
@@ -639,6 +641,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("New Table Style…", "Nowy styl tabeli…"),
("Next Cell", "Następna komórka"),
("Next Document", "Następny dokument"),
("Next Note", "Następna notatka"),
("Next Object Above", "Następny obiekt powyżej"),
("Next Object Below", "Następny obiekt poniżej"),
("Next Object in Group", "Następny obiekt w grupie"),
@@ -672,6 +675,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Preflight Document", "Inspekcja wstępna dokumentu"),
("Previous Cell", "Poprzednia komórka"),
("Previous Document", "Poprzedni dokument"),
("Previous Note", "Poprzednia notatka"),
("Previous Object in Group", "Poprzedni obiekt w grupie"),
("Primary Text Frame", "Główna ramka tekstowa"),
("Print Booklet…", "Drukuj broszurę…"),
@@ -1517,6 +1521,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Previous Page", "Poprzednia strona"),
("First Page", "Pierwsza strona"),
("Last Page", "Ostatnia strona"),
("Go Back", "Przejdź wstecz"),
("Go Forward", "Przejdź dalej"),
("Object Styles", "Style obiektów"),
("Row", "Wiersz"),
("Rulers", "Linijki"),
+6
View File
@@ -213,6 +213,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Previous Page", "Página anterior"),
("First Page", "Primeira página"),
("Last Page", "Última página"),
("Go Back", "Voltar"),
("Go Forward", "Avançar"),
("Font", "Fonte"),
("Size", "Tamanho"),
("Character", "Caractere"),
@@ -1515,6 +1517,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Convert Text to Table", "Converter texto em tabela"),
("Convert to Button", "Converter em botão"),
("Convert to Form Field", "Converter em campo de formulário"),
("Convert to Note", "Converter em nota"),
("Copy Link(s) To…", "Copiar link(s) para…"),
("Corner Options…", "Opções de canto…"),
("Create Frame", "Criar quadro"),
@@ -1581,6 +1584,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Insert Cross-Reference…", "Inserir referência cruzada…"),
("Insert Math Expression…", "Inserir expressão matemática…"),
("Insert Variable", "Inserir variável"),
("Convert Variable to Text", "Converter variável em texto"),
("Inspect Document", "Inspecionar documento"),
("Interface Color Theme", "Tema de cores da interface"),
("Join the ArtCraft Discord…", "Entrar no Discord do ArtCraft…"),
@@ -1614,6 +1618,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("New Table Style…", "Novo estilo de tabela…"),
("Next Cell", "Próxima célula"),
("Next Document", "Próximo documento"),
("Next Note", "Próxima nota"),
("Next Object Above", "Próximo objeto acima"),
("Next Object Below", "Próximo objeto abaixo"),
("Next Object in Group", "Próximo objeto no grupo"),
@@ -1641,6 +1646,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Preflight Document", "Verificar documento (preflight)"),
("Previous Cell", "Célula anterior"),
("Previous Document", "Documento anterior"),
("Previous Note", "Nota anterior"),
("Previous Object in Group", "Objeto anterior no grupo"),
("Printers", "Impressoras"),
("Proof Colors", "Cores de prova"),
+6
View File
@@ -494,6 +494,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Convert Text to Table", "Преобразовать текст в таблицу"),
("Convert to Button", "Преобразовать в кнопку"),
("Convert to Form Field", "Преобразовать в поле формы"),
("Convert to Note", "Преобразовать в заметку"),
("Conveyor", "Конвейер"),
("Copy Link(s) To…", "Копировать связанные файлы в…"),
("Corner Options…", "Параметры углов…"),
@@ -584,6 +585,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Insert Math Expression…", "Вставить математическое выражение…"),
("Insert Pages…", "Вставить страницы…"),
("Insert Variable", "Вставить переменную"),
("Convert Variable to Text", "Преобразовать переменную в текст"),
("Inspect Document", "Проверить документ"),
("Interface Color Theme", "Цветовая тема интерфейса"),
("Join the ArtCraft Discord…", "Присоединиться к Discord ArtCraft…"),
@@ -619,6 +621,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("New Table Style…", "Новый стиль таблицы…"),
("Next Cell", "Следующая ячейка"),
("Next Document", "Следующий документ"),
("Next Note", "Следующая заметка"),
("Next Object Above", "Следующий объект выше"),
("Next Object Below", "Следующий объект ниже"),
("Next Object in Group", "Следующий объект в группе"),
@@ -652,6 +655,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Preflight Document", "Предпечатная проверка документа"),
("Previous Cell", "Предыдущая ячейка"),
("Previous Document", "Предыдущий документ"),
("Previous Note", "Предыдущая заметка"),
("Previous Object in Group", "Предыдущий объект в группе"),
("Primary Text Frame", "Основная текстовая рамка"),
("Print Booklet…", "Печать буклета…"),
@@ -1468,6 +1472,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Previous Page", "Предыдущая страница"),
("First Page", "Первая страница"),
("Last Page", "Последняя страница"),
("Go Back", "Назад"),
("Go Forward", "Вперёд"),
("Object Styles", "Стили объектов"),
("Row", "Строка"),
("Column", "Столбец"),
+6
View File
@@ -638,6 +638,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Convert Text to Table", "Перетворити текст на таблицю"),
("Convert to Button", "Перетворити на кнопку"),
("Convert to Form Field", "Перетворити на поле форми"),
("Convert to Note", "Перетворити на примітку"),
("Conveyor", "Конвеєр"),
("Copy Link(s) To…", "Копіювати пов’язані файли до…"),
("Corner Options…", "Параметри кутів…"),
@@ -728,6 +729,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Insert Math Expression…", "Вставити математичний вираз…"),
("Insert Pages…", "Вставити сторінки…"),
("Insert Variable", "Вставити змінну"),
("Convert Variable to Text", "Перетворити змінну на текст"),
("Inspect Document", "Перевірити документ"),
("Interface Color Theme", "Колірна тема інтерфейсу"),
("Join the ArtCraft Discord…", "Приєднатися до Discord ArtCraft…"),
@@ -763,6 +765,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("New Table Style…", "Новий стиль таблиці…"),
("Next Cell", "Наступна клітинка"),
("Next Document", "Наступний документ"),
("Next Note", "Наступна примітка"),
("Next Object Above", "Наступний об’єкт вище"),
("Next Object Below", "Наступний об’єкт нижче"),
("Next Object in Group", "Наступний об’єкт у групі"),
@@ -796,6 +799,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Preflight Document", "Перевірити документ перед друком"),
("Previous Cell", "Попередня клітинка"),
("Previous Document", "Попередній документ"),
("Previous Note", "Попередня примітка"),
("Previous Object in Group", "Попередній об’єкт у групі"),
("Primary Text Frame", "Основний текстовий фрейм"),
("Print Booklet…", "Друк буклета…"),
@@ -1651,6 +1655,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Previous Page", "Попередня сторінка"),
("First Page", "Перша сторінка"),
("Last Page", "Остання сторінка"),
("Go Back", "Назад"),
("Go Forward", "Вперед"),
("Object Styles", "Стилі об’єктів"),
("Row", "Рядок"),
("Column", "Стовпець"),
+19
View File
@@ -21,6 +21,7 @@ pub mod menus;
mod modal_drag;
mod new_document;
mod object_control;
mod page_history;
mod panel_docking;
pub mod panels;
mod prefs;
@@ -194,6 +195,12 @@ pub struct UiState {
/// The view (by its `views` key) Presentation was entered from; leaving puts it back.
#[serde(skip)]
pub presentation_view: Option<(u64, View)>,
/// The page in that view and the place on it, for Go Back after leaving Presentation.
#[serde(skip)]
pub presentation_page: Option<(designcraft_doc::ids::PageId, designcraft_geom::Vec2)>,
/// That view's Go Back / Go Forward history; leaving puts it back with one jump added.
#[serde(skip)]
pub(crate) presentation_history: Option<page_history::PageHistory>,
pub frame_edges: bool,
pub rulers: bool,
pub guides: bool,
@@ -371,6 +378,8 @@ impl Default for UiState {
screen_mode: ScreenMode::Normal,
presentation_return: ScreenMode::Normal,
presentation_view: None,
presentation_page: None,
presentation_history: None,
frame_edges: true,
rulers: true,
guides: true,
@@ -572,6 +581,8 @@ pub struct DesignApp {
pub ui: UiState,
pub services: Services,
pub views: HashMap<u64, View>,
/// Layout ▸ Go Back / Go Forward, by view (like `views`).
pub(crate) page_history: HashMap<u64, page_history::PageHistory>,
/// The canvas cache and rect of the pane in `pane` (the other pane's are in `other_pane`).
pub canvas: CanvasCache,
pub canvas_rect: Option<egui::Rect>,
@@ -675,6 +686,7 @@ impl DesignApp {
ui: UiState::default(),
services,
views: HashMap::new(),
page_history: HashMap::new(),
canvas: CanvasCache::new(),
canvas_rect: None,
split: false,
@@ -739,6 +751,13 @@ impl DesignApp {
let k = self.view_key()?;
Some(self.views.entry(k).or_default())
}
pub(crate) fn page_history(&self) -> Option<&page_history::PageHistory> {
self.page_history.get(&self.view_key()?)
}
pub(crate) fn page_history_mut(&mut self) -> Option<&mut page_history::PageHistory> {
let k = self.view_key()?;
Some(self.page_history.entry(k).or_default())
}
/// Make `pane` current: its canvas cache and rect move in.
pub fn switch_pane(&mut self, pane: u8) {
if pane == self.pane {
+470 -10
View File
@@ -43,7 +43,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
("app.saveSwatches", "Save Swatches for Exchange…", None, "{path?} — write the colour swatches as .ase"),
("app.exportPng", "Export Page as PNG…", Some("Cmd+E"), "{}"),
("app.exportIdml", "Export IDML…", None, "{path?} — InDesign Markup (IDML) package"),
("app.exportEpub", "Export EPUB…", None, "{path?} — reflowable EPUB 3 (with the first page as its cover)"),
(
"app.exportEpub",
"Export EPUB…",
None,
"{path?} — reflowable EPUB 3 (with the first page as its cover); without a path, EPUB Export Options (Split Document) and then the save dialog",
),
(
"app.exportInteractivePdf",
"Export PDF (Interactive)…",
@@ -251,6 +256,8 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
("view.nextSpread", "Next Spread", Some("Alt+PageDown"), "{} — centre the spread after the one in view"),
("view.previousSpread", "Previous Spread", Some("Alt+PageUp"), "{} — centre the spread before the one in view"),
("view.goToPage", "Go to Page…", Some("Cmd+J"), "{page: number (position) | \"name\" (section page name, or \"+n\" for a position)}"),
("view.goBack", "Go Back", Some("Cmd+PageUp"), "{} — show the page viewed before this one in the window"),
("view.goForward", "Go Forward", Some("Cmd+PageDown"), "{} — show the page viewed after this one (after Go Back)"),
(
"window.panel",
"Show Panel",
@@ -441,6 +448,8 @@ pub const MENUS: &[(&str, &[&str])] = &[
"ui:view.nextSpread",
"ui:view.previousSpread",
"ui:view.goToPage",
"ui:view.goBack",
"ui:view.goForward",
"-",
"cmd:layout.section",
"cmd:toc.generate",
@@ -498,7 +507,11 @@ pub const MENUS: &[(&str, &[&str])] = &[
"-",
">Notes",
"cmd:note.new",
"cmd:note.convertFromText",
"ui:window.panel|Notes|{\"panel\": \"notes\"}",
"-",
"cmd:note.previous",
"cmd:note.next",
"<",
">Track Changes",
"cmd:changes.track",
@@ -521,6 +534,7 @@ pub const MENUS: &[(&str, &[&str])] = &[
">Text Variables",
"cmd:variables.define",
"cmd:variables.insert",
"cmd:variables.convertToText",
"<",
"-",
">Bulleted & Numbered Lists",
@@ -913,6 +927,7 @@ pub const MENUS: &[(&str, &[&str])] = &[
"ui:window.panel|Conditional Text|{\"panel\": \"conditions\"}",
"ui:window.panel|Glyphs|{\"panel\": \"glyphs\"}",
"ui:window.panel|Paragraph|{\"panel\": \"paragraph\"}",
"ui:window.panel|Story|{\"panel\": \"story\"}",
"ui:window.panel|Table|{\"panel\": \"table\"}",
"ui:app.tabsPanel",
"ui:window.panel|Typography|{\"panel\": \"typography\"}",
@@ -966,7 +981,8 @@ pub const CLASSIC_GROUPS: &[&[&str]] = &[
];
/// The Typography preset's available panels in the order observed in InDesign's workspace.
/// CC Libraries and the separate Story panel have no corresponding DesignCraft panel.
/// CC Libraries has no corresponding DesignCraft panel, and where the Story panel sits in it is
/// not recorded, so these groups leave it out.
pub const TYPOGRAPHY_GROUPS: &[&[&str]] = &[
&["pages", "layers", "swatches"],
&["glyphs", "textWrap"],
@@ -1106,10 +1122,11 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
}
"app.exportPng" => export_png(app, p),
"app.exportIdml" => export_idml(app, p),
// From the menu, EPUB Export Options first; with a path, straight to the file.
"app.exportEpub" if p.get("path").is_some() => export_epub(app, p),
"app.exportEpub" => {
let mut q = if p.is_object() { p.clone() } else { json!({}) };
q["cover"] = json!(true);
export_bytes(app, &q, "epub", "file.exportEpub")
crate::dialogs::open_epub_export(app);
Ok(Value::Null)
}
"app.exportInteractivePdf" => {
let mut q = if p.is_object() { p.clone() } else { json!({}) };
@@ -1593,7 +1610,10 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
Ok(v) => v,
Err(e) => return Some(Err(e.to_string())),
};
app.ui.dialog = Some(crate::dialogs::Dialog::new("layerOptions", json!({"layers": layers})));
app.ui.dialog = Some(crate::dialogs::Dialog::new(
"layerOptions",
json!({"useDefaults": !layers.as_array().and_then(|a| a.first()).and_then(|l| l["overridden"].as_bool()).unwrap_or(false), "layers": layers}),
));
Ok(Value::Null)
}
"view.rotateSpread" => {
@@ -1664,6 +1684,10 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
}
Ok(Value::Null)
}
"view.goBack" | "view.goForward" => {
crate::canvas::go_through_history(app, if id == "view.goBack" { -1 } else { 1 });
Ok(Value::Null)
}
"view.nextSpread" | "view.previousSpread" => {
crate::canvas::step_spread(app, if id == "view.nextSpread" { 1 } else { -1 });
Ok(Value::Null)
@@ -2007,13 +2031,28 @@ fn set_screen_mode(app: &mut DesignApp, mode: ScreenMode) {
app.ui.presentation_return = app.ui.screen_mode;
// Presentation fits the spread to the screen; leaving it puts the user's view back.
app.ui.presentation_view = app.view_key().zip(app.view().copied());
app.ui.presentation_page = crate::canvas::page_in_view(app);
app.ui.presentation_history = app.page_history().cloned();
}
if mode != ScreenMode::Presentation
&& presenting
&& let Some((k, v)) = app.ui.presentation_view.take()
&& let Some(view) = app.views.get_mut(&k)
&& app.views.contains_key(&k)
{
*view = v;
// Go Back sees the presentation as one jump to the last spread presented, and putting the
// view back as a jump from there: the spreads in between would otherwise have replaced the
// place the view was at with the presentation's.
let shown = app.ui.presentation_page.take().zip(crate::canvas::page_in_view(app)).filter(|_| app.view_key() == Some(k));
let h = app.page_history.entry(k).or_default();
*h = app.ui.presentation_history.take().unwrap_or_default();
if let Some(((from, at), (to, offset))) = shown {
h.leave(from, at);
h.visit(to, offset);
h.visit(from, at);
}
if let Some(view) = app.views.get_mut(&k) {
*view = v;
}
}
app.ui.screen_mode = mode;
}
@@ -2080,6 +2119,16 @@ fn export_idml(app: &mut DesignApp, p: &Value) -> Result<Value, String> {
/// Export EPUB / Text: ask for a path (default extension `ext`), run `cmd` without one and write
/// what it returns (`base64` or `text`) with the platform writer.
/// File › Export EPUB…: export through `file.exportEpub` (the first page as its cover) to `path`,
/// else to a file the user picks.
pub(crate) fn export_epub(app: &mut DesignApp, p: &Value) -> Result<Value, String> {
let mut q = if p.is_object() { p.clone() } else { json!({}) };
if let Some(o) = q.as_object_mut() {
o.entry("cover").or_insert(json!(true));
}
export_bytes(app, &q, "epub", "file.exportEpub")
}
fn export_bytes(app: &mut DesignApp, p: &Value, ext: &str, cmd: &str) -> Result<Value, String> {
let st = app.session.active().ok_or("no document")?;
let path = match p.get("path").and_then(Value::as_str) {
@@ -2090,7 +2139,11 @@ fn export_bytes(app: &mut DesignApp, p: &Value, ext: &str, cmd: &str) -> Result<
}
};
let Some(path) = path else { return Ok(Value::Null) };
let mut params = json!({});
// The caller's options go to the engine command; the path is ours.
let mut params = match p {
Value::Object(o) => Value::Object(o.iter().filter(|(k, _)| k.as_str() != "path").map(|(k, v)| (k.clone(), v.clone())).collect()),
_ => json!({}),
};
if cmd == "file.exportText" {
let txt = path.to_ascii_lowercase().ends_with(".txt");
params["format"] = json!(if txt { "txt" } else { "rtf" });
@@ -2506,6 +2559,9 @@ pub fn menu_enabled(app: &DesignApp, id: &str) -> bool {
if id == "guide.selectAll" && (!app.ui.guides || app.ui.guides_locked) {
return false;
}
if id == "view.goBack" || id == "view.goForward" {
return crate::canvas::can_go_through_history(app, if id == "view.goBack" { -1 } else { 1 });
}
if ui_label(id).is_some() {
return app.session.active().is_some() || id.starts_with("app.") || id.starts_with("window.") || id.starts_with("help.");
}
@@ -3316,13 +3372,53 @@ pub fn quick_apply_items(session: &designcraft_engine::Session, query: &str, cjk
out
}
/// UI commands search leaves out: they only run with a parameter search can't supply and have no
/// dialog to ask for it, or without one they reset a setting. Each stays in the menu, panel or
/// dialog that gives it the value (the workspace menus, Keyboard Shortcuts, Preferences…).
const NOT_SEARCHED: &[&str] = &[
// The workspace to switch to or delete.
"window.workspace",
"window.deleteWorkspace",
// A command and its shortcut, a menu item, a shortcut set; Reset Shortcuts clears every
// custom shortcut, and the Keyboard Shortcuts dialog offers it.
"window.setShortcut",
"window.resetShortcuts",
"window.importShortcuts",
"window.hideMenuItem",
// Without a value these reset the interface to 100% and the dark theme.
"window.uiScale",
"window.brightness",
// A panel, and where it goes.
"window.floatPanel",
"window.dockPanel",
"window.panel.layout",
"window.panel.move",
"window.panel.float",
"window.panel.dock",
"window.panel.activate",
"window.panel.close",
"window.panel.closeGroup",
// A window edge, an app page, a recent file, a swatch or color group.
"window.resize",
"help.app",
"app.openRecent",
"app.swatchOptionsDialog",
"app.colorGroupOptionsDialog",
// Without a value these clear the flattener preset, go to Normal screen mode or 100% zoom.
"app.flattener",
"view.screenMode",
"view.zoom",
// Where to pin the Contextual Task Bar.
"window.taskBarPin",
];
/// The commands Quick Apply and the application bar's search find: (id, label, default shortcut).
fn quick_commands() -> impl Iterator<Item = (&'static str, &'static str, Option<&'static str>)> {
designcraft_engine::command_specs()
.iter()
.filter(|c| !c.menu.is_empty() || c.shortcut.is_some())
.map(|c| (c.id, c.label, c.shortcut))
.chain(UI_COMMANDS.iter().map(|c| (c.0, c.1, c.2)))
.chain(UI_COMMANDS.iter().filter(|c| !NOT_SEARCHED.contains(&c.0)).map(|c| (c.0, c.1, c.2)))
}
/// Whether any of `texts` contains the lowercased query (an empty query matches everything).
@@ -4237,6 +4333,272 @@ mod tests {
assert_eq!(v.zoom, zoom);
}
#[test]
fn go_back_and_go_forward_retrace_the_pages_viewed() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", &json!({"pages": 7, "facingPages": true})).unwrap();
let ctx = egui::Context::default();
let frame = |app: &mut crate::DesignApp, events: Vec<egui::Event>| {
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))),
events,
..Default::default()
};
let mut out = ctx.run_ui(input, |ui| {
app.logic(&ui.ctx().clone());
app.ui(ui);
});
out.textures_delta.clear();
};
let key = |k: egui::Key, modifiers: egui::Modifiers| {
vec![
egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers },
egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers },
]
};
let page = |app: &crate::DesignApp| crate::canvas::current_page(app).unwrap();
let can = |app: &crate::DesignApp| (menu_enabled(app, "view.goBack"), menu_enabled(app, "view.goForward"));
frame(&mut app, vec![]);
frame(&mut app, vec![]);
// In the Layout menu after Go to Page.
let layout = MENUS.iter().find(|m| m.0 == "Layout").unwrap().1;
let at = |id: &str| layout.iter().position(|e| *e == format!("ui:{id}")).unwrap();
assert_eq!(at("view.goBack"), at("view.goToPage") + 1);
assert_eq!(at("view.goForward"), at("view.goBack") + 1);
assert_eq!(can(&app), (false, false), "one page viewed: nowhere to go");
let cmd = egui::Modifiers { command: true, ctrl: true, ..Default::default() };
let cmd_shift = egui::Modifiers { shift: true, ..cmd };
let shift = egui::Modifiers::SHIFT;
// Pages viewed: 1, 5, 7.
app.run("view.goToPage", json!({"page": 5})).unwrap();
frame(&mut app, vec![]);
frame(&mut app, key(egui::Key::PageDown, cmd_shift));
assert_eq!(page(&app), 6);
frame(&mut app, vec![]);
assert_eq!(can(&app), (true, false));
frame(&mut app, key(egui::Key::PageUp, cmd));
assert_eq!(page(&app), 4, "Go Back");
frame(&mut app, key(egui::Key::PageUp, cmd));
assert_eq!(page(&app), 0, "Go Back again");
frame(&mut app, vec![]);
assert_eq!(can(&app), (false, true), "back at the first page viewed");
frame(&mut app, key(egui::Key::PageDown, cmd));
assert_eq!(page(&app), 4, "Go Forward");
frame(&mut app, key(egui::Key::PageDown, cmd));
assert_eq!(page(&app), 6, "Go Forward again");
frame(&mut app, vec![]);
assert_eq!(can(&app), (true, false));
// After going back, viewing another page drops the pages ahead.
frame(&mut app, key(egui::Key::PageUp, cmd));
assert_eq!(page(&app), 4);
frame(&mut app, key(egui::Key::PageDown, shift));
assert_eq!(page(&app), 5, "Next Page");
frame(&mut app, vec![]);
assert_eq!(can(&app), (true, false), "nothing ahead after a new page");
frame(&mut app, key(egui::Key::PageDown, cmd));
assert_eq!(page(&app), 5, "Go Forward does nothing");
frame(&mut app, key(egui::Key::PageUp, cmd));
assert_eq!(page(&app), 4, "Go Back returns to the page before Next Page");
}
#[test]
fn go_back_waits_for_the_view_to_be_fitted() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", &json!({"pages": 7, "facingPages": true})).unwrap();
let ctx = egui::Context::default();
let frame = |app: &mut crate::DesignApp| {
let input =
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), ..Default::default() };
let mut out = ctx.run_ui(input, |ui| {
app.logic(&ui.ctx().clone());
app.ui(ui);
});
out.textures_delta.clear();
};
frame(&mut app);
frame(&mut app);
app.run("view.goToPage", json!({"page": 5})).unwrap();
frame(&mut app);
assert!(menu_enabled(&app, "view.goBack"));
// Leaving parent pages through the Pages panel: the canvas refits on the next frame.
app.view_mut().unwrap().fitted = false;
assert!(!menu_enabled(&app, "view.goBack"), "the refit would replace where Go Back went");
crate::canvas::go_through_history(&mut app, -1);
frame(&mut app);
assert!(menu_enabled(&app, "view.goBack"), "the unfitted Go Back used no history entry");
}
#[test]
fn next_spread_at_the_last_spread_is_no_jump() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", &json!({"pages": 7, "facingPages": true})).unwrap();
let ctx = egui::Context::default();
let frame = |app: &mut crate::DesignApp, events: Vec<egui::Event>| {
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))),
events,
..Default::default()
};
let mut out = ctx.run_ui(input, |ui| {
app.logic(&ui.ctx().clone());
app.ui(ui);
});
out.textures_delta.clear();
};
let key = |k: egui::Key, modifiers: egui::Modifiers| {
vec![
egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers },
egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers },
]
};
let page = |app: &crate::DesignApp| crate::canvas::current_page(app).unwrap();
let cmd = egui::Modifiers { command: true, ctrl: true, ..Default::default() };
let alt = egui::Modifiers::ALT;
frame(&mut app, vec![]);
frame(&mut app, vec![]);
// Pages 1 | 2-3 | 4-5 | 6-7: page 6 is the left page of the last spread.
app.run("view.goToPage", json!({"page": 6})).unwrap();
frame(&mut app, vec![]);
assert_eq!(page(&app), 5);
// Next Spread has no spread to go to; centring the last one moves the view to its fold.
frame(&mut app, key(egui::Key::PageDown, alt));
frame(&mut app, key(egui::Key::PageDown, alt));
assert_ne!(page(&app), 5, "the view moved to the right page");
frame(&mut app, key(egui::Key::PageUp, cmd));
assert_eq!(page(&app), 0, "Go Back returns to the page before Go to Page");
assert!(!menu_enabled(&app, "view.goBack"));
}
#[test]
fn go_back_returns_to_the_view_a_jump_left_and_scrolling_adds_no_entry() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", &json!({"pages": 7, "facingPages": true})).unwrap();
let ctx = egui::Context::default();
let frame = |app: &mut crate::DesignApp, events: Vec<egui::Event>| {
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))),
events,
..Default::default()
};
let mut out = ctx.run_ui(input, |ui| {
app.logic(&ui.ctx().clone());
app.ui(ui);
});
out.textures_delta.clear();
};
let key = |k: egui::Key, modifiers: egui::Modifiers| {
vec![
egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers },
egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers },
]
};
let spread = |app: &crate::DesignApp| {
let st = app.session.active().unwrap();
crate::canvas::current_slot(app, &designcraft_tools::layout::CanvasLayout::new(&st.doc, false)).unwrap()
};
let cmd = egui::Modifiers { command: true, ctrl: true, ..Default::default() };
let alt = egui::Modifiers::ALT;
frame(&mut app, vec![]);
frame(&mut app, vec![]);
// Pages 1 | 2-3 | 4-5 | 6-7. Spread 2-3 fitted: the view centre is on the fold.
frame(&mut app, key(egui::Key::PageDown, alt));
app.run("view.fitSpread", json!({})).unwrap();
frame(&mut app, vec![]);
let before = *app.view().unwrap();
frame(&mut app, key(egui::Key::PageDown, alt));
assert_eq!(spread(&app), 2, "Next Spread");
frame(&mut app, key(egui::Key::PageUp, cmd));
let v = *app.view().unwrap();
assert!((v.origin - before.origin).hypot() < 1e-6, "Go Back shows the spread as it was: {:?} vs {:?}", v.origin, before.origin);
assert_eq!(v.zoom, before.zoom);
// Zoomed in after the jump: Go Back keeps the magnification in use, as InDesign's does, and
// centres the same place.
let centre = |app: &crate::DesignApp| {
let (v, r) = (app.view().unwrap(), app.canvas_rect.unwrap());
designcraft_geom::Point::new(v.origin.x + r.width() as f64 / 2.0 / v.zoom, v.origin.y + r.height() as f64 / 2.0 / v.zoom)
};
let place = centre(&app);
frame(&mut app, key(egui::Key::PageDown, alt));
app.run("view.zoomIn", json!({})).unwrap();
let zoomed = app.view().unwrap().zoom;
assert!(zoomed > before.zoom);
frame(&mut app, key(egui::Key::PageUp, cmd));
assert_eq!(app.view().unwrap().zoom, zoomed, "the zoom in use is kept");
assert!((centre(&app) - place).hypot() < 1e-6, "the same place: {:?} vs {place:?}", centre(&app));
// Scrolling down two spreads is not a jump: Go Forward keeps its page, and Go Back skips
// from where the scroll ended straight to the page before the first jump.
let slots = designcraft_tools::layout::CanvasLayout::new(&app.session.active().unwrap().doc, false).slots;
app.view_mut().unwrap().origin.y += slots[3].bounds.center().y - slots[1].bounds.center().y;
frame(&mut app, vec![]);
assert_eq!(spread(&app), 3);
assert!(menu_enabled(&app, "view.goForward"), "scrolling kept the page ahead");
frame(&mut app, key(egui::Key::PageUp, cmd));
assert_eq!(spread(&app), 0, "Go Back");
assert!(!menu_enabled(&app, "view.goBack"), "the scroll added no entry");
frame(&mut app, key(egui::Key::PageDown, cmd));
assert_eq!(spread(&app), 3, "Go Forward returns to where the scroll ended");
frame(&mut app, key(egui::Key::PageDown, cmd));
assert_eq!(spread(&app), 2);
}
#[test]
fn go_back_after_presentation_returns_to_the_last_spread_presented() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", &json!({"pages": 7, "facingPages": true})).unwrap();
let ctx = egui::Context::default();
let frame = |app: &mut crate::DesignApp, events: Vec<egui::Event>| {
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))),
events,
..Default::default()
};
let mut out = ctx.run_ui(input, |ui| {
app.logic(&ui.ctx().clone());
app.ui(ui);
});
out.textures_delta.clear();
};
let key = |k: egui::Key, modifiers: egui::Modifiers| {
vec![
egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers },
egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers },
]
};
let spread = |app: &crate::DesignApp| {
let st = app.session.active().unwrap();
crate::canvas::current_slot(app, &designcraft_tools::layout::CanvasLayout::new(&st.doc, false)).unwrap()
};
let cmd = egui::Modifiers { command: true, ctrl: true, ..Default::default() };
frame(&mut app, vec![]);
frame(&mut app, vec![]);
// Scrolled off the page's centre, so the place Go Back returns to is not just the page.
let view = app.view_mut().unwrap();
view.origin.x += 40.0;
view.origin.y += 60.0;
frame(&mut app, vec![]);
assert_eq!(spread(&app), 0);
let before = *app.view().unwrap();
// Presentation goes on to spreads 1 and 2; leaving it puts spread 0 back as it was.
app.run("view.screenMode", json!({"mode": "presentation"})).unwrap();
frame(&mut app, vec![]);
frame(&mut app, key(egui::Key::ArrowRight, egui::Modifiers::NONE));
frame(&mut app, key(egui::Key::ArrowRight, egui::Modifiers::NONE));
assert_eq!(spread(&app), 2);
app.run("view.screenMode", json!({"mode": "normal"})).unwrap();
frame(&mut app, vec![]);
assert_eq!(spread(&app), 0);
assert!(!menu_enabled(&app, "view.goForward"));
frame(&mut app, key(egui::Key::PageUp, cmd));
assert_eq!(spread(&app), 2, "Go Back shows the last spread presented");
frame(&mut app, key(egui::Key::PageUp, cmd));
assert_eq!(spread(&app), 0, "Go Back again, the view Presentation was entered from");
let v = *app.view().unwrap();
assert!((v.origin - before.origin).hypot() < 1e-6, "where it was: {:?} vs {:?}", v.origin, before.origin);
assert!(!menu_enabled(&app, "view.goBack"), "the presentation is one jump: spread 1 isn't kept");
frame(&mut app, key(egui::Key::PageDown, cmd));
assert_eq!(spread(&app), 2, "Go Forward");
}
#[test]
fn presentation_shows_one_spread_and_goes_through_them_with_keys_and_clicks() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
@@ -4924,6 +5286,104 @@ mod tests {
assert!(quick_apply_items(&s, "preferences", false).iter().any(|i| i.id == "app.preferences"));
}
#[test]
fn searched_ui_commands_run_without_parameters_and_keep_preferences() {
// Without a value these reset a setting or need a place the test host always has.
let searched: Vec<&str> = quick_commands().map(|c| c.0).collect();
for id in ["app.flattener", "view.screenMode", "view.zoom", "window.taskBarPin"] {
assert!(!searched.contains(&id), "{id} is searched");
}
// A placed PDF: a graphic with layers.
let mut scratch = designcraft_engine::Session::new();
scratch.execute("file.new", &json!({})).unwrap();
let pdf = scratch.execute("file.exportPdf", &json!({})).unwrap()["base64"].clone();
let contexts = ["nothing selected", "text insertion point", "text frame selected", "table cell", "placed PDF selected"];
// A two-page document, a saved workspace and non-default preferences, a host with window
// controls, and file dialogs that are dismissed.
let setup = |context: usize| {
let services = crate::Services {
pick_open: Some(Box::new(|_| None)),
pick_save: Some(Box::new(|_| None)),
clipboard_text: Some(Box::new(|| None)),
..Default::default()
};
let mut app = DesignApp::new(designcraft_engine::Session::new(), services);
app.custom_titlebar = true;
app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 700.0)));
app.run("file.new", json!({"pages": 2})).unwrap();
app.session.prefs.recent_files.push("/nowhere/recent.dcraft".into());
app.ui.shortcuts.insert("view.rulers".into(), "Ctrl+Alt+R".into());
app.ui.hidden_menu_items.push("view.guides".into());
app.ui.ui_scale = 1.25;
app.ui.brightness = crate::theme::Brightness::Light;
app.ui.task_bar_at = Some([200.0, 200.0]);
run_ui(&mut app, "window.newWorkspace", &json!({"name": "Mine"})).unwrap().unwrap();
match context {
1 => {
app.run("frame.create", json!({"rect": [72, 72, 400, 400], "content": "text", "text": "One", "caret": true})).unwrap();
}
2 => {
let f = app.run("frame.create", json!({"rect": [72, 72, 400, 400], "content": "text", "text": "One"})).unwrap()["id"].clone();
app.run("selection.set", json!({"ids": [f]})).unwrap();
}
3 => {
app.run("frame.create", json!({"rect": [72, 72, 400, 400], "content": "text", "caret": true})).unwrap();
app.run("table.insert", json!({"rows": 2, "cols": 2})).unwrap();
}
4 => {
let f = app.run("frame.create", json!({"rect": [72, 72, 400, 400]})).unwrap()["id"].clone();
app.run("file.place", json!({"base64": pdf, "name": "a.pdf", "frame": f})).unwrap();
app.run("selection.set", json!({"ids": [f]})).unwrap();
}
_ => {}
}
app.ui.dialog = None;
app
};
let prefs = |app: &DesignApp| {
let u = &app.ui;
json!({
"shortcuts": u.shortcuts, "hiddenMenuItems": u.hidden_menu_items, "uiScale": u.ui_scale,
"brightness": u.brightness, "workspace": u.workspace, "customWorkspaces": u.custom_workspaces,
"docking": u.docking, "dockingHidden": u.docking_hidden, "floating": u.floating,
})
};
let quick = quick_apply_items(&setup(0).session, "workspace", false);
assert!(quick.iter().any(|i| i.id == "window.newWorkspace"));
// Deleting needs a workspace name: the application bar's workspace menu lists them.
assert!(!quick.iter().any(|i| i.id == "window.deleteWorkspace"));
// Each command runs as Quick Apply runs an entry. One that needs a selection may fail
// without it, but runs in one of the contexts; none changes a preference it wasn't asked to.
// Opening a panel changes where the panels are, as it should.
let opens_a_panel = ["app.tabsPanel", "app.characterStylesPanel", "app.paragraphStylesPanel"];
let arrangement = ["docking", "dockingHidden", "floating"];
let mut failed = Vec::new();
for (id, ..) in quick_commands().filter(|(id, ..)| UI_COMMANDS.iter().any(|c| c.0 == *id)) {
let mut errors = Vec::new();
for (i, context) in contexts.iter().enumerate() {
let mut app = setup(i);
let before = prefs(&app);
match app.run(id, json!({})) {
Err(e) => errors.push(format!("{context}: {e}")),
Ok(_) => {
let after = prefs(&app);
let changed: Vec<&String> =
before.as_object().unwrap().iter().filter(|(k, v)| after.get(k.as_str()) != Some(*v)).map(|(k, _)| k).collect();
let changed: Vec<&String> =
changed.into_iter().filter(|k| !(opens_a_panel.contains(&id) && arrangement.contains(&k.as_str()))).collect();
if !changed.is_empty() {
failed.push(format!("{id} ({context}): changed {changed:?}"));
}
}
}
}
if errors.len() == contexts.len() {
failed.push(format!("{id}: {errors:?}"));
}
}
assert!(failed.is_empty(), "{failed:#?}");
}
#[test]
fn distribute_rows_menu_dispatches_an_undoable_edit() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff. Show more