Merge branch 'main' into codex/hungarian-localization

This commit is contained in:
macadminHU
2026-10-23 19:32:53 +02:00
committed by GitHub
123 changed files with 4711 additions and 727 deletions
+8 -4
View File
@@ -45,10 +45,10 @@ jobs:
persist-credentials: false
- name: Install Linux build dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev \
uses: storytold/craft-common-actions/actions/install-apt-packages@main
with:
packages: >-
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
- name: Install Rust (stable + clippy, rustfmt, wasm32)
@@ -57,6 +57,10 @@ jobs:
--target wasm32-unknown-unknown
rustup default stable
- uses: taiki-e/install-action@f7e5d7c961414b23f5b25b2da9294395d08513ad # v2.87.26
with:
tool: cargo-nextest@0.9.145
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
+6 -6
View File
@@ -40,12 +40,12 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| `docs/images/page-4.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI |
| `docs/images/page-cover.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI |
| `docs/images/ui-spread.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI |
| `crates/render/tests/golden/drop-shadow-spread.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
| `crates/render/tests/golden/drop-shadow-text.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
| `crates/render/tests/golden/drop-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
| `crates/render/tests/golden/feather.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
| `crates/render/tests/golden/inner-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
| `crates/render/tests/golden/outer-glow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
| `crates/render/tests/golden/drop-shadow-spread.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
| `crates/render/tests/golden/drop-shadow-text.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
| `crates/render/tests/golden/drop-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
| `crates/render/tests/golden/feather.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
| `crates/render/tests/golden/inner-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
| `crates/render/tests/golden/outer-glow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
| `assets/app-icon/LICENSE.txt` | (licence text) | this project | — | Licence for the app icon |
| `assets/app-icon/designcraft-1024.png` | DesignCraft project owner (drawn in ArtCraft) | rendered from `assets/app-icon/designcraft.svg` by `packaging/icons.sh` | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | App icon: calico cat in a polka-dot scarf (see `assets/app-icon/README.md`) |
| `assets/app-icon/designcraft-macos-512.png` | DesignCraft project owner (drawn in ArtCraft) | rendered from `assets/app-icon/designcraft.svg` by `packaging/icons.sh` | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | App icon: calico cat in a polka-dot scarf (see `assets/app-icon/README.md`) |
Generated
+21 -21
View File
@@ -942,7 +942,7 @@ dependencies = [
[[package]]
name = "designcraft"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"arboard",
"designcraft-engine",
@@ -964,7 +964,7 @@ dependencies = [
[[package]]
name = "designcraft-cli"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -980,7 +980,7 @@ dependencies = [
[[package]]
name = "designcraft-color"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"craft-cms",
"kurbo",
@@ -992,7 +992,7 @@ dependencies = [
[[package]]
name = "designcraft-compose"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"designcraft-color",
"designcraft-doc",
@@ -1012,7 +1012,7 @@ dependencies = [
[[package]]
name = "designcraft-doc"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"chrono",
"designcraft-color",
@@ -1025,7 +1025,7 @@ dependencies = [
[[package]]
name = "designcraft-engine"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"caseless",
"craft-atomic",
@@ -1064,7 +1064,7 @@ dependencies = [
[[package]]
name = "designcraft-epub"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"chrono",
"designcraft-color",
@@ -1074,7 +1074,7 @@ dependencies = [
[[package]]
name = "designcraft-fonts"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"core-text",
"harfrust 0.13.3",
@@ -1087,7 +1087,7 @@ dependencies = [
[[package]]
name = "designcraft-format"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"designcraft-doc",
"designcraft-images",
@@ -1099,7 +1099,7 @@ dependencies = [
[[package]]
name = "designcraft-geom"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"flo_curves",
"kurbo",
@@ -1112,7 +1112,7 @@ dependencies = [
[[package]]
name = "designcraft-idml"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1127,7 +1127,7 @@ dependencies = [
[[package]]
name = "designcraft-images"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"craft-cms",
"designcraft-color",
@@ -1142,7 +1142,7 @@ dependencies = [
[[package]]
name = "designcraft-mcp"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"designcraft-engine",
"designcraft-geom",
@@ -1155,7 +1155,7 @@ dependencies = [
[[package]]
name = "designcraft-pdf"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"chrono",
"craft-pdf",
@@ -1178,7 +1178,7 @@ dependencies = [
[[package]]
name = "designcraft-pptx"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"chrono",
"designcraft-color",
@@ -1196,7 +1196,7 @@ dependencies = [
[[package]]
name = "designcraft-render"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"craft-pdf",
"designcraft-color",
@@ -1215,7 +1215,7 @@ dependencies = [
[[package]]
name = "designcraft-textimport"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"designcraft-doc",
"quick-xml",
@@ -1224,7 +1224,7 @@ dependencies = [
[[package]]
name = "designcraft-tools"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1236,7 +1236,7 @@ dependencies = [
[[package]]
name = "designcraft-ui-egui"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"craft-ui",
"designcraft-color",
@@ -1263,7 +1263,7 @@ dependencies = [
[[package]]
name = "designcraft-web"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"designcraft-engine",
"designcraft-format",
@@ -5814,7 +5814,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"serde_json",
]
+6 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"]
[workspace.package]
version = "0.11.0"
version = "0.12.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -103,11 +103,16 @@ todo = "warn"
too_many_arguments = "allow"
type_complexity = "allow"
# Line tables keep file and line in panics and backtraces; full debug info
# for every test binary and dependency would multiply the target dir size.
# The test profile inherits both settings.
[profile.dev.package."*"]
opt-level = 2
debug = false
[profile.dev]
opt-level = 1
debug = "line-tables-only"
[profile.release]
opt-level = 3
+4
View File
@@ -8,6 +8,10 @@
</p>
<p align="center">
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.designcraft.png" width="128" height="128" alt="DesignCraft icon: an engraved cat in a polka-dot scarf on a green tile">
</p>
<h1 align="center">DesignCraft</h1>
<p align="center">
+9 -1
View File
@@ -33,7 +33,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Index: page references (topic from the selection or up to 4 levels, sort keys; current page, to end of story, next n paragraphs, suppressed, See / See also), Generate / Update Index (section headings, nested or run-in, page ranges merged, Index Title / Section Head / Level 1–4 styles); IDML export/import as InDesign topics, page references and topic cross-references (verified in InDesign 2026); `index.*` commands.
- Text clipboard keeps formatting (character/paragraph formats, footnotes, cross-references, index markers, tables) and falls back to plain text when the system clipboard changed; Paste without Formatting (⇧⌘V); Change Case (UPPERCASE, lowercase, Title Case, Sentence case — formatting kept); Type › Insert Special Character / White Space / Break Character submenus.
- Anchored objects: items flow in the text inline (on the baseline, with Y offset; lines grow to fit) or above the line (left/center/right, space before/after); paste copied items into text to anchor them; Anchored Object Options and Release (back onto the page where shown); rendered, exported to PDF and to/from IDML (InDesign anchored object settings; verified in InDesign 2026); copy/paste and threading carry them; `anchored.*` commands.
- Ruler guides: drag out of the rulers (page guides, or spread guides on the pasteboard), drag to move, drop on a ruler to delete; Layout › Create Guides (rows/columns with gutters, fit to margins or page); Delete All Guides on Spread; `guide.*` commands.
- Ruler guides: drag out of the rulers (page guides, or spread guides on the pasteboard), drag to move, drop on a ruler to delete; a dragged guide snaps along its axis to other guides, margins, columns, bleed, page and object edges and centers and the document grid (Shift: the ruler's ticks; Cmd suspends snapping); selected like objects (click, Shift-click, a marquee that touches no object, Edit › Select All Guides ⌥⌘G), moved together, nudged, deleted, and placed exactly with the X or Y field; Layout › Create Guides (rows/columns with gutters, fit to margins or page); Delete All Guides on Spread; `guide.*` commands.
- Links panel: every placed graphic with status (OK / Modified / Missing / Embedded, checked against the file on disk), page and effective PPI (low resolution flagged); Relink…, Go To, Update, Embed; `links.*` commands.
- Missing fonts: highlighted pink on screen (never in output), listed by Preflight; Type › Find/Replace Font (fonts in the document, missing first; replace in text, cells, footnotes and styles); `font.list` / `font.replace`.
- Missing glyphs, as in InDesign: a character the applied font lacks is drawn as that font's missing-glyph box (its .notdef; a drawn box when the font's is empty), on screen and in PDF (as a path, so PDF/A and PDF/UA exports take it), and Preflight lists the characters by font. Preferences › Composition › Draw Missing Glyphs from Fallback Fonts (`document.preferences {glyphFallback}`, stored with the document) draws them from other fonts instead; it is off in new documents and IDML imports, on in documents saved before it existed. A missing font's substitute always uses fallback fonts.
@@ -352,6 +352,14 @@ Newest first.
- **2026-10-17** · HU1: Hungarian (`hu` / Magyar) interface with 1,917 entries and two context captions, including tool names; AI-assisted and initially reviewed by a native DTP practitioner. Unknown/new strings remain English. Hungarian spelling and hyphenation remain follow-up work. No readiness percentage change.
- **2026-10-16** · Selection: drag a column guide to move its gutter, as in InDesign (#1096). Both guides of the gutter move together and keep its width, stop at the neighbouring guides and the margins, and snap to the document grid, guides and objects, or with Shift to the ruler's tick marks; Escape cancels and the drag is one undo step. Locked column guides (the default) are not picked. A document page takes the result as its own columns; on a parent page it passes to the pages that follow it. `layout.moveColumnGuide` does the same over the control channel and MCP. No readiness percentage change.
- **2026-10-16** · Layout: Margins and Columns keeps dragged column guides unless the column count or gutter changes (#1096). The dialog sends every field on OK, so a margin-only change re-divided the columns evenly. Margin changes keep the positions; the columns follow them only while they fit the margin box. No readiness percentage change.
- **2026-10-16** · Layout: autoflow, inserted pages and new parents follow dragged column guides (#1096). Autoflow sets one frame per dragged column and counts the pages it adds from them; Insert Pages and New Parent keep the custom positions with the column count and gutter; `document.inspect` reports each page's gutter, column rects and whether its columns are custom. No readiness percentage change.
- **2026-10-16** · Layout: autoflow, inserted pages and new parents follow dragged column guides (#1096). Autoflow sets one frame per dragged column and counts the pages it adds from them; Insert Pages and New Parent keep the custom positions with the column count and gutter; a page that moves to the other side of the spine (inserted, deleted or moved pages, Duplicate Spread, a new parent's other page) has them mirrored across its margin box, as InDesign does; `document.inspect` reports each page's gutter, column rects and whether its columns are custom. No readiness percentage change.
- **2026-10-16** · IDML: custom column positions are checked before they are used (#1096). A set must give two edges per column, ascending from the left margin to the right one; any other (a wrong count, unsorted or non-numeric values, a list longer than 216 columns allow, or positions left behind by a margin or page-size change) is the even split, in the canvas, on import and on export, instead of columns drawn outside the margins. No readiness percentage change.
- **2026-10-16** · IDML: a page keeps column guides that were dragged off centre (InDesign's Custom columns). They were divided evenly on import, so the gutter sprang back to the middle; the imported positions now place the columns, with the canvas guides, snapping and text placement following them, and they are written back on export. Layout › Margins and Columns re-divides them evenly when the count or gutter changes, as InDesign's does. No readiness percentage change.
- **2026-10-16** · Lock Guides and Lock Column Guides are commands in View ▸ Grids & Guides (`view.lockGuides`, ⌥⌘;, and `view.lockColumnGuides`), so the control channel and MCP reach them. Lock Column Guides is saved with the document, on in new ones, and read from and written to IDML's `ColumnGuideLocked`, which export always wrote as locked (#1096). No readiness percentage change.
- **2026-10-16** · Lock Guides and Lock Column Guides are commands in View ▸ Grids & Guides (`view.lockGuides`, ⌥⌘;, and `view.lockColumnGuides`), so the control channel and MCP reach them. Lock Column Guides is an undo step, is saved with the document, on in new ones, and read from and written to IDML's `ColumnGuideLocked`, which export always wrote as locked (#1096). No readiness percentage change.
- **2026-10-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change.
- **2026-10-16** · Greek text hyphenates. The patterns were never compiled in, so Greek set unhyphenated however narrow the column; `"Greek"`, `"Greek: Greece"`, `el` and `el-GR` all resolve now and ανθρωπος breaks αν-θρω-πος. They are the monotonic patterns (hyph-el-monoton, Dimitrios Filippou, under MIT), which is what polytonic text hyphenates by too. Five of InDesign's ~40 hyphenation languages. No readiness percentage change.
- **2026-10-16** · A swatch applied to a placed greyscale or bitmap image colours it, as in InDesign: IDML's `<Image FillColor>` is read and written, greyscale pixels become a tint of the swatch on opaque white and a bitmap's white lets the frame show through, on screen and in PDF (spot swatches print on their plate). Colour images keep their colours. Applying the swatch from the Swatches panel comes next. No readiness percentage change.
+1
View File
@@ -0,0 +1 @@
mod cli;
+2 -2
View File
@@ -2,12 +2,12 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DesignCraft</title>
<link data-trunk rel="rust" data-bin="designcraft-web" data-wasm-opt="z" data-weak-refs />
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
#designcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
#designcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; }
#designcraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
color: #b8b8b8; font: 14px system-ui, sans-serif; pointer-events: none; }
</style>
+2
View File
@@ -395,6 +395,8 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
let ctx = ctx.clone();
let dialog = if request.purpose == "swatches" {
rfd::AsyncFileDialog::new().add_filter("Swatch Exchange (ASE)", &["ase"])
} else if request.purpose == "shortcuts" {
rfd::AsyncFileDialog::new().add_filter("Shortcut set (text)", &["txt"])
} else if request.purpose == "place" {
rfd::AsyncFileDialog::new().add_filter("Graphics", IMAGE_EXTS)
} else {
+1
View File
@@ -187,6 +187,7 @@ fn open_filters(purpose: &str) -> &'static [OpenFilter] {
match purpose {
"swatches" => &[OpenFilter { name: "Swatch Exchange (ASE)", extensions: &["ase"] }],
"script" => &[OpenFilter { name: "Script", extensions: &["dcscript", "txt", "json"] }],
"shortcuts" => &[OpenFilter { name: "Shortcut set (text)", extensions: &["txt"] }],
"icc" => &[OpenFilter { name: "ICC profile", extensions: &["icc", "icm"] }],
"book" => &[OpenFilter { name: "Book", extensions: &["dcbook"] }],
"xml" => &[OpenFilter { name: "XML", extensions: &["xml"] }],
+12
View File
@@ -91,6 +91,18 @@ impl NativeMenu {
items.insert(id.into(), (command.into(), Value::Null, Handle::Plain(item)));
appended
}
AppMenuItem::Language => {
let sub = Submenu::new(menu_title(app, "Language"), true);
for (code, name) in designcraft_ui_egui::i18n::LANGUAGES {
let id = format!("dc-language-{}", if code.is_empty() { "en" } else { code });
let params = serde_json::json!({ "lang": code });
let checked = menus::checked(app, "app.language", &params).unwrap_or(false);
let item = CheckMenuItem::with_id(id.clone(), menus::native_label(name), true, checked, None);
let _ = sub.append(&item);
items.insert(id, ("app.language".into(), params, Handle::Check(item)));
}
app_menu.append(&sub)
}
AppMenuItem::Sep => app_menu.append(&PredefinedMenuItem::separator()),
AppMenuItem::Services => app_menu.append(&PredefinedMenuItem::services(None)),
AppMenuItem::Hide => app_menu.append(&PredefinedMenuItem::hide(None)),
+81 -2
View File
@@ -343,6 +343,8 @@ impl Document {
let mut pages: Vec<(Page, Vec<Arc<Item>>)> = Vec::new();
let mut parity: Vec<bool> = Vec::new();
let mut kept: Vec<(usize, Arc<Spread>)> = Vec::new();
// Whether each shuffled page was a left page, to mirror its dragged columns if it moves.
let mut was_left: BTreeMap<PageId, bool> = BTreeMap::new();
let mut abs = 0;
for sp in self.spreads.drain(..) {
if !sp.allow_shuffle && sp.pages.len() > 1 {
@@ -350,8 +352,9 @@ impl Document {
kept.push((pages.len(), sp));
continue;
}
for _ in &sp.pages {
for p in &sp.pages {
parity.push(odd.get(abs).copied().unwrap_or(abs % 2 == 0));
was_left.insert(p.id, p.side == PageSide::Left);
abs += 1;
}
let mut sp = Arc::unwrap_or_clone(sp);
@@ -445,6 +448,19 @@ impl Document {
out.insert(at, sp);
}
self.spreads = out;
// A page that crosses the spine keeps its inside and outside margins, so its margin box
// mirrors; its dragged column guides mirror with it, as in InDesign.
let crossed = |p: &Page| was_left.get(&p.id).is_some_and(|&l| l != (p.side == PageSide::Left)) && p.columns.positions.is_some();
for sp in &mut self.spreads {
if !sp.pages.iter().any(crossed) {
continue;
}
for p in &mut Arc::make_mut(sp).pages {
if crossed(&*p) {
p.columns = p.columns.mirrored(p.margin_rect().width());
}
}
}
// Pages can change sides, and so parent pages.
self.sync_parent_margins();
}
@@ -466,6 +482,10 @@ impl Document {
if let Some(t) = &template {
p.width = t.width;
p.height = t.height;
// Dragged column guides too: the same width and margins keep them valid.
// They are taken as for this single page; repaginating mirrors them if the
// page lands on a left page.
p.columns.positions = p.columns_from(t).positions;
}
ids.push(p.id);
p
@@ -675,7 +695,7 @@ impl Document {
if page.local_margins {
continue;
}
let Some(src) = self.parent_page(page).filter(|q| !q.same_margins(page)).map(|q| (q.margins, q.columns.clone())) else {
let Some(src) = self.parent_page(page).filter(|q| !q.same_margins(page)).map(|q| (q.margins, page.columns_from(q))) else {
continue;
};
if let Some(page) = Arc::make_mut(&mut self.spreads[si]).pages.get_mut(pi) {
@@ -879,6 +899,65 @@ mod tests {
assert!(d.delete_pages(&[0, 1]).is_err());
}
/// Pages inserted after a page with dragged column guides keep them, as they keep its column
/// count and gutter; pages that follow a parent take the parent's.
#[test]
fn inserted_pages_keep_dragged_columns() {
let dragged = Columns { count: 2, gutter: 12.0, positions: Some(vec![0.0, 100.0, 130.0, 540.0]) };
let widths = |p: &Page| p.column_rects().iter().map(|r| r.width()).collect::<Vec<_>>();
let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, ..Default::default() });
let pg = &mut Arc::make_mut(&mut d.spreads[0]).pages[0];
(pg.columns, pg.local_margins) = (dragged.clone(), true);
d.insert_pages(Some(0), 2, None).unwrap();
for i in 1..3 {
let p = d.page(i).unwrap();
assert_eq!(p.columns, dragged, "page {i}");
assert_eq!(widths(p), vec![100.0, 410.0], "page {i}");
}
let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, ..Default::default() });
Arc::make_mut(&mut d.parents[0]).pages[0].columns = dragged.clone();
d.sync_parent_margins();
d.insert_pages(Some(0), 1, d.parents.first().map(|p| p.id)).unwrap();
assert_eq!(d.page(1).map(widths), Some(vec![100.0, 410.0]));
}
/// A right page whose gutter was dragged toward the spine keeps it there when inserting a
/// page before it makes it a left page: its dragged columns mirror across the margin box.
#[test]
fn dragged_columns_mirror_when_a_page_changes_side() {
let mut d = Document::new(&NewDocument { pages: 1, facing_pages: true, ..Default::default() });
let pg = &mut Arc::make_mut(&mut d.spreads[0]).pages[0];
assert_eq!(pg.side, PageSide::Right);
let w = pg.margin_rect().width();
(pg.columns, pg.local_margins) = (Columns { count: 2, gutter: 12.0, positions: Some(vec![0.0, 100.0, 112.0, w]) }, true);
let id = pg.id;
// How far the gutter's spine-side edge is from the margin on the spine side.
let from_spine = |p: &Page| {
let (m, c) = (p.margin_rect(), p.column_rects());
match p.side {
PageSide::Left => m.x1 - c[1].x0,
_ => c[0].x1 - m.x0,
}
};
assert!((from_spine(d.page(0).unwrap()) - 100.0).abs() < 1e-9);
d.insert_pages(None, 1, None).unwrap();
let moved = d.page(1).unwrap();
assert_eq!((moved.id, moved.side), (id, PageSide::Left));
assert!((from_spine(moved) - 100.0).abs() < 1e-9, "{:?}", moved.column_rects());
let widths: Vec<f64> = moved.column_rects().iter().map(|r| r.width()).collect();
assert!((widths[0] - (w - 112.0)).abs() < 1e-9 && (widths[1] - 100.0).abs() < 1e-9, "{widths:?}");
// The inserted page is a right page like the one it copies: no mirroring.
assert_eq!(d.page(0).unwrap().columns.positions, Some(vec![0.0, 100.0, 112.0, w]));
// Moving it back to the right undoes the mirror.
d.delete_pages(&[0]).unwrap();
let back = d.page(0).unwrap();
assert_eq!(back.side, PageSide::Right);
assert!((from_spine(back) - 100.0).abs() < 1e-9, "{:?}", back.column_rects());
}
#[test]
fn items_travel_with_pages() {
let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() });
+9
View File
@@ -399,11 +399,19 @@ pub struct DocSettings {
/// Lock Zero Point: dragging from the ruler corner leaves it where it is.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub zero_point_locked: bool,
/// View › Grids & Guides › Lock Column Guides (IDML `ColumnGuideLocked`): column guides can't
/// be dragged. On in new documents, as in InDesign.
#[serde(skip_serializing_if = "is_true")]
pub column_guides_locked: bool,
/// 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,
}
fn is_true(b: &bool) -> bool {
*b
}
fn is_origin(p: &[f64; 2]) -> bool {
p[0] == 0.0 && p[1] == 0.0
}
@@ -461,6 +469,7 @@ impl Default for DocSettings {
ruler_origin: RulerOrigin::Spread,
zero_point: [0.0, 0.0],
zero_point_locked: false,
column_guides_locked: true,
new_object: NewObjectColors::default(),
}
}
+214 -5
View File
@@ -42,6 +42,86 @@ impl Default for Columns {
}
}
/// The most columns a page can have, as in InDesign.
pub const MAX_COLUMNS: u32 = 216;
/// How far, in points, the first and last custom column positions may sit from the margin box's
/// edges: room for float noise in files and page arithmetic, far less than any dragged guide.
pub const COLUMN_POSITION_TOLERANCE: f64 = 0.01;
/// Whether `positions` are usable custom column positions for `count` columns across a margin box
/// `margin_width` wide, in IDML's `ColumnsPositions` form: each column's left and right edge as an
/// offset from the margin box's left edge, so `2 × count` finite numbers, ascending across the
/// whole list, the first at 0 and the last at `margin_width` (within
/// [`COLUMN_POSITION_TOLERANCE`]).
pub fn valid_column_positions(positions: &[f64], count: u32, margin_width: f64) -> bool {
(1..=MAX_COLUMNS).contains(&count)
&& positions.len() == 2 * count as usize
&& margin_width.is_finite()
&& positions.iter().all(|v| v.is_finite())
&& positions.windows(2).all(|w| matches!(w, [a, b] if a <= b))
&& positions.first().is_some_and(|v| v.abs() <= COLUMN_POSITION_TOLERANCE)
&& positions.last().is_some_and(|v| (v - margin_width).abs() <= COLUMN_POSITION_TOLERANCE)
}
impl Columns {
/// The custom positions when they are valid for a margin box `margin_width` wide (see
/// [`valid_column_positions`]). Anything else means the columns are evenly spaced: a stored set
/// that no longer fits (a page resized after the drag, a hostile file) is not drawn.
pub fn valid_positions(&self, margin_width: f64) -> Option<&[f64]> {
let positions = self.positions.as_deref()?;
valid_column_positions(positions, self.count, margin_width).then_some(positions)
}
/// Every column's left and right edge as offsets from the margin box's left edge: the valid
/// custom positions, else the even split. This is what IDML writes as `ColumnsPositions`.
pub fn edges(&self, margin_width: f64) -> Vec<f64> {
let w = if margin_width.is_finite() { margin_width.max(0.0) } else { 0.0 };
match self.valid_positions(w) {
Some(p) => p.iter().map(|&v| inside(v, w)).collect(),
None => column_rects(Rect::new(0.0, 0.0, w, 0.0), self.count.max(1), self.gutter).iter().flat_map(|r| [r.x0, r.x1]).collect(),
}
}
/// The column edges (as [`Columns::edges`]) with gutter `gutter` (0 is the one between the first
/// two columns) moved so its left edge sits at `new_left`, an offset from the margin box's left
/// edge. Both of the gutter's guides move, so it keeps its width, and it stops at its
/// neighbouring gutters or the margins: no column gets a negative width. `None` when there is
/// no such gutter, `new_left` is not finite, or the result would not be valid.
pub fn with_gutter_moved(&self, margin_width: f64, gutter: usize, new_left: f64) -> Option<Vec<f64>> {
if !new_left.is_finite() {
return None;
}
let mut pos = self.edges(margin_width);
let i = gutter.checked_mul(2)?.checked_add(1)?;
let &[lo, left, right, hi] = pos.get(i - 1..i.checked_add(3)?)? else {
return None;
};
let width = right - left;
let at = new_left.min(hi - width).max(lo);
let [l, r] = pos.get_mut(i..i + 2)? else {
return None;
};
*l = at;
*r = (at + width).min(hi);
valid_column_positions(&pos, self.count, margin_width).then_some(pos)
}
/// These columns reflected across a margin box `margin_width` wide, as on the facing page
/// across the spine: each custom position `p` becomes `margin_width - p`, in reverse order so
/// they stay ascending. Count and gutter stay; positions that are not valid (see
/// [`Columns::valid_positions`]) become `None`, the even split, which mirrors onto itself.
pub fn mirrored(&self, margin_width: f64) -> Columns {
let positions = self.valid_positions(margin_width).map(|p| p.iter().rev().map(|&v| inside(margin_width - v, margin_width)).collect());
Columns { count: self.count, gutter: self.gutter, positions }
}
}
/// `v` limited to `0..=w` without `clamp`, which panics when `w` is NaN.
fn inside(v: f64, w: f64) -> f64 {
v.max(0.0).min(w)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Orientation {
@@ -233,9 +313,10 @@ impl Page {
pub fn bounds(&self) -> Rect {
Rect::new(self.x, 0.0, self.x + self.width, self.height)
}
/// Same margins and columns as `other`.
/// Same margins and columns as `other`, with `other`'s columns as it would take them from this
/// page: mirrored when one is a left page and the other isn't.
pub fn same_margins(&self, other: &Page) -> bool {
self.margins == other.margins && self.columns == other.columns
self.margins == other.margins && other.columns_from(self) == other.columns
}
/// Left and right margins resolved for the page side (inside is at the spine).
pub fn left_right_margins(&self) -> (f64, f64) {
@@ -244,23 +325,39 @@ impl Page {
_ => (self.margins.inside, self.margins.outside),
}
}
/// `src`'s columns as they fall on this page: the same when both pages put their inside
/// margin on the same physical side, mirrored across `src`'s margin box when one is a left
/// page and the other is not, as InDesign mirrors dragged column guides across the spine.
pub fn columns_from(&self, src: &Page) -> Columns {
if (self.side == PageSide::Left) == (src.side == PageSide::Left) {
src.columns.clone()
} else {
src.columns.mirrored(src.margin_rect().width())
}
}
/// The live area inside the margins.
pub fn margin_rect(&self) -> Rect {
let (l, r) = self.left_right_margins();
let b = self.bounds();
Rect::new(b.x0 + l, b.y0 + self.margins.top, (b.x1 - r).max(b.x0 + l), (b.y1 - self.margins.bottom).max(b.y0 + self.margins.top))
}
/// Column rects within the margins.
/// Column rects within the margins: the custom positions when the column guides have been
/// dragged and still fit the margin box (see [`valid_column_positions`]), otherwise an even
/// split.
pub fn column_rects(&self) -> Vec<Rect> {
let m = self.margin_rect();
column_rects(m, self.columns.count.max(1), self.columns.gutter)
let w = m.width();
match self.columns.valid_positions(w) {
Some(p) => p.as_chunks::<2>().0.iter().map(|&[a, b]| Rect::new(m.x0 + inside(a, w), m.y0, m.x0 + inside(b, w), m.y1)).collect(),
None => column_rects(m, self.columns.count.max(1), self.columns.gutter),
}
}
}
/// Split `area` into `n` equal columns separated by `gutter`.
pub fn column_rects(area: Rect, n: u32, gutter: f64) -> Vec<Rect> {
// The public allocator also receives native/imported options, outside command validation.
let n = n.clamp(1, 216) as usize;
let n = n.clamp(1, MAX_COLUMNS) as usize;
let w = ((area.width() - gutter * (n as f64 - 1.0)) / n as f64).max(0.0);
(0..n).map(|i| Rect::new(area.x0 + i as f64 * (w + gutter), area.y0, area.x0 + i as f64 * (w + gutter) + w, area.y1)).collect()
}
@@ -450,6 +547,118 @@ mod tests {
assert!((cols[1].x0 - cols[0].x1 - 12.0).abs() < 1e-9);
}
/// Dragged column guides place the columns; a set that cannot mean anything falls back to the
/// even split rather than producing nothing or a column outside the margins (#1096).
#[test]
fn custom_column_positions_place_the_columns() {
let mut p = page(PageSide::Single, 0.0);
let m = p.margin_rect();
let w = m.width();
let even = p.column_rects();
p.columns.positions = Some(vec![0.0, 100.0, 130.0, w]);
assert_eq!(p.column_rects(), vec![Rect::new(m.x0, m.y0, m.x0 + 100.0, m.y1), Rect::new(m.x0 + 130.0, m.y0, m.x1, m.y1)]);
// Float noise at the edges is absorbed and kept inside the box.
p.columns.positions = Some(vec![-0.001, 100.0, 130.0, w + 0.001]);
assert_eq!((p.column_rects()[0].x0, p.column_rects()[1].x1), (m.x0, m.x1));
let huge: Vec<f64> = (0..10_000).map(f64::from).collect();
let unusable = [
vec![],
vec![10.0],
vec![0.0, w], // one column's worth for two columns
vec![0.0, 100.0, 130.0, 200.0, 230.0, w], // three columns' worth
vec![0.0, 100.0, 90.0, w], // descending across pairs
vec![0.0, 130.0, 100.0, w], // an inverted gutter
vec![0.0, f64::NAN, 130.0, w],
vec![0.0, 100.0, f64::INFINITY, w],
vec![5.0, 100.0, 130.0, w], // not from the left margin
vec![0.0, 100.0, 130.0, w + 500.0], // a page narrowed after the drag
vec![0.0, 100.0, 130.0, w - 50.0], // a page widened after the drag
huge,
];
for bad in unusable {
p.columns.positions = Some(bad.clone());
assert_eq!(p.column_rects(), even, "{:?}", bad.iter().take(6).collect::<Vec<_>>());
assert_eq!(p.columns.edges(w), even.iter().flat_map(|r| [r.x0 - m.x0, r.x1 - m.x0]).collect::<Vec<_>>());
}
p.columns.positions = None;
assert_eq!(p.column_rects(), even);
// A count outside 1..=216 has no valid positions, however many are given.
for count in [0, 217, u32::MAX] {
let c = Columns { count, gutter: 0.0, positions: Some(vec![0.0, w]) };
assert!(c.valid_positions(w).is_none(), "{count}");
}
}
/// Moving a gutter moves both its guides, keeps its width and stops at the neighbouring gutters
/// and the margins; the result is valid custom positions.
#[test]
fn with_gutter_moved_moves_a_gutter_pair_and_clamps() {
// Three columns across 450 with gutter 15: 0 140 155 295 310 450.
let c = Columns { count: 3, gutter: 15.0, positions: None };
let even = c.edges(450.0);
assert_eq!(even, vec![0.0, 140.0, 155.0, 295.0, 310.0, 450.0]);
assert_eq!(c.with_gutter_moved(450.0, 0, 100.0), Some(vec![0.0, 100.0, 115.0, 295.0, 310.0, 450.0]));
// Past the left margin: the first column closes to nothing.
assert_eq!(c.with_gutter_moved(450.0, 0, -50.0), Some(vec![0.0, 0.0, 15.0, 295.0, 310.0, 450.0]));
// Past the next gutter: the middle column closes to nothing, the next gutter stays put.
assert_eq!(c.with_gutter_moved(450.0, 0, 400.0), Some(vec![0.0, 280.0, 295.0, 295.0, 310.0, 450.0]));
// The last gutter stops at the right margin.
assert_eq!(c.with_gutter_moved(450.0, 1, 1000.0), Some(vec![0.0, 140.0, 155.0, 435.0, 450.0, 450.0]));
// It starts from custom positions already there, and its result is accepted as custom.
let moved = Columns { positions: c.with_gutter_moved(450.0, 0, 100.0), ..c.clone() };
assert!(moved.valid_positions(450.0).is_some());
assert_eq!(moved.with_gutter_moved(450.0, 1, 200.0), Some(vec![0.0, 100.0, 115.0, 200.0, 215.0, 450.0]));
// No such gutter, or no position to move it to.
assert_eq!(c.with_gutter_moved(450.0, 2, 100.0), None);
assert_eq!(c.with_gutter_moved(450.0, usize::MAX, 100.0), None);
assert_eq!(c.with_gutter_moved(450.0, 0, f64::NAN), None);
assert_eq!(Columns { count: 1, gutter: 12.0, positions: None }.with_gutter_moved(450.0, 0, 100.0), None);
assert_eq!(c.with_gutter_moved(f64::NAN, 0, 100.0), None);
}
/// Mirroring reflects dragged columns across the margin box and keeps them ascending; an
/// invalid set becomes the even split.
#[test]
fn mirrored_columns_reflect_across_the_margin_box() {
let two = Columns { count: 2, gutter: 12.0, positions: Some(vec![0.0, 100.0, 130.0, 540.0]) };
assert_eq!(two.mirrored(540.0), Columns { positions: Some(vec![0.0, 410.0, 440.0, 540.0]), ..two.clone() });
assert_eq!(two.mirrored(540.0).mirrored(540.0), two);
let three = Columns { count: 3, gutter: 10.0, positions: Some(vec![0.0, 50.0, 60.0, 200.0, 210.0, 300.0]) };
let m = three.mirrored(300.0);
assert_eq!(m.positions, Some(vec![0.0, 90.0, 100.0, 240.0, 250.0, 300.0]));
assert!(m.valid_positions(300.0).is_some());
// Positions that do not fit the margin box, or none at all, mirror to the even split.
assert_eq!(two.mirrored(600.0).positions, None);
assert_eq!(two.mirrored(f64::NAN).positions, None);
let unsorted = Columns { positions: Some(vec![0.0, 130.0, 100.0, 540.0]), ..two.clone() };
assert_eq!(unsorted.mirrored(540.0).positions, None);
let even = Columns { positions: None, ..two.clone() };
assert_eq!(even.mirrored(540.0), even);
// A left page takes a right page's columns mirrored, a page on the same side as they are.
let mut r = page(PageSide::Right, 612.0);
r.columns = Columns { count: 2, gutter: 12.0, positions: Some(vec![0.0, 100.0, 130.0, 522.0]) };
let l = page(PageSide::Left, 0.0);
assert_eq!(l.columns_from(&r).positions, Some(vec![0.0, 392.0, 422.0, 522.0]));
assert_eq!(page(PageSide::Right, 0.0).columns_from(&r), r.columns);
// A left page holding them mirrored has the same margins as that right page (a one-page
// parent in a facing document); holding them unmirrored, it doesn't.
let mut l = page(PageSide::Left, 0.0);
l.columns = l.columns_from(&r);
assert!(r.same_margins(&l));
l.columns = r.columns.clone();
assert!(!r.same_margins(&l));
}
#[test]
fn spread_spine_and_hit() {
let s = Spread {
+14
View File
@@ -2,9 +2,19 @@
use serde::{Deserialize, Serialize};
use crate::edit::SpreadRef;
use crate::ids::{ItemId, StoryId};
use crate::table::CellRange;
/// A selected ruler guide: index `index` in the guides of page `page` of spread `spread`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GuideSel {
pub spread: SpreadRef,
pub page: usize,
pub index: usize,
}
/// A table cell: the table (in the selection's story) and the owning cell's grid position.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -83,6 +93,10 @@ pub struct Selection {
/// The page selected with the Page tool (absolute index).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub page: Option<usize>,
/// Selected ruler guides. Never together with items, text or cells: selecting one clears
/// the other.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub guides: Vec<GuideSel>,
}
impl Selection {
+1 -1
View File
@@ -109,7 +109,7 @@ fn position_param(p: &Value, base: &AnchorPosition) -> Result<AnchorPosition> {
}
/// Text frames can't be anchored yet (their stories need frames on a spread).
fn check_item(it: &Item) -> Result<()> {
pub(crate) fn check_item(it: &Item) -> Result<()> {
let mut ok = true;
it.walk(&mut |i| ok &= !matches!(i.content, Content::Text(_)));
if ok { Ok(()) } else { Err(bad("anchored.insert", "text frames can't be anchored yet")) }
+1 -1
View File
@@ -436,7 +436,7 @@ fn assign_page(dst: &mut Document, abs: usize, from: &Page) {
p.guides = from.guides.clone();
p.parent = from.parent;
p.margins = from.margins;
p.columns = from.columns.clone();
p.columns = p.columns_from(from);
p.local_margins = from.local_margins;
p.width = from.width;
p.height = from.height;
+135 -27
View File
@@ -43,24 +43,64 @@ fn copy_text(s: &mut Session) -> Result<Option<String>> {
Ok(Some(text))
}
/// Plain text for the system clipboard after copying objects: the copied text frames' stories,
/// or U+FFFC (object replacement character) when none has text. Paste reads it back as "the
/// copied objects", not as text.
pub fn copied_objects_text(s: &Session) -> String {
let Some(clip) = &s.clipboard else { return '\u{FFFC}'.to_string() };
let mut stories = Vec::new();
for it in clip.spreads.first().map(|sp| sp.items.as_slice()).unwrap_or_default() {
it.walk(&mut |i| {
if let Some(tf) = i.text_frame()
&& !stories.contains(&tf.story)
{
stories.push(tf.story);
}
});
}
let text = stories.iter().filter_map(|id| clip.story(*id)).map(plain).filter(|t| !t.is_empty()).collect::<Vec<_>>().join("\n");
if text.is_empty() { '\u{FFFC}'.to_string() } else { text }
}
/// `text` (the system clipboard's) is what the last copy put there: objects were copied last and
/// it is their placeholder, so Paste stands for the copied objects.
fn is_objects_placeholder(s: &Session, text: &str) -> bool {
s.text_clipboard.is_none() && s.clipboard.is_some() && text.replace("\r\n", "\n") == copied_objects_text(s).replace("\r\n", "\n")
}
/// Paste text at the insertion point: the formatted copy when the system clipboard still holds
/// what was copied here (or `formatted` is forced), else plain text in the insertion format.
/// With `formatted`, objects copied last paste as anchored objects when `text` is absent or their
/// placeholder; other text was copied in another app since. Objects that can't be anchored (text
/// frames, into a table cell) paste as their placeholder text.
fn paste_text(s: &mut Session, text: Option<String>, formatted: bool) -> Result<Value> {
let t = s.doc()?.selection.text.ok_or_else(|| super::bad("edit.paste", "no insertion point"))?;
let clip = s.text_clipboard.clone();
// Items copied last (no text copied since) paste into the text as anchored objects.
if formatted
&& clip.is_none()
let own = formatted && clip.is_none() && s.clipboard.is_some() && text.as_deref().is_none_or(|x| is_objects_placeholder(s, x));
if own
&& t.cell.is_none()
&& let Some(items) = s.clipboard.clone()
{
let list: Vec<designcraft_doc::Item> = items.spreads.first().map(|sp| sp.items.iter().map(|i| (**i).clone()).collect()).unwrap_or_default();
if !list.is_empty() && list.iter().all(|i| !i.is_text_frame()) {
if !list.is_empty() && list.iter().all(|i| super::anchored::check_item(i).is_ok()) {
return s.edit(|d, sel| {
let st = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
let r = t.range();
st.delete(r.start.min(st.len())..r.end.min(st.len()));
let end = super::anchored::anchor_items(d, t.story, r.start, list.clone(), &Default::default())?;
// Fresh ids, story and asset copies for every paste, as on the page.
let mut stories = std::collections::HashMap::new();
let fresh: Vec<designcraft_doc::Item> = list
.iter()
.map(|i| {
let mut i = i.clone();
super::object::renumber(d, &items, &mut i, &mut stories);
if d.layer(i.layer).is_none() {
i.layer = d.default_layer();
}
i
})
.collect();
let end = super::anchored::anchor_items(d, t.story, r.start, fresh, &Default::default())?;
sel.text = Some(designcraft_doc::TextSel { anchor: end, focus: end, ..t });
Ok(json!({"pos": end, "anchored": list.len()}))
});
@@ -80,6 +120,8 @@ fn paste_text(s: &mut Session, text: Option<String>, formatted: bool) -> Result<
Ok(json!({"pos": end, "formatted": true}))
});
}
// U+FFFC stands for objects with no text.
let text = if own { Some(copied_objects_text(s)).filter(|m| m != "\u{FFFC}") } else { text };
let text = text.or_else(|| clip.map(|(_, p)| p)).ok_or_else(|| super::bad("edit.paste", "clipboard is empty"))?;
let text = outside_text(&text)?;
s.execute("text.insert", &json!({"text": text, "raw": true}))
@@ -256,28 +298,39 @@ pub fn specs() -> Vec<CommandSpec> {
st.bump_revision();
ok()
}),
cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{ids?} — delete selected items (or the selected text)", has_doc, |s, p| {
if s.doc()?.selection.text.is_some() && p.get("ids").is_none() {
return super::text::delete_selection(s);
}
let ids = super::targets(s, p)?;
// With a placed graphic itself selected (Direct Selection), only the graphic goes: its
// frame stays, empty and selected (as in InDesign). Other items are deleted.
let content = s.doc()?.selection.content && p.get("ids").is_none();
s.edit(|d, sel| {
let mut kept = Vec::new();
for id in &ids {
if let Some(it) = d.item_mut(*id).filter(|it| content && matches!(it.content, designcraft_doc::Content::Graphic(_))) {
it.content = designcraft_doc::Content::Unassigned;
kept.push(*id);
} else {
let _ = d.remove_item(*id);
}
cmd!(
"edit.clear",
"Clear",
["Edit"],
Some("Delete"),
"{ids?} — delete selected items (or the selected text or ruler guides)",
has_doc,
|s, p| {
if s.doc()?.selection.text.is_some() && p.get("ids").is_none() {
return super::text::delete_selection(s);
}
*sel = Selection::items(kept);
Ok(json!({"deleted": ids.len()}))
})
}),
if p.get("ids").is_none() && s.doc()?.selection.items.is_empty() && !s.doc()?.selection.guides.is_empty() {
return s.execute("guide.delete", &json!({}));
}
let ids = super::targets(s, p)?;
// With a placed graphic itself selected (Direct Selection), only the graphic goes: its
// frame stays, empty and selected (as in InDesign). Other items are deleted.
let content = s.doc()?.selection.content && p.get("ids").is_none();
s.edit(|d, sel| {
let mut kept = Vec::new();
for id in &ids {
if let Some(it) = d.item_mut(*id).filter(|it| content && matches!(it.content, designcraft_doc::Content::Graphic(_))) {
it.content = designcraft_doc::Content::Unassigned;
kept.push(*id);
} else {
let _ = d.remove_item(*id);
}
}
*sel = Selection::items(kept);
Ok(json!({"deleted": ids.len()}))
})
}
),
cmd!(noundo "edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{} → {text} when text is selected (with formatting, footnotes, markers)", has_selection_or_text, |s, _| {
if let Some(text) = copy_text(s)? {
return Ok(json!({"text": text}));
@@ -316,7 +369,8 @@ pub fn specs() -> Vec<CommandSpec> {
if s.doc()?.selection.text.is_some() {
return paste_text(s, text.map(str::to_string), true);
}
if let Some(text) = text {
// The placeholder of an object copy pastes the copied objects.
if let Some(text) = text.filter(|t| !is_objects_placeholder(s, t)) {
// What our own text copy put on the clipboard keeps its formatting.
let slice = s.text_clipboard.clone().filter(|(_, plain)| text.replace("\r\n", "\n") == *plain).map(|(slice, _)| slice);
return paste_text_frame(s, p, text, slice.as_deref());
@@ -527,6 +581,60 @@ mod text_clipboard_tests {
s.doc().unwrap().doc.check().unwrap();
}
/// Copying a rectangle, then text in another app, pasted the rectangle into the text (#212).
#[test]
fn paste_at_a_caret_prefers_other_apps_text_to_copied_objects() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let story = frame(&mut s, "ab");
let rect = s.execute("frame.create", &json!({"rect": [72, 400, 200, 480]})).unwrap()["id"].clone();
s.execute("selection.set", &json!({"ids": [rect]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
let marker = copied_objects_text(&s);
s.execute("text.select", &json!({"story": story.0, "anchor": 2, "focus": 2})).unwrap();
let r = s.execute("edit.paste", &json!({"text": "From elsewhere"})).unwrap();
assert!(r.get("anchored").is_none(), "{r}");
assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "abFrom elsewhere");
// What the object copy put on the system clipboard still pastes the objects.
assert_eq!(s.execute("edit.paste", &json!({"text": marker})).unwrap()["anchored"], 1);
// With no insertion point too: the rectangle, not a text frame holding the marker.
s.execute("edit.deselectAll", &json!({})).unwrap();
let r = s.execute("edit.paste", &json!({"text": marker})).unwrap();
let id = ItemId(r["ids"][0].as_u64().unwrap());
assert!(s.doc().unwrap().doc.item(id).unwrap().text_frame().is_none());
s.doc().unwrap().doc.check().unwrap();
// Paste without Formatting inserts the text given; empty text is no text.
s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 0})).unwrap();
assert!(s.execute("edit.pasteWithoutFormatting", &json!({"text": ""})).is_err());
}
/// Text frames, and groups holding one, can't be anchored: at a caret their copy pastes as text.
#[test]
fn copied_text_frames_paste_at_a_caret_as_their_text() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let target = frame(&mut s, "ab");
let copy_and_paste = |s: &mut Session, ids: Value, text: Value| {
s.execute("selection.set", &json!({"ids": ids})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("text.select", &json!({"story": target.0, "anchor": 0, "focus": 0})).unwrap();
s.execute("edit.paste", &json!({"text": text})).unwrap()
};
let chapter = s.execute("frame.create", &json!({"rect": [72, 400, 300, 480], "content": "text", "text": "Chapter 1"})).unwrap()["id"].clone();
// The same text copied in another app, and the system clipboard read back as absent.
let r = copy_and_paste(&mut s, json!([chapter]), json!("Chapter 1"));
assert!(r.get("anchored").is_none(), "{r}");
s.execute("edit.paste", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.story(target).unwrap().text, "Chapter 1Chapter 1ab");
// A group with a text frame inside, its own placeholder on the system clipboard.
let rect = s.execute("frame.create", &json!({"rect": [72, 500, 200, 560]})).unwrap()["id"].clone();
let group = s.execute("object.group", &json!({"ids": [chapter, rect]})).unwrap()["id"].clone();
let r = copy_and_paste(&mut s, json!([group]), Value::Null);
assert!(r.get("anchored").is_none(), "{r}");
assert_eq!(s.doc().unwrap().doc.story(target).unwrap().text, "Chapter 1Chapter 1Chapter 1ab");
s.doc().unwrap().doc.check().unwrap();
}
#[test]
fn outside_text_never_pastes_markers() {
let mut s = Session::new();
+5 -1
View File
@@ -679,7 +679,11 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.active()?;
st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| matches!(it.content, Content::Unassigned | Content::Graphic(_))))
});
let spread = super::spread_param(p, "file.place")?;
// `"spread": null` counts as omitted, like the other placement parameters.
let spread = match p.get("spread") {
Some(Value::Null) => designcraft_doc::SpreadRef::Doc(0),
_ => super::spread_param(p, "file.place")?,
};
let lid = s.doc()?.active_layer;
let px = p.get("x").and_then(Value::as_f64);
let py = p.get("y").and_then(Value::as_f64);
+564 -27
View File
@@ -1,18 +1,28 @@
//! Ruler guides: add (dragged from the rulers), move, delete, list; Layout › Create Guides.
//! Ruler guides: add (dragged from the rulers), select, move, delete, list; Layout › Create
//! Guides.
//!
//! Guides are stored on their page in spread coordinates; a spread guide (dropped on the
//! pasteboard) lives on the spread's first page with `spread: true` and crosses the pasteboard.
//! Selected guides are [`Selection::guides`], addressed as (spread, page, index): the commands
//! that delete guides renumber it, and [`keep_selection_valid`] drops what another command made
//! stale.
use designcraft_doc::{Document, Guide, Orientation, SpreadRef};
use std::collections::{HashMap, HashSet};
use designcraft_doc::{Document, Guide, GuideSel, Orientation, Selection, SpreadRef};
use designcraft_geom::Rect;
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, str_param};
use crate::{Result, Session};
use super::{CommandSpec, bad, bool_or, cmd, has_doc, str_param};
use crate::{DocState, Result, Session};
const MAX_GUIDE_DIVISIONS: u64 = 1000;
const MAX_GUIDES_CREATED: usize = 10_000;
const MAX_GUIDE_GUTTER: f64 = 1_000_000.0;
/// The farthest a guide may be from the spread origin, in points, either way.
const MAX_GUIDE_POSITION: f64 = 1_000_000.0;
/// The most guides one `guides` list may name.
const MAX_GUIDES_NAMED: usize = 10_000;
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -21,34 +31,96 @@ pub fn specs() -> Vec<CommandSpec> {
"New Guide",
[],
None,
"{orientation: horizontal|vertical, position (spread coordinate), spread?, page? (index in the spread; default: the page under `at` or the position), at? (the other coordinate), spreadGuide?: bool, liquid?: bool (a liquid guide, as the Page tool draws from the rulers)} or {orientation: both, point: [x, y], spread?, page?, spreadGuide?: bool, liquid?: bool} → {page, index}; `both` adds a horizontal guide through y and a vertical guide through x as one undo step → {guides: [{page, index}, {page, index}]}",
"{orientation: horizontal|vertical, position (spread coordinate, at most 1000000 pt either way), spread?, page? (index in the spread; default: the page under `at` or the position), at? (the other coordinate), spreadGuide?: bool, liquid?: bool (a liquid guide, as the Page tool draws from the rulers)} or {orientation: both, point: [x, y], spread?, page?, spreadGuide?: bool, liquid?: bool} → {page, index}; `both` adds a horizontal guide through y and a vertical guide through x as one undo step → {guides: [{page, index}, {page, index}]}. The new guides become the selection; objects are deselected",
has_doc,
add
),
cmd!("guide.move", "Move Guide", [], None, "{spread?, page, index, position}", has_doc, move_guide),
cmd!("guide.delete", "Delete Guide", [], None, "{spread?, page, index}", has_doc, delete),
cmd!("guide.move", "Move Guide", [], None, "{spread?, page, index, position (at most 1000000 pt either way)}", has_doc, move_guide),
cmd!(
"guide.moveBy",
"Move Guides",
[],
None,
"{dx?, dy?, guides?: [{spread?, page, index}] (default: the selected guides)} — vertical guides move by dx, horizontal ones by dy, as one undo step",
has_doc,
move_by
),
cmd!(
"guide.delete",
"Delete Guide",
[],
None,
"{spread?, page, index}, or {} for the selected guides; an address without page or index is an error",
has_doc,
delete
),
cmd!("guide.deleteAll", "Delete All Guides on Spread", ["View", "Grids & Guides"], None, "{spread?}", has_doc, |s, p| {
let r = super::spread_param(p, "guide.deleteAll")?;
s.edit(|d, _| {
s.edit(|d, sel| {
let sp = d.spread_mut(r).ok_or_else(|| bad("guide.deleteAll", "no such spread"))?;
let mut n = 0;
for pg in &mut sp.pages {
n += pg.guides.len();
pg.guides.clear();
}
sel.guides.retain(|g| g.spread != r);
Ok(json!({"deleted": n}))
})
}),
cmd!(query "guide.list", "Guides", [], None, "{spread?} → [{page, index, orientation, position, spread}]", has_doc, |s, p| {
let d = &s.doc()?.doc;
let sp = d.spread(super::spread_param(p, "guide.list")?).ok_or_else(|| bad("guide.list", "no such spread"))?;
let mut out = Vec::new();
for (pi, pg) in sp.pages.iter().enumerate() {
for (i, g) in pg.guides.iter().enumerate() {
out.push(json!({"page": pi, "index": i, "orientation": g.orientation, "position": g.position, "spread": g.spread}));
cmd!(
"view.lockColumnGuides",
"Lock Column Guides",
["View", "Grids & Guides"],
None,
"{on?: bool (default: toggle)} → {locked} — locked column guides can't be dragged; saved with the document (IDML ColumnGuideLocked), on in new documents",
has_doc,
lock_column_guides
),
cmd!(
query "guide.list",
"Guides",
[],
None,
"{spread?} → [{page, index, orientation, position, spread, selected}]",
has_doc,
|s, p| {
let st = s.doc()?;
let r = super::spread_param(p, "guide.list")?;
let sp = st.doc.spread(r).ok_or_else(|| bad("guide.list", "no such spread"))?;
let picked: HashSet<GuideSel> = st.selection.guides.iter().copied().collect();
let mut out = Vec::new();
for (pi, pg) in sp.pages.iter().enumerate() {
for (i, g) in pg.guides.iter().enumerate() {
let selected = picked.contains(&GuideSel { spread: r, page: pi, index: i });
out.push(json!({"page": pi, "index": i, "orientation": g.orientation, "position": g.position, "spread": g.spread, "selected": selected}));
}
}
Ok(Value::Array(out))
}
Ok(Value::Array(out))
),
cmd!(
noundo "guide.select",
"Select Guide",
[],
None,
"{spread?, page, index, guides?: [{spread?, page, index}] (several, in place of page and index), add?: bool (keep the guides already selected), toggle?: bool (deselect a selected guide instead: Shift-click)} — selects ruler guides and deselects objects → {selected: n}",
has_doc,
select
),
cmd!(
noundo "guide.selectAll",
"Select All Guides",
["Edit"],
Some("Cmd+Alt+G"),
"{spread?} — every guide on the spread that is not locked or on a locked or hidden layer → {selected: n}",
has_doc,
select_all
),
cmd!(noundo "guide.deselect", "Deselect Guides", [], None, "{}", has_doc, |s, _| {
let st = s.doc_mut()?;
st.selection.guides.clear();
st.bump_revision();
super::ok()
}),
cmd!(
"layout.createGuides",
@@ -62,6 +134,18 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
fn lock_column_guides(s: &mut Session, p: &Value) -> Result<Value> {
let cur = s.doc()?.doc.settings.column_guides_locked;
let on = p.get("on").and_then(Value::as_bool).unwrap_or(!cur);
if on == cur {
return Ok(json!({"locked": cur}));
}
s.edit(|d, _| {
d.settings.column_guides_locked = on;
Ok(json!({"locked": on}))
})
}
fn checked_usize(value: &Value, key: &str, command: &str) -> Result<usize> {
let n = value.as_u64().ok_or_else(|| bad(command, format!("`{key}` must be a non-negative integer")))?;
usize::try_from(n).map_err(|_| bad(command, format!("`{key}` is too large for this platform")))
@@ -80,6 +164,14 @@ fn finite_number(p: &Value, key: &str, command: &str) -> Result<Option<f64>> {
Ok(Some(number))
}
/// `position` as a guide position: finite and within [`MAX_GUIDE_POSITION`] of the origin.
fn guide_position(position: f64, command: &str) -> Result<f64> {
if !position.is_finite() || position.abs() > MAX_GUIDE_POSITION {
return Err(bad(command, format!("a guide position must be between -{MAX_GUIDE_POSITION} and {MAX_GUIDE_POSITION}")));
}
Ok(position)
}
fn orientation(p: &Value) -> Result<Orientation> {
match str_param(p, "orientation") {
Some("horizontal") => Ok(Orientation::Horizontal),
@@ -115,6 +207,9 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
};
vec![(o, pos, x)]
};
for (_, pos, _) in &guides {
guide_position(*pos, "guide.add")?;
}
let d = &s.doc()?.doc;
let placed: Vec<(Orientation, f64, usize)> = guides
.into_iter()
@@ -128,11 +223,13 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
})
.collect();
let layer = s.doc()?.active_layer;
s.edit(|d, _| {
s.edit(|d, sel| {
let sp = d.spread_mut(r).ok_or_else(|| bad("guide.add", "no such spread"))?;
let mut out = Vec::new();
let mut added = Vec::new();
for (o, pos, pi) in placed {
let pg = sp.pages.get_mut(pi).ok_or_else(|| bad("guide.add", "no such page"))?;
let index = pg.guides.len();
pg.guides.push(Guide {
orientation: o,
position: pos,
@@ -141,8 +238,11 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
layer: Some(layer),
liquid: liquid && !spread_guide,
});
out.push(json!({"page": pi, "index": pg.guides.len() - 1}));
out.push(json!({"page": pi, "index": index}));
added.push(GuideSel { spread: r, page: pi, index });
}
// A new guide is selected, as in InDesign; nothing else stays selected.
*sel = Selection { guides: added, ..Default::default() };
match <[Value; 1]>::try_from(out) {
Ok([one]) => Ok(one),
Err(out) => Ok(json!({"guides": out})),
@@ -168,6 +268,7 @@ fn check_editable(d: &Document, r: SpreadRef, pi: usize, i: usize, command: &str
fn move_guide(s: &mut Session, p: &Value) -> Result<Value> {
let (r, pi, i) = guide_ref(p, "guide.move")?;
let pos = finite_number(p, "position", "guide.move")?.ok_or_else(|| bad("guide.move", "missing position"))?;
let pos = guide_position(pos, "guide.move")?;
s.edit(|d, _| {
check_editable(d, r, pi, i, "guide.move")?;
let g = d
@@ -181,18 +282,209 @@ fn move_guide(s: &mut Session, p: &Value) -> Result<Value> {
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let (r, pi, i) = guide_ref(p, "guide.delete")?;
s.edit(|d, _| {
check_editable(d, r, pi, i, "guide.delete")?;
let pg = d.spread_mut(r).and_then(|sp| sp.pages.get_mut(pi)).ok_or_else(|| bad("guide.delete", "no such page"))?;
if i >= pg.guides.len() {
return Err(bad("guide.delete", "no such guide"));
// An address names one guide: a partial one (only `spread`, say) is an error, not the selection.
let targets = if ["spread", "page", "index"].iter().all(|k| p.get(k).is_none()) {
s.doc()?.selection.guides.clone()
} else {
let (spread, page, index) = guide_ref(p, "guide.delete")?;
vec![GuideSel { spread, page, index }]
};
if targets.is_empty() {
return Err(bad("guide.delete", "no guide selected"));
}
s.edit(|d, sel| {
for g in &targets {
check_editable(d, g.spread, g.page, g.index, "guide.delete")?;
}
pg.guides.remove(i);
Ok(Value::Null)
let deleted = remove_guides(d, sel, &targets, "guide.delete")?;
Ok(json!({"deleted": deleted}))
})
}
/// Removes `targets` (existing guides; repeats count once) and renumbers the guide selection to
/// match. Returns how many guides it removed.
fn remove_guides(d: &mut Document, sel: &mut Selection, targets: &[GuideSel], command: &str) -> Result<usize> {
let gone: HashSet<GuideSel> = targets.iter().copied().collect();
// Per page, the removed indices in ascending order.
let mut by_page: HashMap<(SpreadRef, usize), Vec<usize>> = HashMap::new();
for g in &gone {
by_page.entry((g.spread, g.page)).or_default().push(g.index);
}
for ((spread, page), indices) in &mut by_page {
indices.sort_unstable();
let pg = d.spread_mut(*spread).and_then(|sp| sp.pages.get_mut(*page)).ok_or_else(|| bad(command, "no such page"))?;
for &i in indices.iter().rev() {
if i >= pg.guides.len() {
return Err(bad(command, "no such guide"));
}
pg.guides.remove(i);
}
}
sel.guides = sel
.guides
.iter()
.filter(|g| !gone.contains(g))
.map(|g| {
let below = by_page.get(&(g.spread, g.page)).map_or(0, |indices| indices.partition_point(|&i| i < g.index));
GuideSel { index: g.index.saturating_sub(below), ..*g }
})
.collect();
Ok(gone.len())
}
/// The guides a `guides: [{spread?, page, index}]` list names, if the params have one.
fn guides_param(p: &Value, command: &str) -> Result<Option<Vec<GuideSel>>> {
let Some(list) = p.get("guides") else { return Ok(None) };
let list = list.as_array().ok_or_else(|| bad(command, "`guides` must be a list of {spread?, page, index}"))?;
if list.len() > MAX_GUIDES_NAMED {
return Err(bad(command, format!("`guides` may name at most {MAX_GUIDES_NAMED} guides")));
}
list.iter()
.map(|g| {
let (spread, page, index) = guide_ref(g, command)?;
Ok(GuideSel { spread, page, index })
})
.collect::<Result<Vec<_>>>()
.map(Some)
}
/// The guide `g` addresses, if there is one.
fn guide_of(d: &Document, g: GuideSel) -> Option<&Guide> {
d.spread(g.spread)?.pages.get(g.page)?.guides.get(g.index)
}
/// A guide that can be selected: it exists, and neither it nor its layer is locked or hidden.
pub(crate) fn selectable(d: &Document, g: GuideSel) -> bool {
guide_of(d, g).is_some_and(|guide| guide.editable_in(d))
}
fn select(s: &mut Session, p: &Value) -> Result<Value> {
let named = match guides_param(p, "guide.select")? {
Some(list) => list,
None => {
let (spread, page, index) = guide_ref(p, "guide.select")?;
vec![GuideSel { spread, page, index }]
}
};
let (add, toggle) = (bool_or(p, "add", false), bool_or(p, "toggle", false));
let st = s.doc_mut()?;
for g in &named {
check_editable(&st.doc, g.spread, g.page, g.index, "guide.select")?;
}
let mut guides = if add || toggle { st.selection.guides.clone() } else { Vec::new() };
let mut have: HashSet<GuideSel> = guides.iter().copied().collect();
let mut seen = HashSet::new();
let mut dropped = HashSet::new();
for g in named.into_iter().filter(|g| seen.insert(*g)) {
if !have.insert(g) {
if toggle {
dropped.insert(g);
}
} else {
guides.push(g);
}
}
guides.retain(|g| !dropped.contains(g));
let n = guides.len();
st.selection = Selection { guides, ..Default::default() };
st.bump_revision();
Ok(json!({"selected": n}))
}
fn select_all(s: &mut Session, p: &Value) -> Result<Value> {
let r = super::spread_param(p, "guide.selectAll")?;
let st = s.doc_mut()?;
let sp = st.doc.spread(r).ok_or_else(|| bad("guide.selectAll", "no such spread"))?;
let guides: Vec<GuideSel> = sp
.pages
.iter()
.enumerate()
.flat_map(|(page, pg)| (0..pg.guides.len()).map(move |index| GuideSel { spread: r, page, index }))
.filter(|g| selectable(&st.doc, *g))
.collect();
let n = guides.len();
st.selection = Selection { guides, ..Default::default() };
st.bump_revision();
Ok(json!({"selected": n}))
}
fn move_by(s: &mut Session, p: &Value) -> Result<Value> {
let dx = finite_number(p, "dx", "guide.moveBy")?.unwrap_or(0.0);
let dy = finite_number(p, "dy", "guide.moveBy")?.unwrap_or(0.0);
let mut targets = match guides_param(p, "guide.moveBy")? {
Some(list) => list,
None => s.doc()?.selection.guides.clone(),
};
let mut seen = HashSet::new();
targets.retain(|g| seen.insert(*g));
if targets.is_empty() {
return Err(bad("guide.moveBy", "no guide selected"));
}
s.edit(|d, _| {
for g in &targets {
check_editable(d, g.spread, g.page, g.index, "guide.moveBy")?;
}
for g in &targets {
let guide = d
.spread_mut(g.spread)
.and_then(|sp| sp.pages.get_mut(g.page))
.and_then(|pg| pg.guides.get_mut(g.index))
.ok_or_else(|| bad("guide.moveBy", "no such guide"))?;
let to = guide.position
+ match guide.orientation {
Orientation::Vertical => dx,
Orientation::Horizontal => dy,
};
guide.position = guide_position(to, "guide.moveBy").map_err(|_| bad("guide.moveBy", "the guide would move out of range"))?;
}
Ok(json!({"moved": targets.len()}))
})
}
/// After a command: guides stay selected only while nothing else is (objects, text, cells, a
/// page), and only those that are still there and can be selected. A command that changed
/// which guides a page holds without keeping the guide selection up to date clears it, since
/// its (page, index) addresses may name other guides now. `before` is the document and
/// selection before the command, when it ran on the same document.
pub(crate) fn keep_selection_valid(st: &mut DocState, before: Option<(&Document, &Selection)>, id: &str) {
let sel = &mut st.selection;
if sel.guides.is_empty() {
return;
}
if !sel.items.is_empty() || sel.text.is_some() || sel.cells.is_some() || sel.page.is_some() {
sel.guides.clear();
return;
}
let kept_up = id.starts_with("guide.") || matches!(id, "edit.undo" | "edit.redo") || before.is_some_and(|(_, old)| old.guides != sel.guides);
if !kept_up
&& let Some((old, _)) = before
&& !std::ptr::eq(old, st.doc.as_ref())
&& !same_guide_addresses(old, &st.doc)
{
sel.guides.clear();
return;
}
let mut seen = HashSet::new();
sel.guides.retain(|g| selectable(&st.doc, *g) && seen.insert(*g));
}
/// Every (spread, page, index) of `old` still names a guide of the same orientation on the same
/// page in `new`: guides were only added at the end of their pages, or moved.
fn same_guide_addresses(old: &Document, new: &Document) -> bool {
let same = |a: &[std::sync::Arc<designcraft_doc::Spread>], b: &[std::sync::Arc<designcraft_doc::Spread>]| {
a.len() == b.len()
&& a.iter().zip(b).all(|(a, b)| {
a.pages.len() == b.pages.len()
&& a.pages.iter().zip(&b.pages).all(|(a, b)| {
a.id == b.id
&& a.guides.len() <= b.guides.len()
&& a.guides.iter().zip(&b.guides).all(|(a, b)| a.orientation == b.orientation)
})
})
};
same(&old.spreads, &new.spreads) && same(&old.parents, &new.parents)
}
fn guide_divisions(p: &Value, key: &str) -> Result<u64> {
let Some(value) = p.get(key) else { return Ok(0) };
let count = value.as_u64().ok_or_else(|| bad("layout.createGuides", format!("`{key}` must be an integer")))?;
@@ -273,7 +565,11 @@ fn create_guides(s: &mut Session, p: &Value) -> Result<Value> {
.filter(|count| *count <= MAX_GUIDES_CREATED)
.ok_or_else(|| bad("layout.createGuides", format!("a single command may create at most {MAX_GUIDES_CREATED} guides")))?;
let layer = s.doc()?.active_layer;
s.edit(|d, _| {
s.edit(|d, sel| {
if remove {
// The cleared pages' guide addresses name the new guides now.
sel.guides.retain(|g| g.spread != r || only.is_some_and(|o| o != g.page));
}
let sp = d.spread_mut(r).ok_or_else(|| bad("layout.createGuides", "no such spread"))?;
let mut n = 0usize;
for (pi, pg) in sp.pages.iter_mut().enumerate() {
@@ -315,6 +611,39 @@ fn create_guides(s: &mut Session, p: &Value) -> Result<Value> {
mod tests {
use super::*;
#[test]
fn lock_column_guides_toggles_sets_and_undoes() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let locked = |s: &Session| s.doc().unwrap().doc.settings.column_guides_locked;
let undo_steps = |s: &Session| s.doc().unwrap().history.undo.len();
assert!(locked(&s), "locked in new documents");
let before = undo_steps(&s);
assert_eq!(s.execute("view.lockColumnGuides", &json!({})).unwrap()["locked"], false);
assert!(!locked(&s));
assert_eq!(undo_steps(&s), before + 1, "one undo step per toggle");
s.execute("edit.undo", &json!({})).unwrap();
assert!(locked(&s), "undo restores the lock");
s.execute("edit.redo", &json!({})).unwrap();
assert!(!locked(&s), "redo unlocks again");
// Setting the current value changes nothing and adds no undo step.
let steps = undo_steps(&s);
assert_eq!(s.execute("view.lockColumnGuides", &json!({"on": false})).unwrap()["locked"], false);
assert_eq!(undo_steps(&s), steps);
assert_eq!(s.execute("view.lockColumnGuides", &json!({"on": true})).unwrap()["locked"], true);
assert!(locked(&s));
assert_eq!(s.execute("view.lockColumnGuides", &json!({})).unwrap()["locked"], false);
// Saved with the document; files without the setting read as locked.
let json = serde_json::to_string(&*s.doc().unwrap().doc).unwrap();
assert!(json.contains("\"columnGuidesLocked\":false"), "{json}");
let back: Document = serde_json::from_str(&json).unwrap();
assert!(!back.settings.column_guides_locked);
let old = json.replace("\"columnGuidesLocked\":false,", "").replace(",\"columnGuidesLocked\":false", "");
assert!(!old.contains("columnGuidesLocked"));
let back: Document = serde_json::from_str(&old).unwrap();
assert!(back.settings.column_guides_locked);
}
#[test]
fn guides_add_move_delete_and_grid() {
let mut s = Session::new();
@@ -390,6 +719,214 @@ mod tests {
}
}
#[cfg(test)]
mod selection_tests {
use serde_json::{Value, json};
use crate::Session;
/// A page with vertical guides at 100, 200, 300 and a horizontal one at 400.
fn guides() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
for x in [100.0, 200.0, 300.0] {
s.execute("guide.add", &json!({"orientation": "vertical", "position": x})).unwrap();
}
s.execute("guide.add", &json!({"orientation": "horizontal", "position": 400.0})).unwrap();
s
}
fn selected(s: &Session) -> Vec<usize> {
s.doc().unwrap().selection.guides.iter().map(|g| g.index).collect()
}
fn positions(s: &mut Session) -> Vec<f64> {
let l = s.execute("guide.list", &json!({})).unwrap();
l.as_array().unwrap().iter().map(|g| g["position"].as_f64().unwrap()).collect()
}
#[test]
fn selecting_guides_clears_objects_and_the_other_way_round() {
let mut s = guides();
let id = s.execute("frame.create", &json!({"rect": [36, 36, 100, 100]})).unwrap()["id"].clone();
s.execute("selection.set", &json!({"ids": [id]})).unwrap();
s.execute("guide.select", &json!({"page": 0, "index": 1})).unwrap();
assert!(s.doc().unwrap().selection.items.is_empty());
assert_eq!(selected(&s), [1]);
// Shift-click adds, Shift-click again removes.
s.execute("guide.select", &json!({"page": 0, "index": 3, "toggle": true})).unwrap();
assert_eq!(selected(&s), [1, 3]);
s.execute("guide.select", &json!({"page": 0, "index": 1, "toggle": true})).unwrap();
assert_eq!(selected(&s), [3]);
let r = s.execute("guide.select", &json!({"guides": [{"page": 0, "index": 0}, {"page": 0, "index": 2}], "add": true})).unwrap();
assert_eq!(r["selected"], 3);
let l = s.execute("guide.list", &json!({})).unwrap();
assert_eq!(l.as_array().unwrap().iter().map(|g| g["selected"] == true).collect::<Vec<_>>(), [true, false, true, true]);
// Selecting an object (Shift-click too) deselects the guides.
s.execute("selection.toggle", &json!({"id": id})).unwrap();
assert!(selected(&s).is_empty());
s.execute("guide.select", &json!({"page": 0, "index": 0})).unwrap();
s.execute("edit.deselectAll", &json!({})).unwrap();
assert!(selected(&s).is_empty());
s.execute("guide.selectAll", &json!({})).unwrap();
s.execute("guide.deselect", &json!({})).unwrap();
assert!(selected(&s).is_empty());
// No such guide.
assert!(s.execute("guide.select", &json!({"page": 0, "index": 9})).is_err());
assert!(s.execute("guide.select", &json!({"page": 3, "index": 0})).is_err());
assert!(s.execute("guide.select", &json!({"guides": "all"})).is_err());
}
#[test]
fn select_all_guides_skips_locked_ones() {
let mut s = guides();
let layer = s.execute("layer.new", &json!({"name": "Grid"})).unwrap()["id"].as_u64().unwrap();
s.execute("layer.activate", &json!({"id": layer})).unwrap();
s.execute("guide.add", &json!({"orientation": "vertical", "position": 500.0})).unwrap();
s.execute("layer.set", &json!({"id": layer, "locked": true})).unwrap();
assert_eq!(s.execute("guide.selectAll", &json!({})).unwrap()["selected"], 4);
assert_eq!(selected(&s), [0, 1, 2, 3]);
assert!(s.execute("guide.select", &json!({"page": 0, "index": 4})).is_err());
// Locking the layer of a selected guide deselects it.
s.execute("layer.set", &json!({"id": layer, "locked": false})).unwrap();
s.execute("guide.selectAll", &json!({})).unwrap();
s.execute("layer.set", &json!({"id": layer, "locked": true})).unwrap();
assert_eq!(selected(&s), [0, 1, 2, 3]);
let spec = crate::cmd::find_command("guide.selectAll").unwrap();
assert_eq!((spec.menu, spec.shortcut), (&["Edit"][..], Some("Cmd+Alt+G")));
}
#[test]
fn selected_guides_move_and_delete_as_one_undo_step() {
let mut s = guides();
s.execute("guide.select", &json!({"guides": [{"page": 0, "index": 0}, {"page": 0, "index": 2}, {"page": 0, "index": 3}]})).unwrap();
// Vertical guides move by dx, horizontal ones by dy.
s.execute("guide.moveBy", &json!({"dx": 10.0, "dy": -5.0})).unwrap();
assert_eq!(positions(&mut s), [110.0, 200.0, 310.0, 395.0]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(positions(&mut s), [100.0, 200.0, 300.0, 400.0]);
assert_eq!(selected(&s), [0, 2, 3]);
// Delete (edit.clear, as the Delete key) takes all three; the guide left is the one at 200.
assert_eq!(s.execute("edit.clear", &json!({})).unwrap()["deleted"], 3);
assert_eq!(positions(&mut s), [200.0]);
assert!(selected(&s).is_empty());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(positions(&mut s), [100.0, 200.0, 300.0, 400.0]);
assert_eq!(selected(&s), [0, 2, 3]);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(positions(&mut s), [200.0]);
assert!(s.execute("guide.moveBy", &json!({"dx": 1.0})).is_err(), "nothing selected");
// A guide stays within 1000000 pt of the origin.
assert!(s.execute("guide.moveBy", &json!({"dx": f64::MAX, "guides": [{"page": 0, "index": 0}]})).is_err());
assert!(s.execute("guide.moveBy", &json!({"dx": 1e6, "guides": [{"page": 0, "index": 0}]})).is_err());
assert_eq!(positions(&mut s), [200.0]);
s.execute("guide.moveBy", &json!({"dx": -1e6 - 200.0, "guides": [{"page": 0, "index": 0}]})).unwrap();
assert_eq!(positions(&mut s), [-1e6]);
assert!(s.execute("guide.move", &json!({"page": 0, "index": 0, "position": 1e6 + 1.0})).is_err());
assert!(s.execute("guide.add", &json!({"orientation": "vertical", "position": -2e6})).is_err());
assert!(s.execute("guide.add", &json!({"orientation": "both", "point": [0.0, 1e300]})).is_err());
assert_eq!(positions(&mut s), [-1e6]);
}
#[test]
fn the_guide_selection_follows_deletes_and_drops_stale_guides() {
let mut s = guides();
s.execute("guide.select", &json!({"guides": [{"page": 0, "index": 2}, {"page": 0, "index": 3}]})).unwrap();
// Deleting a guide before them renumbers them.
s.execute("guide.delete", &json!({"page": 0, "index": 0})).unwrap();
assert_eq!(selected(&s), [1, 2]);
assert_eq!(positions(&mut s), [200.0, 300.0, 400.0]);
// Deleting a selected one deselects it.
s.execute("guide.delete", &json!({"page": 0, "index": 1})).unwrap();
assert_eq!(selected(&s), [1]);
// Undo puts back the selection that went with the document.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(selected(&s), [1, 2]);
// A new guide is the selection.
s.execute("guide.add", &json!({"orientation": "vertical", "position": 50.0})).unwrap();
assert_eq!(selected(&s), [3]);
// Undoing the add to before the first delete: those addresses held other guides then.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(selected(&s), [2, 3]);
// Create Guides replacing the page's guides: their addresses name new guides, so the
// selection goes.
s.execute("layout.createGuides", &json!({"columns": 2, "removeExisting": true})).unwrap();
assert!(selected(&s).is_empty());
s.execute("edit.undo", &json!({})).unwrap();
s.execute("guide.select", &json!({"page": 0, "index": 0})).unwrap();
s.execute("layout.createGuides", &json!({"columns": 2, "columnGutter": 0})).unwrap();
assert_eq!(selected(&s), [0], "guides only added: the addresses still hold");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("guide.selectAll", &json!({})).unwrap();
s.execute("guide.deleteAll", &json!({})).unwrap();
assert!(selected(&s).is_empty());
// Deleting the page goes with it.
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 2})).unwrap();
s.execute("guide.add", &json!({"spread": 1, "orientation": "vertical", "position": 100.0})).unwrap();
s.execute("guide.selectAll", &json!({"spread": 1})).unwrap();
assert_eq!(selected(&s), [0]);
s.execute("layout.pages.delete", &json!({"pages": [1]})).unwrap();
assert!(selected(&s).is_empty());
let _: Value = s.execute("guide.list", &json!({})).unwrap();
}
}
#[cfg(test)]
mod new_guide_tests {
use designcraft_doc::GuideSel;
use serde_json::json;
use crate::Session;
fn selected(s: &Session) -> Vec<GuideSel> {
s.doc().unwrap().selection.guides.clone()
}
#[test]
fn a_new_guide_is_the_selection() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 2})).unwrap();
let id = s.execute("frame.create", &json!({"rect": [36, 36, 100, 100]})).unwrap()["id"].clone();
s.execute("selection.set", &json!({"ids": [id]})).unwrap();
s.execute("guide.add", &json!({"orientation": "vertical", "position": 100.0})).unwrap();
let spread = s.doc().unwrap().selection.guides[0].spread;
assert_eq!(selected(&s), [GuideSel { spread, page: 0, index: 0 }]);
assert!(s.doc().unwrap().selection.items.is_empty(), "objects are deselected");
// Both ways (Cmd-drag from the ruler corner): the two new guides.
s.execute("guide.add", &json!({"orientation": "both", "point": [50.0, 60.0], "spreadGuide": true})).unwrap();
assert_eq!(selected(&s), [GuideSel { spread, page: 0, index: 1 }, GuideSel { spread, page: 0, index: 2 }]);
// Undo gives back the selection from before.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(selected(&s), [GuideSel { spread, page: 0, index: 0 }]);
}
#[test]
fn delete_takes_a_whole_address_or_the_selection() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 3})).unwrap();
for spread in [0, 1] {
s.execute("guide.add", &json!({"spread": spread, "orientation": "vertical", "position": 100.0})).unwrap();
}
s.execute("guide.select", &json!({"spread": 1, "page": 0, "index": 0})).unwrap();
// A spread without page and index is not "the selection".
assert!(s.execute("guide.delete", &json!({"spread": 0})).is_err());
assert!(s.execute("guide.delete", &json!({"spread": 1, "page": 0})).is_err());
assert_eq!(selected(&s).len(), 1);
// A selection naming a guide twice, apart, deletes it once.
s.execute("guide.add", &json!({"spread": 1, "orientation": "vertical", "position": 200.0})).unwrap();
s.execute("guide.add", &json!({"spread": 1, "orientation": "vertical", "position": 300.0})).unwrap();
let spread = selected(&s)[0].spread;
let at = |index| GuideSel { spread, page: 0, index };
s.doc_mut().unwrap().selection.guides = vec![at(0), at(2), at(0)];
assert_eq!(s.execute("guide.delete", &json!({})).unwrap()["deleted"], 2);
let left = s.execute("guide.list", &json!({"spread": 1})).unwrap();
assert_eq!(left.as_array().unwrap().iter().map(|g| g["position"].as_f64().unwrap()).collect::<Vec<_>>(), [200.0]);
assert_eq!(s.execute("guide.list", &json!({"spread": 0})).unwrap().as_array().unwrap().len(), 1);
}
}
#[cfg(test)]
mod layer_tests {
use serde_json::json;
+3 -1
View File
@@ -85,7 +85,9 @@ fn inspect(s: &mut Session, _p: &Value) -> Result<Value> {
let abs = d.first_page_of_spread(si) + pi;
json!({"index": abs, "name": d.page_name(abs), "bounds": [p.x, 0.0, p.x + p.width, p.height], "side": p.side,
"parent": p.parent.and_then(|pid| d.parents.iter().find(|x| x.id == pid)).and_then(|x| x.parent.as_ref()).map(|i| i.label()),
"margins": p.margins, "columns": p.columns.count})
"margins": p.margins, "columns": p.columns.count, "gutter": p.columns.gutter,
"customColumns": p.columns.valid_positions(p.margin_rect().width()).is_some(),
"columnRects": p.column_rects().iter().map(|r| [r.x0, r.y0, r.x1, r.y1]).collect::<Vec<_>>()})
}).collect::<Vec<_>>(),
"items": sp.items.iter().map(|i| item_json(i)).collect::<Vec<_>>(),
})
+451 -9
View File
@@ -88,6 +88,7 @@ fn dist(r: designcraft_geom::Rect, x: f64) -> f64 {
/// Apply the `margins`, `columns` and `gutter` params of `layout.marginsAndColumns` to one page.
fn set_margins_columns(pg: &mut designcraft_doc::Page, p: &Value) {
let width_before = pg.margin_rect().width();
match p.get("margins") {
Some(Value::Number(n)) => pg.margins = Margins::uniform(n.as_f64().unwrap_or(36.0)),
Some(v @ Value::Object(_)) => {
@@ -97,14 +98,62 @@ fn set_margins_columns(pg: &mut designcraft_doc::Page, p: &Value) {
}
_ => {}
}
scale_columns(pg, width_before);
// A new count or gutter re-divides the columns evenly: dragged column positions are what
// the new value replaces. The dialog always sends both, so an unchanged value keeps them.
if let Some(c) = p.get("columns").and_then(Value::as_u64) {
pg.columns.count = (c as u32).clamp(1, 216);
let c = u32::try_from(c).unwrap_or(u32::MAX).clamp(1, 216);
if c != pg.columns.count {
pg.columns.count = c;
pg.columns.positions = None;
}
}
if let Some(g) = p.get("gutter").and_then(Value::as_f64) {
pg.columns.gutter = g.max(0.0);
if let Some(g) = p.get("gutter").and_then(Value::as_f64).filter(|g| g.is_finite()) {
let g = g.max(0.0);
if (g - pg.columns.gutter).abs() > GUTTER_TOLERANCE {
pg.columns.gutter = g;
pg.columns.positions = None;
}
}
}
/// Dragged column positions are measured across the margin box. When the box was
/// `width_before` wide and now has another width, they scale with it, as InDesign's do; a set
/// that doesn't scale into a valid one goes back to the even split rather than stay hidden.
fn scale_columns(pg: &mut designcraft_doc::Page, width_before: f64) {
let width = pg.margin_rect().width();
if (width - width_before).abs() <= GUTTER_TOLERANCE {
return;
}
let Some(positions) = pg.columns.positions.as_mut() else { return };
let factor = width / width_before;
if factor.is_finite() && factor > 0.0 {
positions.iter_mut().for_each(|p| *p *= factor);
// The ends sit exactly on the margins whatever the rounding.
if let Some(first) = positions.first_mut() {
*first = 0.0;
}
if let Some(last) = positions.last_mut() {
*last = width;
}
}
if pg.columns.valid_positions(width).is_none() {
pg.columns.positions = None;
}
}
/// Gives `pg` the page width `width`, scaling its dragged columns with the margin box.
fn set_page_width(pg: &mut designcraft_doc::Page, width: f64) {
let before = pg.margin_rect().width();
pg.width = width;
scale_columns(pg, before);
}
/// How far a gutter may differ from the page's and still count as unchanged, in points. The
/// dialog shows the gutter in the ruler unit, rounded (inches to four decimals: up to 0.0036 pt
/// off), so an OK that leaves the field alone sends back a slightly different number.
const GUTTER_TOLERANCE: f64 = 0.01;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -197,8 +246,9 @@ pub fn specs() -> Vec<CommandSpec> {
let nsp = std::sync::Arc::make_mut(&mut d.spreads[nsi]);
let pg = &mut nsp.pages[npi];
let dx = pg.x - src.x;
(pg.width, pg.height, pg.margins, pg.columns, pg.liquid) =
(src.width, src.height, src.margins, src.columns.clone(), src.liquid);
// A copy on the other side of the spine has its dragged columns mirrored.
let columns = pg.columns_from(src);
(pg.width, pg.height, pg.margins, pg.columns, pg.liquid) = (src.width, src.height, src.margins, columns, src.liquid);
pg.guides = src.guides.clone();
for g in pg.guides.iter_mut().filter(|g| g.orientation == designcraft_doc::Orientation::Vertical) {
g.position += dx;
@@ -252,8 +302,20 @@ pub fn specs() -> Vec<CommandSpec> {
let name = str_param(p, "name").unwrap_or("Parent").to_string();
s.edit(|d, _| {
let prefix = str_param(p, "prefix").map(str::to_string).unwrap_or_else(|| d.next_parent_prefix());
let m = d.page(0).map(|p| (p.columns.count, p.columns.gutter, p.margins)).unwrap_or((1, 12.0, Margins::uniform(36.0)));
let id = d.add_parent(&prefix, &name, m.0, m.1, m.2);
let src = d.page(0).cloned();
let (columns, margins) =
src.as_ref().map_or_else(|| (designcraft_doc::Columns::default(), Margins::uniform(36.0)), |p| (p.columns.clone(), p.margins));
let id = d.add_parent(&prefix, &name, columns.count, columns.gutter, margins);
// Dragged column guides carry over to the parent pages they still fit, mirrored
// on the parent page across the spine from the first page.
if let (Some(src), Some(sp)) = (&src, d.parents.iter_mut().find(|x| x.id == id)) {
for pg in &mut std::sync::Arc::make_mut(sp).pages {
let columns = pg.columns_from(src);
if columns.valid_positions(pg.margin_rect().width()).is_some() {
pg.columns.positions = columns.positions;
}
}
}
Ok(json!({"id": id.0, "prefix": prefix}))
})
}),
@@ -368,6 +430,15 @@ pub fn specs() -> Vec<CommandSpec> {
})
}
),
cmd!(
"layout.moveColumnGuide",
"Move Column Guide",
[],
None,
"{spread?, parent? (a parent's prefix, label or index: its spread instead of `spread`), page (index in the spread), gutter (0 = between the first two columns), position (the gutter's new left edge, spread x)} → {positions (each column's left and right edge from the margin box's left edge), left (where the gutter's left edge went, spread x)} — both guides of the gutter move, keeping its width, and stop at the neighbouring guides and the margins; a document page takes the result as its own columns, a parent page passes it to the pages that follow it; refused while column guides are locked",
column_guides_unlocked,
move_column_guide
),
cmd!(
"layout.documentSetup",
"Document Setup…",
@@ -477,7 +548,7 @@ pub fn specs() -> Vec<CommandSpec> {
for sp in d.spreads.iter_mut().chain(d.parents.iter_mut()) {
let sp = std::sync::Arc::make_mut(sp);
for pg in &mut sp.pages {
pg.width = d.settings.page_width;
set_page_width(pg, d.settings.page_width);
pg.height = d.settings.page_height;
}
sp.relayout();
@@ -808,6 +879,52 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// Column guides can be dragged: a document is open and View › Grids & Guides › Lock Column
/// Guides is off.
fn column_guides_unlocked(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
match s.active() {
Some(st) if st.doc.settings.column_guides_locked => Err("column guides are locked (View › Grids & Guides › Lock Column Guides)".into()),
_ => Ok(()),
}
}
/// A column guide dragged with the Selection tool: gutter `gutter` of a page moves to `position`.
fn move_column_guide(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "layout.moveColumnGuide";
let index = |key: &str| -> Result<usize> {
let v = p.get(key).ok_or_else(|| bad(CMD, format!("missing `{key}`")))?;
v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(CMD, format!("`{key}` must be a non-negative integer")))
};
let (page, gutter) = (index("page")?, index("gutter")?);
let position =
p.get("position").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad(CMD, "`position` must be a finite number"))?;
let r = match p.get("parent") {
None | Some(Value::Null) => super::spread_param(p, CMD)?,
Some(v) => designcraft_doc::SpreadRef::Parent(parent_spread_index(&s.doc()?.doc, v).ok_or_else(|| bad(CMD, "no such parent"))?),
};
s.edit(|d, _| {
if d.settings.column_guides_locked {
return Err(bad(CMD, "column guides are locked"));
}
let pg = d.spread_mut(r).ok_or_else(|| bad(CMD, "no such spread"))?.pages.get_mut(page).ok_or_else(|| bad(CMD, "no such page"))?;
let m = pg.margin_rect();
let count = pg.columns.count;
if gutter.checked_add(1).is_none_or(|g| g >= count as usize) {
return Err(bad(CMD, format!("the page has {} gutters", count.saturating_sub(1))));
}
let positions =
pg.columns.with_gutter_moved(m.width(), gutter, position - m.x0).ok_or_else(|| bad(CMD, "the columns cannot take that position"))?;
let left = positions.get(gutter * 2 + 1).map_or(position, |v| m.x0 + v);
pg.columns.positions = Some(positions.clone());
match r {
designcraft_doc::SpreadRef::Doc(_) => pg.local_margins = true,
designcraft_doc::SpreadRef::Parent(_) => d.sync_parent_margins(),
}
Ok(json!({"positions": positions, "left": left}))
})
}
/// A parent spread named by prefix, label (`A-Parent`) or index.
fn parent_spread_index(d: &designcraft_doc::Document, v: &Value) -> Option<usize> {
match v {
@@ -957,7 +1074,7 @@ pub(crate) fn resize_pages_from(d: &mut designcraft_doc::Document, pages: &[usiz
let dw = w.map_or(0.0, |w| w.max(1.0) - pg.width);
let dh = h.map_or(0.0, |h| h.max(1.0) - pg.height);
if let Some(w) = w {
pg.width = w.max(1.0);
set_page_width(pg, w.max(1.0));
}
if let Some(h) = h {
pg.height = h.max(1.0);
@@ -1650,6 +1767,191 @@ mod adjust_tests {
// An unknown parent is an error, not a silent no-op.
assert!(s.execute("layout.marginsAndColumns", &json!({"parent": 9, "margins": 10})).is_err());
}
/// Margins and Columns re-divides the columns: a new count or gutter replaces column guides
/// that were dragged (an imported Custom columns page), rather than being ignored by them.
#[test]
fn a_new_column_count_replaces_dragged_column_positions() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap();
// There is no command for dragging a column guide yet, so set what an import would leave.
let drag = |s: &mut Session| {
s.edit(|d, _| {
let pg = &mut std::sync::Arc::make_mut(&mut d.spreads[0]).pages[0];
pg.columns = designcraft_doc::Columns { count: 2, gutter: 12.0, positions: Some(vec![0.0, 100.0, 130.0, 540.0]) };
Ok(())
})
.unwrap();
};
drag(&mut s);
let dragged = s.doc().unwrap().doc.page(0).unwrap().column_rects();
assert_eq!(dragged.len(), 2);
assert!((dragged[0].x1 - dragged[0].x0 - 100.0).abs() < 1e-6, "the dragged gutter is in use: {dragged:?}");
// A new count: evenly divided again, and the stale positions are gone.
s.execute("layout.marginsAndColumns", &json!({"columns": 3})).unwrap();
let pg = s.doc().unwrap().doc.page(0).unwrap().clone();
assert_eq!((pg.columns.count, pg.columns.positions.clone()), (3, None));
assert_eq!(pg.column_rects().len(), 3);
// So does a new gutter on its own.
drag(&mut s);
s.execute("layout.marginsAndColumns", &json!({"gutter": 24})).unwrap();
let pg = s.doc().unwrap().doc.page(0).unwrap().clone();
assert_eq!(pg.columns.positions, None);
assert!((pg.column_rects()[1].x0 - pg.column_rects()[0].x1 - 24.0).abs() < 1e-6);
// Margins that keep the margin box's width leave them alone; a new width scales them.
drag(&mut s);
s.execute("layout.marginsAndColumns", &json!({"margins": {"top": 72, "bottom": 72, "inside": 36, "outside": 36}})).unwrap();
assert_eq!(s.doc().unwrap().doc.page(0).unwrap().columns.positions, Some(vec![0.0, 100.0, 130.0, 540.0]));
s.execute("layout.marginsAndColumns", &json!({"margins": 72})).unwrap();
assert_scaled(s.doc().unwrap().doc.page(0).unwrap().columns.positions.clone(), 468.0);
}
/// The params the Margins and Columns dialog sends on OK: every margin, the count and the
/// gutter, whether or not the user changed them.
fn dialog_params(margin: f64, columns: u32, gutter: f64) -> serde_json::Value {
json!({"margins": {"top": margin, "bottom": margin, "inside": margin, "outside": margin},
"columns": columns, "gutter": gutter, "adjustLayout": false})
}
const DRAGGED: [f64; 4] = [0.0, 100.0, 130.0, 540.0];
fn dragged() -> designcraft_doc::Columns {
designcraft_doc::Columns { count: 2, gutter: 12.0, positions: Some(DRAGGED.to_vec()) }
}
/// `positions` are [`DRAGGED`] scaled from its 540 pt margin box to one `width` wide.
#[track_caller]
fn assert_scaled(positions: Option<Vec<f64>>, width: f64) {
let want: Vec<f64> = DRAGGED.iter().map(|p| p * width / 540.0).collect();
let got = positions.unwrap_or_default();
assert!(got.len() == want.len() && got.iter().zip(&want).all(|(g, w)| (g - w).abs() < 1e-6), "{got:?} ≠ {want:?}");
}
/// An OK in the dialog keeps dragged columns unless the count or gutter it sends differs.
#[test]
fn the_dialog_keeps_dragged_columns_unless_the_count_or_gutter_changes() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap();
let drag = |s: &mut Session| {
s.edit(|d, _| {
std::sync::Arc::make_mut(&mut d.spreads[0]).pages[0].columns = dragged();
Ok(())
})
.unwrap();
};
let positions = |s: &Session| s.doc().unwrap().doc.page(0).unwrap().columns.positions.clone();
// The same count and gutter (the gutter as an inch field rounds it) keep them in use.
drag(&mut s);
s.execute("layout.marginsAndColumns", &dialog_params(36.0, 2, 12.0)).unwrap();
assert_eq!(positions(&s), Some(DRAGGED.to_vec()));
s.execute("layout.marginsAndColumns", &dialog_params(36.0, 2, 0.1667 * 72.0)).unwrap();
assert_eq!(positions(&s), Some(DRAGGED.to_vec()));
let rects = s.doc().unwrap().doc.page(0).unwrap().column_rects();
assert!((rects[0].x1 - rects[0].x0 - 100.0).abs() < 1e-6, "the dragged columns are in use: {rects:?}");
// Top and bottom margins leave the margin box's width alone, and the columns with it.
let mut taller = dialog_params(36.0, 2, 12.0);
taller["margins"]["top"] = json!(72.0);
taller["margins"]["bottom"] = json!(72.0);
s.execute("layout.marginsAndColumns", &taller).unwrap();
assert_eq!(positions(&s), Some(DRAGGED.to_vec()));
// A new width scales them with the margin box, and the old width scales them back.
s.execute("layout.marginsAndColumns", &dialog_params(72.0, 2, 12.0)).unwrap();
assert_scaled(positions(&s), 468.0);
let rects = s.doc().unwrap().doc.page(0).unwrap().column_rects();
assert!((rects[0].x1 - rects[0].x0 - 100.0 * 468.0 / 540.0).abs() < 1e-6, "the scaled columns are in use: {rects:?}");
s.execute("layout.marginsAndColumns", &dialog_params(36.0, 2, 12.0)).unwrap();
assert_scaled(positions(&s), 540.0);
// A new count resets them.
drag(&mut s);
s.execute("layout.marginsAndColumns", &dialog_params(36.0, 3, 12.0)).unwrap();
let pg = s.doc().unwrap().doc.page(0).unwrap().clone();
assert_eq!((pg.columns.count, pg.columns.positions), (3, None));
// So does a new gutter.
drag(&mut s);
s.execute("layout.marginsAndColumns", &dialog_params(36.0, 2, 18.0)).unwrap();
let pg = s.doc().unwrap().doc.page(0).unwrap().clone();
assert_eq!((pg.columns.gutter, pg.columns.positions), (18.0, None));
}
/// A page of another width (Page Size, Document Setup) scales its dragged columns.
#[test]
fn a_new_page_width_scales_dragged_columns() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap();
let drag = |s: &mut Session| {
s.edit(|d, _| {
// A dragged page's columns are its own (`local_margins`), as the drag will make them.
let pg = &mut std::sync::Arc::make_mut(&mut d.spreads[0]).pages[0];
pg.columns = dragged();
pg.local_margins = true;
Ok(())
})
.unwrap();
};
let positions = |s: &Session| s.doc().unwrap().doc.page(0).unwrap().columns.positions.clone();
drag(&mut s);
s.execute("layout.pageSize", &json!({"pages": [1], "height": 900})).unwrap();
assert_eq!(positions(&s), Some(DRAGGED.to_vec()), "a new height keeps them");
s.execute("layout.pageSize", &json!({"pages": [1], "width": 700})).unwrap();
assert_scaled(positions(&s), 628.0);
s.execute("layout.pageSize", &json!({"pages": [1], "width": 612})).unwrap();
assert_scaled(positions(&s), 540.0);
drag(&mut s);
s.execute("layout.documentSetup", &json!({"width": 700})).unwrap();
assert_scaled(positions(&s), 628.0);
}
/// Parent pages follow the same rule, and the document pages that follow them see it.
#[test]
fn the_dialog_keeps_a_parents_dragged_columns_unless_the_count_or_gutter_changes() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap();
let drag = |s: &mut Session| {
s.edit(|d, _| {
std::sync::Arc::make_mut(&mut d.parents[0]).pages[0].columns = dragged();
d.sync_parent_margins();
Ok(())
})
.unwrap();
};
let positions = |s: &Session| {
let d = &s.doc().unwrap().doc;
(d.parents[0].pages[0].columns.positions.clone(), d.page(0).unwrap().columns.positions.clone())
};
let on_parent = |m: f64, c: u32, g: f64| {
let mut p = dialog_params(m, c, g);
p["parent"] = json!(0);
p
};
drag(&mut s);
assert_eq!(positions(&s), (Some(DRAGGED.to_vec()), Some(DRAGGED.to_vec())));
s.execute("layout.marginsAndColumns", &on_parent(36.0, 2, 12.0)).unwrap();
assert_eq!(positions(&s), (Some(DRAGGED.to_vec()), Some(DRAGGED.to_vec())));
s.execute("layout.marginsAndColumns", &on_parent(48.0, 2, 12.0)).unwrap();
let (parent, follower) = positions(&s);
assert_scaled(parent, 516.0);
assert_scaled(follower, 516.0);
drag(&mut s);
s.execute("layout.marginsAndColumns", &on_parent(36.0, 3, 12.0)).unwrap();
assert_eq!(positions(&s), (None, None));
drag(&mut s);
s.execute("layout.marginsAndColumns", &on_parent(36.0, 2, 24.0)).unwrap();
assert_eq!(positions(&s), (None, None));
}
}
#[cfg(test)]
@@ -1948,6 +2250,146 @@ mod parent_tests {
assert_eq!(r, json!({"on": false, "parent": null}));
assert!(!s.doc().unwrap().editing_parents);
}
/// A new parent takes the first page's columns, dragged column guides included: the right
/// parent page as the first (right) page has them, the left one mirrored across the spine.
/// `document.inspect` reports where those columns are.
#[test]
fn a_new_parent_keeps_dragged_columns() {
let mut s = crate::Session::new();
s.execute("file.new", &json!({"pages": 1})).unwrap();
let dragged = designcraft_doc::Columns { count: 2, gutter: 12.0, positions: Some(vec![0.0, 100.0, 130.0, 540.0]) };
// There is no command for dragging a column guide yet, so set what an import would leave.
s.edit(|d, _| {
let pg = &mut std::sync::Arc::make_mut(&mut d.spreads[0]).pages[0];
(pg.columns, pg.local_margins) = (dragged.clone(), true);
Ok(())
})
.unwrap();
let id = s.execute("layout.parents.new", &json!({"prefix": "B"})).unwrap()["id"].as_u64().unwrap();
let d = &s.doc().unwrap().doc;
let parent = d.parents.iter().find(|p| p.id.0 == id).unwrap();
assert_eq!(parent.pages.len(), 2);
let side = |s: designcraft_doc::PageSide| parent.pages.iter().find(|p| p.side == s).unwrap();
let (left, right) = (side(designcraft_doc::PageSide::Left), side(designcraft_doc::PageSide::Right));
assert_eq!(right.columns, dragged);
assert_eq!(right.column_rects().iter().map(|r| r.width()).collect::<Vec<_>>(), vec![100.0, 410.0]);
assert_eq!(left.columns.positions, Some(vec![0.0, 410.0, 440.0, 540.0]));
assert_eq!(left.column_rects().iter().map(|r| r.width()).collect::<Vec<_>>(), vec![410.0, 100.0]);
let inspect = s.execute("document.inspect", &json!({})).unwrap();
let page = &inspect["spreads"][0]["pages"][0];
assert_eq!((page["columns"].clone(), page["customColumns"].clone()), (json!(2), json!(true)), "{page}");
assert_eq!(page["columnRects"][0], json!([36.0, 36.0, 136.0, 756.0]), "{page}");
}
fn columns_of(s: &crate::Session, abs: usize) -> (Option<Vec<f64>>, bool) {
let pg = s.doc().unwrap().doc.page(abs).unwrap().clone();
(pg.columns.positions, pg.local_margins)
}
/// Three columns across a 540 pt margin box (36 pt margins), column guides unlocked.
fn three_columns(pages: u64) -> crate::Session {
let mut s = crate::Session::new();
s.execute("file.new", &json!({"pages": pages})).unwrap();
s.execute("layout.marginsAndColumns", &json!({"parent": "A", "columns": 3, "gutter": 12})).unwrap();
s.execute("view.lockColumnGuides", &json!({"on": false})).unwrap();
s
}
/// Dragging a column guide moves its gutter's two guides together, stops at the neighbouring
/// gutter and the margin, makes a following page's columns its own, and undoes in one step.
#[test]
fn move_column_guide_moves_the_gutter_pair_within_its_neighbours() {
let mut s = three_columns(1);
assert_eq!(columns_of(&s, 0), (None, false), "the page follows A");
let mv =
|s: &mut crate::Session, gutter: u64, x: f64| s.execute("layout.moveColumnGuide", &json!({"page": 0, "gutter": gutter, "position": x}));
let r = mv(&mut s, 0, 236.0).unwrap();
assert_eq!(r, json!({"positions": [0.0, 200.0, 212.0, 356.0, 368.0, 540.0], "left": 236.0}));
assert_eq!(columns_of(&s, 0), (Some(vec![0.0, 200.0, 212.0, 356.0, 368.0, 540.0]), true));
let inspect = s.execute("document.inspect", &json!({})).unwrap();
assert_eq!(inspect["spreads"][0]["pages"][0]["columnRects"][0], json!([36.0, 36.0, 236.0, 756.0]));
// Past the next gutter: it stops there, and the middle column has no width.
assert_eq!(mv(&mut s, 0, 1000.0).unwrap()["left"], json!(36.0 + 344.0));
// Past the right margin: the last gutter stops 12 pt (its width) inside it.
assert_eq!(mv(&mut s, 1, 1e12).unwrap()["positions"], json!([0.0, 344.0, 356.0, 528.0, 540.0, 540.0]));
// Past the left margin.
assert_eq!(mv(&mut s, 0, -1e12).unwrap()["positions"], json!([0.0, 0.0, 12.0, 528.0, 540.0, 540.0]));
for _ in 0..3 {
s.execute("edit.undo", &json!({})).unwrap();
}
assert_eq!(columns_of(&s, 0), (Some(vec![0.0, 200.0, 212.0, 356.0, 368.0, 540.0]), true));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(columns_of(&s, 0), (None, false), "one undo step per drag");
}
/// On a parent page the drag changes the parent, and the document pages that follow it get
/// the new columns; a page with columns of its own keeps them.
#[test]
fn move_column_guide_on_a_parent_reaches_the_pages_that_follow_it() {
let mut s = three_columns(3);
// Page 1, the left page of spread 1, gets columns of its own.
let x = s.doc().unwrap().doc.spreads[1].pages[0].margin_rect().x0 + 300.0;
s.execute("layout.moveColumnGuide", &json!({"spread": 1, "page": 0, "gutter": 1, "position": x})).unwrap();
let own = columns_of(&s, 1);
assert!(own.1 && own.0.is_some());
let d = &s.doc().unwrap().doc;
// A's right page: the one page 0 (a right page) and page 2 follow.
let x0 = d.parents[0].pages[1].margin_rect().x0;
s.execute("layout.moveColumnGuide", &json!({"parent": "A", "page": 1, "gutter": 0, "position": x0 + 100.0})).unwrap();
let moved = vec![0.0, 100.0, 112.0, 356.0, 368.0, 540.0];
assert_eq!(s.doc().unwrap().doc.parents[0].pages[1].columns.positions, Some(moved.clone()));
assert_eq!(columns_of(&s, 0), (Some(moved.clone()), false));
assert_eq!(columns_of(&s, 2), (Some(moved), false));
assert_eq!(columns_of(&s, 1), own, "page 1's columns are its own");
// The same parent page by spread reference.
let spread = json!({"kind": "parent", "index": 0});
s.execute("layout.moveColumnGuide", &json!({"spread": spread, "page": 1, "gutter": 0, "position": x0 + 50.0})).unwrap();
assert_eq!(columns_of(&s, 0).0.unwrap()[1], 50.0);
}
/// A drag's preview in one document is put back there when another document became active.
#[test]
fn a_column_guide_preview_is_cancelled_in_its_own_document() {
let mut s = three_columns(1);
let uid = s.doc().unwrap().uid;
s.begin_interaction("Move Column Guide").unwrap();
s.preview("layout.moveColumnGuide", json!({"page": 0, "gutter": 0, "position": 236})).unwrap();
s.execute("file.new", &json!({})).unwrap();
s.cancel_interaction_of(uid);
s.set_active(0);
assert!(s.doc().unwrap().interaction.is_none());
assert_eq!(columns_of(&s, 0), (None, false));
}
#[test]
fn move_column_guide_refuses_while_locked_and_hostile_params() {
let mut s = three_columns(1);
s.execute("view.lockColumnGuides", &json!({"on": true})).unwrap();
assert!(s.execute("layout.moveColumnGuide", &json!({"page": 0, "gutter": 0, "position": 200})).is_err());
assert_eq!(columns_of(&s, 0), (None, false));
s.execute("view.lockColumnGuides", &json!({"on": false})).unwrap();
for p in [
json!({"page": 0, "gutter": 0}),
json!({"page": 0, "gutter": 0, "position": f64::NAN}),
json!({"page": 0, "gutter": 0, "position": "200"}),
json!({"page": 0, "gutter": 2, "position": 200}),
json!({"page": 0, "gutter": u64::MAX, "position": 200}),
json!({"page": 0, "gutter": -1, "position": 200}),
json!({"page": 0, "gutter": 0.5, "position": 200}),
json!({"page": 9, "gutter": 0, "position": 200}),
json!({"page": u64::MAX, "gutter": 0, "position": 200}),
json!({"gutter": 0, "position": 200}),
json!({"spread": 99, "page": 0, "gutter": 0, "position": 200}),
json!({"parent": "Z", "page": 0, "gutter": 0, "position": 200}),
json!({"page": 0, "gutter": 0, "position": f64::MAX}),
] {
let r = s.execute("layout.moveColumnGuide", &p);
// A huge but finite position is clamped to the margin; everything else is refused.
assert!(r.is_err() || p["position"] == json!(f64::MAX), "{p}: {r:?}");
}
}
}
#[cfg(test)]
+2 -1
View File
@@ -17,10 +17,11 @@ mod endnotes;
mod export;
mod file;
pub(crate) mod find;
pub use edit::copied_objects_text;
pub use find::same_find_domain;
pub(crate) mod flex;
mod fonts;
mod guides;
pub(crate) mod guides;
mod index;
mod inspect;
mod interactive;
+2 -1
View File
@@ -1074,7 +1074,8 @@ fn adopt_or_dangle(dst: &mut Document, src: &Document, id: AssetId) -> AssetId {
dst.adopt_asset(src, id).unwrap_or_else(|| AssetId(dst.alloc()))
}
fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut HashMap<StoryId, StoryId>) {
/// Give `it` (an item of `src`) fresh ids from `dst`, with copies of its stories and the assets it uses.
pub(crate) fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut HashMap<StoryId, StoryId>) {
it.id = ItemId(dst.alloc());
let my_id = it.id;
if let Some(m) = it.media.as_mut()
+3 -2
View File
@@ -135,8 +135,9 @@ impl OutputRoot {
{
use std::os::unix::fs::PermissionsExt;
// Match fs::write for a new output: 0666 restricted by the caller's umask.
builder.permissions(std::fs::Permissions::from_mode(0o666));
// Like fs::write for a new output: request 0666 and let the process umask decide the
// effective mode (0644 with the usual 022, 0664 with 002).
builder.permissions(std::fs::Permissions::from_mode(0o666)); // NOSONAR
}
let file = builder.tempfile_in(parent).map_err(|e| path_error(path, e))?;
#[cfg(unix)]
+38 -1
View File
@@ -277,7 +277,7 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s
if added >= max_pages {
return Err(bad(cmd, format!("autoflow stopped after {max_pages} pages")));
}
let per_page = if vertical { 1 } else { d.page(page).map_or(1, |p| p.columns.count.max(1) as usize) };
let per_page = if vertical { 1 } else { d.page(page).map_or(1, |p| p.column_rects().len().max(1)) };
let pages = want.div_ceil(per_page).clamp(1, max_pages - added);
let parent = d.page_loc(page).and_then(|(si, pi)| d.spreads[si].pages[pi].parent);
new_pages.extend(d.insert_pages(Some(page), pages, parent)?);
@@ -511,6 +511,43 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// Autoflow follows dragged column guides: one frame per dragged column, on the first page and
/// on every page it adds.
#[test]
fn autoflow_sets_frames_on_dragged_columns() {
let dir = std::env::temp_dir().join(format!("dc-place-dragged-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("long.txt");
std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 40].join("\n")).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({"preset": "Letter", "pages": 1, "columns": 2, "gutter": 18, "margins": 54})).unwrap();
// The margin box is 504 pt wide; the gutter is dragged left. There is no command for
// dragging a column guide yet, so set what an import would leave, on the parent.
let dragged = designcraft_doc::Columns { count: 2, gutter: 18.0, positions: Some(vec![0.0, 150.0, 170.0, 504.0]) };
s.edit(|d, _| {
for pg in &mut std::sync::Arc::make_mut(&mut d.parents[0]).pages {
pg.columns = dragged.clone();
}
d.sync_parent_margins();
Ok(())
})
.unwrap();
let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "page": 1, "autoflow": true})).unwrap();
assert_eq!(r["overset"], false, "{r}");
let d = &s.doc().unwrap().doc;
assert!(d.page_count() >= 3, "{r}");
let frames = &d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames;
assert!(frames.len() > 2 * (d.page_count() - 1), "{} frames", frames.len());
for (i, f) in frames.iter().enumerate() {
let page = d.page_of_item(*f).unwrap();
assert_eq!(page, i / 2, "frame {i}");
let m = d.page(page).unwrap().margin_rect();
let (x0, x1) = [(0.0, 150.0), (170.0, 504.0)][i % 2];
assert_eq!(d.item(*f).unwrap().bounds(), Rect::new(m.x0 + x0, m.y0, m.x0 + x1, m.y1), "frame {i}");
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn autoflow_keeps_the_frame_the_user_drew() {
let dir = std::env::temp_dir().join(format!("dc-place-drawn-{}", std::process::id()));
+11
View File
@@ -571,6 +571,7 @@ impl Session {
Ok(r) => r,
Err(e) => {
// What it did before it failed can be undone too.
self.keep_guide_selection_valid(before.as_ref(), id);
self.record_undo_step(spec, before, nested);
return Err(e);
}
@@ -613,6 +614,7 @@ impl Session {
}
}
}
self.keep_guide_selection_valid(before.as_ref(), id);
self.record_undo_step(spec, before, nested);
if spec.journal {
self.journal.push((id.to_string(), params.clone()));
@@ -621,6 +623,15 @@ impl Session {
Ok(r)
}
/// Drops selected guides the command `id` made stale, against the document as it will be
/// recorded: after the passes that run with the command (smart reflow, live captions, autosize).
fn keep_guide_selection_valid(&mut self, before: Option<&(u64, Arc<Document>, Selection)>, id: &str) {
if let Some(st) = self.active_mut() {
let old = before.filter(|(uid, _, _)| *uid == st.uid).map(|(_, d, sel)| (d.as_ref(), sel));
cmd::guides::keep_selection_valid(st, old, id);
}
}
/// Record undo if the document changed (and we're not previewing an interaction), with the
/// selection from before the command. A command run by an undoable one leaves the step to it:
/// one command is one undo step.
+2 -1
View File
@@ -294,8 +294,9 @@ fn cmd_move_does_not_snap() {
fn cmd_resize_still_snaps_to_a_guide() {
use designcraft_tools::{PointerEvent, PointerKind};
let mut s = session();
s.execute("frame.create", &json!({"rect": [100.0, 100.0, 200.0, 180.0], "content": "unassigned"})).unwrap();
// The new guide is selected: the frame created after it is the selection.
s.execute("guide.add", &json!({"orientation": "vertical", "position": 250.0, "page": 0})).unwrap();
s.execute("frame.create", &json!({"rect": [100.0, 100.0, 200.0, 180.0], "content": "unassigned"})).unwrap();
s.set_tool("selection");
let v = ViewInfo::at_zoom(1.0);
let b0 = s.doc().unwrap().doc.spreads[0].items[0].bounds();
+14
View File
@@ -331,4 +331,18 @@ impl Session {
}
}
}
/// Cancel the interaction of the document with `uid`, active or not: a gesture that began in
/// one document and lost it to another tab ends there, leaving the document as it began.
pub fn cancel_interaction_of(&mut self, uid: u64) {
if self.active().is_some_and(|st| st.uid == uid) {
self.cancel_interaction();
} else if let Some(st) = self.docs.iter_mut().find(|st| st.uid == uid)
&& let Some(it) = st.interaction.take()
{
st.doc = it.doc;
st.selection = it.selection;
st.revision = it.revision;
}
}
}
@@ -1,5 +1,3 @@
#![cfg(not(target_arch = "wasm32"))]
use designcraft_engine::Session;
use serde_json::json;
use std::path::Path;
@@ -1,5 +1,4 @@
//! Browser Print must reject native spooler operations without trapping the editor.
#![cfg(target_arch = "wasm32")]
use designcraft_engine::Session;
use serde_json::json;
@@ -1,5 +1,3 @@
#![cfg(not(target_arch = "wasm32"))]
use designcraft_engine::Session;
use serde_json::json;
+23
View File
@@ -0,0 +1,23 @@
#[cfg(not(target_arch = "wasm32"))]
mod book_safety;
#[cfg(target_arch = "wasm32")]
mod browser_print;
mod character_style_from_selection;
mod close_retention;
#[cfg(not(target_arch = "wasm32"))]
mod collection_state_atomicity;
mod combined_close_glyph;
mod distribute_locks;
mod export_page_input;
mod insert_pages_bounds;
mod param_keys;
mod pdf_backend;
mod preflight_resolution;
mod primary_frame_columns;
#[cfg(not(target_arch = "wasm32"))]
mod save_state_atomicity;
mod stroke_tint_range;
mod text_decorations;
mod text_insert_memory;
mod text_thread_regressions;
mod thread_color_safety;
@@ -1,5 +1,3 @@
#![cfg(not(target_arch = "wasm32"))]
use std::path::Path;
use std::sync::Arc;
+2
View File
@@ -0,0 +1,2 @@
mod export_tag_styles;
mod grouped_reading_order;
+4
View File
@@ -0,0 +1,4 @@
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod system_fonts;
@@ -1,7 +1,5 @@
//! A new session finds the installed system fonts by family name (#22). One test in its own
//! process, so the process-wide database starts fresh, as it does when the app starts.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use designcraft_fonts::{FontDb, system_font_dirs};
+5 -4
View File
@@ -8,7 +8,7 @@ use designcraft_doc::{
CharAttrs, CharFormat, ColumnsKind, Content, Document, Item, ListType, PageSide, ParaAttrs, ParaFormat, Rule, Shape, SpanColumns, Spread, Story,
TextFrameOptions, story as st,
};
use designcraft_geom::{Affine, PathData, Rect};
use designcraft_geom::{Affine, PathData};
use crate::names::{self, CHAR_BUILTINS, OBJECT_BUILTINS, PARA_BUILTINS, escape_id, num, pt};
use crate::xml::{El, Node, document, document_with_pi};
@@ -769,7 +769,7 @@ impl<'a> Ex<'a> {
},
)
.attr("CreatePrimaryTextFrame", bool_s(s.primary_text_frame))
.attr("ColumnGuideLocked", "true")
.attr("ColumnGuideLocked", bool_s(s.column_guides_locked))
.attr("MasterTextFrame", bool_s(s.primary_text_frame))
.attr("SnippetImportUsesOriginalLocation", "false"),
);
@@ -2901,8 +2901,9 @@ impl Group {
fn margin_el(pg: &designcraft_doc::Page) -> El {
let m = pg.margin_rect();
let cols = designcraft_doc::column_rects(Rect::new(0.0, 0.0, m.width(), m.height()), pg.columns.count.max(1), pg.columns.gutter);
let pos: Vec<String> = cols.iter().flat_map(|c| [num(c.x0), num(c.x1)]).collect();
// Dragged column guides that still fit the margin box are written as they stand, so they
// survive the round trip; otherwise the even split, which InDesign writes for unmoved columns.
let pos: Vec<String> = pg.columns.edges(m.width()).into_iter().map(num).collect();
El::new("MarginPreference")
.attr("ColumnCount", pg.columns.count.max(1))
.attr("ColumnGutter", num(pg.columns.gutter))
+86 -6
View File
@@ -850,6 +850,9 @@ impl<'r> Importer<'r> {
if let Some(v) = e.boolean("CreatePrimaryTextFrame") {
s.primary_text_frame = v;
}
if let Some(v) = e.boolean("ColumnGuideLocked") {
s.column_guides_locked = v;
}
}
fn grid_prefs(&mut self, e: &El) {
@@ -2534,9 +2537,15 @@ impl<'r> Importer<'r> {
.map(|q| (q.margins, q.columns.clone()))
};
let (margins, columns) = match (p.find("MarginPreference"), &from_parent) {
(Some(m), _) => margins_of(m),
(Some(m), _) => {
let (margins, columns) = margins_of(m);
(margins, fit_columns(columns, &margins, r.width()))
}
(None, Some(own)) => own.clone(),
(None, None) => self.default_margins.clone().unwrap_or((Margins::uniform(36.0), Columns::default())),
(None, None) => {
let (margins, columns) = self.default_margins.clone().unwrap_or((Margins::uniform(36.0), Columns::default()));
(margins, fit_columns(columns, &margins, r.width()))
}
};
let local_margins = from_parent.is_some_and(|(m, c)| m != margins || c != columns);
out_pages.push(Page {
@@ -3465,10 +3474,39 @@ fn composer_in(name: &str) -> designcraft_doc::Composer {
fn margins_of(m: &El) -> (Margins, Columns) {
let g = |k: &str, d: f64| m.num(k).filter(|v| v.is_finite()).unwrap_or(d);
(
Margins { top: g("Top", 36.0), bottom: g("Bottom", 36.0), inside: g("Left", 36.0), outside: g("Right", 36.0) },
Columns { count: g("ColumnCount", 1.0).clamp(1.0, 216.0) as u32, gutter: g("ColumnGutter", 12.0), positions: None },
)
let margins = Margins { top: g("Top", 36.0), bottom: g("Bottom", 36.0), inside: g("Left", 36.0), outside: g("Right", 36.0) };
let count = g("ColumnCount", 1.0).clamp(1.0, f64::from(designcraft_doc::MAX_COLUMNS)) as u32;
let gutter = g("ColumnGutter", 12.0);
let positions = m.get("ColumnsPositions").and_then(|s| custom_columns(s, count, gutter));
(margins, Columns { count, gutter, positions })
}
/// `ColumnsPositions` when the column guides have been dragged off the even split: `2 × count`
/// offsets from the margin box's left edge, valid as [`designcraft_doc::valid_column_positions`]
/// over the box they span. InDesign writes the attribute whether or not they have been moved, so
/// an even set is dropped: `positions` means "custom", and keeping an even copy would freeze the
/// columns against a later change of count or gutter. Anything malformed is the even split; at
/// most one number past `2 × count` is parsed, so a long list costs nothing.
fn custom_columns(s: &str, count: u32, gutter: f64) -> Option<Vec<f64>> {
let want = 2 * count as usize;
let pos = s.split_whitespace().take(want + 1).map(|v| v.parse::<f64>().ok()).collect::<Option<Vec<f64>>>()?;
let width = *pos.last()?;
if !designcraft_doc::valid_column_positions(&pos, count, width) {
return None;
}
let even = Columns { count, gutter, positions: None }.edges(width);
// The tolerance only absorbs float noise in InDesign's arithmetic; a dragged guide moves far more.
pos.iter().zip(&even).any(|(a, b)| (a - b).abs() > 1e-4).then_some(pos)
}
/// `columns` with custom positions only when they fit the margin box of a `page_width`-wide page
/// with these margins (the box's width doesn't depend on which side of the spine it is).
fn fit_columns(mut columns: Columns, margins: &Margins, page_width: f64) -> Columns {
let width = (page_width - margins.inside - margins.outside).max(0.0);
if columns.valid_positions(width).is_none() {
columns.positions = None;
}
columns
}
/// Text Frame Options from `TextFramePreference` elements: each attribute from the first that sets it.
@@ -3995,6 +4033,48 @@ mod page_column_import_safety_tests {
assert_eq!(margins, Margins::uniform(36.));
}
}
/// Hostile `ColumnsPositions` are the even split: never half-read, never a panic, and a long
/// list is not parsed past what `ColumnCount` allows (#1096).
#[test]
fn hostile_column_positions_fall_back_to_the_even_split() {
let positions = |count: &str, raw: &str| {
let m = El::new("MarginPreference").attr("ColumnCount", count).attr("ColumnGutter", "20").attr("ColumnsPositions", raw);
margins_of(&m).1.positions
};
assert_eq!(positions("2", "0 150 170 450"), Some(vec![0., 150., 170., 450.]));
let huge = (0..10_000).map(|i| i.to_string()).collect::<Vec<_>>().join(" ");
for (count, raw) in [
("2", "0 215 235 450"), // the even split
("2", "0 150 170"), // odd
("2", "0 450"), // too few for the count
("2", "0 100 120 200 220 450"), // too many for the count
("2", "0 150 90 450"), // descending
("2", "0 NaN 170 450"),
("2", "0 inf 170 450"),
("2", "0 150 170 1e309"),
("2", "0 x 170 450"),
("2", "10 150 170 450"), // not from the margin
("2", ""),
("2", huge.as_str()),
("4000000000", huge.as_str()), // the count is capped at 216, so is the list
("216", huge.as_str()),
("NaN", "0 150 170 450"),
] {
assert_eq!(positions(count, raw), None, "{count}: {}", raw.get(..raw.len().min(40)).unwrap_or(""));
}
// The cap itself: 216 one-point columns with no gutter are the even split; one edge moved
// makes them custom.
let gapless = |raw: &str| {
let m = El::new("MarginPreference").attr("ColumnCount", "216").attr("ColumnGutter", "0").attr("ColumnsPositions", raw);
margins_of(&m).1.positions
};
let edges = |first_right: &str| {
(0..432).map(|i| if i == 1 { first_right.to_string() } else { ((i + 1) / 2).to_string() }).collect::<Vec<_>>().join(" ")
};
assert_eq!(gapless(&edges("1")), None);
assert_eq!(gapless(&edges("0.5")).map(|p| p.len()), Some(432));
}
}
#[cfg(test)]
+2 -1
View File
@@ -17,7 +17,8 @@
//! ## Supported subset
//! Swatches (process/spot CMYK, RGB and Lab colours, tints, linear/radial gradients), paragraph,
//! character and object styles (with `BasedOn` and style groups), fonts, document preferences
//! (page size, facing pages, bleed/slug, margins and columns, units, grids), layers, sections,
//! (page size, facing pages, bleed/slug, margins and columns including dragged column positions,
//! units, grids), ruler guides, layers, sections,
//! parent (master) spreads and applied parents, spreads and pages, page items (`TextFrame`,
//! `Rectangle`, `Oval`, `Polygon`, `GraphicLine`, `Group`) with fill/stroke, corner options, text
//! frame options (with column rules), text wrap, transparency (opacity, blend mode, drop shadow,
+134
View File
@@ -2773,6 +2773,18 @@ fn round_trips_the_zero_point_and_ruler_origin() {
assert_eq!(fixture("NaN 4").settings.zero_point, [0.0, 0.0]);
}
#[test]
fn round_trips_lock_column_guides() {
let mut d = Document::new(&NewDocument::default());
assert!(zip_text(&export_idml(&d), "Resources/Preferences.xml").contains(r#"ColumnGuideLocked="true""#));
assert!(import_idml(&export_idml(&d)).unwrap().settings.column_guides_locked);
d.settings.column_guides_locked = false;
let bytes = export_idml(&d);
let prefs = zip_text(&bytes, "Resources/Preferences.xml");
assert!(prefs.contains(r#"ColumnGuideLocked="false""#), "{prefs}");
assert!(!import_idml(&bytes).unwrap().settings.column_guides_locked);
}
#[test]
fn kenten_shatai_and_grid_settings_import_and_round_trip() {
use designcraft_doc::cjk::CharacterAlignment;
@@ -4194,6 +4206,128 @@ mod liquid_page_rules {
}
}
/// A dragged column gutter (InDesign's Custom columns) survives an IDML import, and an even set —
/// which InDesign writes for every page, moved or not — does not pretend to be custom (#1096).
#[test]
fn custom_column_positions_import_and_round_trip() {
// Page 1 of SPREAD is 500 wide with Left=20 Right=30, so its margin box is 450 wide and
// starts at x=30. Two columns with gutter 20 split evenly as "0 215 235 450".
let with = |cols: &str| {
let spread = SPREAD.replace(
r#"<MarginPreference ColumnCount="3" ColumnGutter="12" Top="10" Bottom="10" Left="20" Right="30"/>"#,
&format!(
r#"<MarginPreference ColumnCount="2" ColumnGutter="20" Top="10" Bottom="10" Left="20" Right="30" ColumnDirection="Horizontal" {cols}/>"#
),
);
import_idml_with(
&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),
]),
&|_| None,
)
.unwrap()
};
let rects = |d: &designcraft_doc::Document| d.spreads[0].pages[0].column_rects().iter().map(|r| (r.x0, r.x1)).collect::<Vec<_>>();
// Dragged: the gutter sits at 150..170 instead of 215..235, and the columns follow it.
let d = with(r#"ColumnsPositions="0 150 170 450""#);
assert_eq!(d.spreads[0].pages[0].columns.positions, Some(vec![0.0, 150.0, 170.0, 450.0]));
assert_eq!(rects(&d), vec![(30.0, 180.0), (200.0, 480.0)]);
// The even set is dropped, so a later change of count or gutter is not frozen out. The
// geometry is the same either way, which is what makes keeping it pointless.
let even = with(r#"ColumnsPositions="0 215 235 450""#);
assert_eq!(even.spreads[0].pages[0].columns.positions, None);
assert_eq!(rects(&even), vec![(30.0, 245.0), (265.0, 480.0)]);
// Including when InDesign's own arithmetic leaves the last bits different.
assert_eq!(with(r#"ColumnsPositions="0 215.00000000000003 234.99999999999997 450""#).spreads[0].pages[0].columns.positions, None);
// Malformed sets are ignored rather than half-read: an odd count, three columns' worth for a
// count of two, a descending pair, a NaN, a set that stops short of the page's margin box, and
// a list far longer than any count allows. Each page keeps the even split.
let huge = format!(r#"ColumnsPositions="{}""#, (0..10_000).map(|i| i.to_string()).collect::<Vec<_>>().join(" "));
for bad in [
r#"ColumnsPositions="0 150 170""#,
r#"ColumnsPositions="0 100 120 200 220 450""#,
r#"ColumnsPositions="0 150 90 450""#,
r#"ColumnsPositions="0 x 170 450""#,
r#"ColumnsPositions="0 NaN 170 450""#,
r#"ColumnsPositions="0 150 170 400""#,
r#"ColumnsPositions="""#,
huge.as_str(),
] {
let d = with(bad);
assert_eq!(d.spreads[0].pages[0].columns.positions, None, "{}", bad.get(..bad.len().min(60)).unwrap_or(""));
assert_eq!(rects(&d), rects(&even));
}
// And they come back from an export, so the round trip keeps the dragged gutter.
let back = import_idml(&export_idml(&d)).unwrap();
assert_eq!(back.spreads[0].pages[0].columns.positions, Some(vec![0.0, 150.0, 170.0, 450.0]));
assert_eq!(rects(&back), rects(&d));
// A page nobody dragged still exports the even split, not a frozen copy of it.
assert_eq!(import_idml(&export_idml(&even)).unwrap().spreads[0].pages[0].columns.positions, None);
}
/// #1096: a ruler guide inside the gutter of a two-column page, written the way InDesign writes
/// it (a `<Page>` child after its `MarginPreference`, with `Properties`/`GuideColor`, `PageIndex`
/// and `GuideZone`), in a document without facing pages. A spread guide sits beside it.
#[test]
fn ruler_guide_in_a_column_gutter_imports_without_facing_pages() {
use designcraft_doc::Orientation::{Horizontal, Vertical};
let package = |view: &str| {
let prefs =
PREFS.replace(r#"FacingPages="true""#, r#"FacingPages="false""#).replace("</idPkg:Preferences>", &format!("{view}</idPkg:Preferences>"));
let spread = SPREAD.replace(
r#"<Page Self="p1" AppliedMaster="m1" GeometricBounds="0 0 700 500" ItemTransform="1 0 0 1 -500 -350">
<MarginPreference ColumnCount="3" ColumnGutter="12" Top="10" Bottom="10" Left="20" Right="30"/>
</Page>"#,
r#"<Page Self="p1" AppliedMaster="m1" GeometricBounds="0 0 700 500" ItemTransform="1 0 0 1 -500 -350" Name="1" OverrideList="">
<Properties><PageColor type="enumeration">UseMasterColor</PageColor></Properties>
<MarginPreference ColumnCount="2" ColumnGutter="20" Top="10" Bottom="10" Left="20" Right="30" ColumnDirection="Horizontal" ColumnsPositions="0 150 170 450"/>
<GridDataInformation FontStyle="Regular" PointSize="12"/>
<Guide Self="g1" OverriddenPageItemProps="" Orientation="Vertical" Location="180" FitToPage="true" ViewThreshold="0" Locked="false" ItemLayer="L1" PageIndex="0" GuideType="Ruler" GuideZone="0">
<Properties><GuideColor type="enumeration">Cyan</GuideColor></Properties>
</Guide>
<Guide Self="g2" OverriddenPageItemProps="" Orientation="Horizontal" Location="300" FitToPage="false" ViewThreshold="5" Locked="false" ItemLayer="L1" PageIndex="0" GuideType="Ruler" GuideZone="1">
<Properties><GuideColor type="enumeration">Cyan</GuideColor></Properties>
</Guide>
</Page>"#,
);
assert_ne!(spread, SPREAD, "the fixture's first page was replaced");
import_idml(&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),
]))
.unwrap()
};
let guides =
|d: &Document| d.spreads[0].pages[0].guides.iter().map(|g| (g.orientation, g.position, g.spread, g.layer.is_some())).collect::<Vec<_>>();
// The first page starts at x 0 in our spread coordinates, so the spread's and the page's ruler
// origins agree; no ViewPreference means InDesign's default, the spread.
for view in ["", r#"<ViewPreference RulerOrigin="SpreadOrigin"/>"#, r#"<ViewPreference RulerOrigin="PageOrigin"/>"#] {
let first = package(view);
assert!(!first.settings.facing_pages);
assert_eq!(guides(&first), [(Vertical, 180.0, false, true), (Horizontal, 300.0, true, true)], "{view:?}");
// The guide sits between the page's left margin (20) and right margin (470).
let m = first.spreads[0].pages[0].margin_rect();
assert!(m.x0 < 180.0 && 180.0 < m.x1);
let back = import_idml(&export_idml(&first)).unwrap();
assert_eq!(guides(&back), guides(&first), "round trip, {view:?}");
}
}
/// Cell Options › Text › First Baseline, set by a cell style that a table style applies to its body
/// region (cells with `[None]` take it from there) and overridden on one cell.
const FIRST_BASELINE_STYLES: &str = r#"<idPkg:Styles xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging">
+1 -1
View File
@@ -118,7 +118,7 @@ fn select_font() -> usvg::FontSelectionFn<'static> {
return Some(id);
}
}
// A family (or generic family) that matches: usvg's own selection.
// A family (or generic family) that matches: the query below.
_ => break,
}
}
+1
View File
@@ -0,0 +1 @@
mod rgba_conversion_contract;
@@ -3,8 +3,6 @@
//! goes inside the write root. Paths that leave a root (`..`, another folder, a sibling with the
//! same prefix, a link) are refused, nothing from a refused file reaches the document, and nothing
//! is written outside.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::{Path, PathBuf};
+4
View File
@@ -0,0 +1,4 @@
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod automation_roots;
-5
View File
@@ -1,5 +0,0 @@
//! Run public contracts with the production content-cache hasher.
#[path = "support/image_content_cache_contract.rs"]
mod contracts;
#[path = "support/image_fixtures.rs"]
mod image_fixtures;
@@ -42,7 +42,7 @@ fn mixed_gradient_export_survives_in_subprocess() {
const CHILD: &str = "DESIGNCRAFT_TEST_MIXED_GRADIENT_CHILD";
if std::env::var_os(CHILD).is_none() {
let out = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "mixed_gradient_export_survives_in_subprocess", "--nocapture"])
.args(["--exact", "gradient_color_spaces::mixed_gradient_export_survives_in_subprocess", "--nocapture"])
.env(CHILD, "1")
.output()
.unwrap();
@@ -0,0 +1,3 @@
//! Run public contracts with the production content-cache hasher.
#[path = "../support/image_content_cache_contract.rs"]
mod contracts;
+6
View File
@@ -0,0 +1,6 @@
#[path = "../support/image_fixtures.rs"]
mod image_fixtures;
mod gradient_color_spaces;
mod image_content_cache;
mod page_entry_search_contract;
@@ -3,7 +3,7 @@
//!
//! Goldens live in `tests/golden/*.png` and are compared with a tolerance (antialiasing and blur
//! rounding may differ by a few levels between SIMD levels). Regenerate them after an intended
//! change with `DESIGNCRAFT_BLESS=1 cargo test -p designcraft-render --test effects`.
//! change with `DESIGNCRAFT_BLESS=1 cargo test -p designcraft-render --test it effects::`.
use designcraft_compose::Cache;
use designcraft_doc::build::NewDocument;
+12
View File
@@ -0,0 +1,12 @@
mod blend_over_paper;
mod colorize;
mod effects;
mod glyph_retention;
mod gradient_inks;
mod gradients;
mod pdf_geometry;
mod perf;
mod placed_pdf;
mod stroke_alignment;
mod text_frame_bottom;
mod text_stroke_behind;
@@ -1,6 +1,6 @@
//! Renderer caching/culling behaviour, and a render-time regression test (`#[ignore]`: wall-clock
//! timings are only meaningful on an idle machine — run with
//! `cargo test --release -p designcraft-render --test perf -- --ignored`).
//! `cargo test --release -p designcraft-render --test it perf:: -- --ignored`).
use std::time::Instant;
+2
View File
@@ -76,6 +76,7 @@ pub(crate) fn snapped_drag_rect(cx: &ToolContext, sr: SpreadRef, sa: Point, end:
x_edges,
y_edges,
exclude: &[],
exclude_guides: &[],
copying: false,
lengths: [length_on(x_edges, r.width()), length_on(y_edges, r.height())],
angle: None,
@@ -160,6 +161,7 @@ fn line_hit(cx: &ToolContext, spread: SpreadRef, point: Point, len: Option<f64>)
x_edges: [true, true, true],
y_edges: [true, true, true],
exclude: &[],
exclude_guides: &[],
copying: false,
lengths: [len, None],
angle: None,
+10
View File
@@ -140,6 +140,9 @@ pub struct SnapView {
pub smart_dimensions: bool,
pub smart_spacing: bool,
pub zone_px: f64,
/// View › Grids & Guides › Lock Guides: ruler guides can't be selected or moved.
#[serde(default)]
pub guides_locked: bool,
}
impl SnapView {
@@ -153,6 +156,7 @@ impl SnapView {
smart_dimensions: true,
smart_spacing: true,
zone_px: 4.0,
guides_locked: false,
};
pub const OFF: Self = Self {
snap_to_guides: false,
@@ -164,6 +168,7 @@ impl SnapView {
smart_dimensions: false,
smart_spacing: false,
zone_px: 4.0,
guides_locked: false,
};
/// Guides, the document grid, or smart guides are on.
@@ -186,6 +191,9 @@ pub enum Gesture {
Create,
Rotate,
Point,
/// A ruler guide being dragged: one line on one axis, with no size or spacing to match and
/// no pull from the baseline grid.
Guide,
}
pub struct SnapRequest<'a> {
@@ -197,6 +205,8 @@ pub struct SnapRequest<'a> {
/// Top, center, bottom.
pub y_edges: [bool; 3],
pub exclude: &'a [ItemId],
/// Ruler guides that are not targets: the ones being dragged.
pub exclude_guides: &'a [designcraft_doc::GuideSel],
pub copying: bool,
/// Proposed width and height for the dimensions pass. None when that length is not being set.
pub lengths: [Option<f64>; 2],
+62 -3
View File
@@ -2,7 +2,8 @@
//!
//! Click selects the frontmost item (Shift toggles), drag moves (Alt duplicates, Shift constrains),
//! the 8 bounding-box handles resize (Shift keeps proportions, Alt from centre, Cmd scales content),
//! empty-canvas drags make a marquee, double-clicking a text frame switches to the Type tool and
//! empty-canvas drags make a marquee (it selects the ruler guides it crosses when it touches no
//! object), double-clicking a text frame switches to the Type tool and
//! double-clicking a handle fits the frame to its content (corner: both sides, edge: that one).
//! A click on a selected text frame's in or out port loads the text cursor (see [`crate::thread`]).
//!
@@ -179,6 +180,42 @@ fn spread_delta_to_canvas(xf: Affine, d: Vec2) -> Vec2 {
(xf * Point::new(d.x, d.y)) - (xf * Point::ORIGIN)
}
/// The ruler guides a marquee `r` (canvas) crosses that can be selected, as `guide.select`
/// takes them: none while guides are hidden or locked.
fn guides_crossing(cx: &ToolContext, r: Rect) -> Vec<Value> {
if !cx.snap.show_guides || cx.snap.guides_locked {
return vec![];
}
let mut out = Vec::new();
for slot in &cx.layout.slots {
let Some(sp) = cx.doc.spread(slot.spread) else { continue };
let pasteboard = slot.xf.inverse().transform_rect_bbox(cx.layout.pasteboard);
for (pi, pg) in sp.pages.iter().enumerate() {
for (gi, g) in pg.guides.iter().enumerate() {
if !g.editable_in(cx.doc) {
continue;
}
// The line as the canvas draws it: a spread guide crosses the pasteboard (a
// vertical one the spread's height and a little), a page guide its page.
let span = match (g.spread, g.orientation) {
(false, _) => pg.bounds(),
(true, designcraft_doc::Orientation::Horizontal) => pasteboard,
(true, designcraft_doc::Orientation::Vertical) => sp.bounds().inflate(0.0, 36.0),
};
let line = match g.orientation {
designcraft_doc::Orientation::Horizontal => Rect::new(span.x0, g.position, span.x1, g.position),
designcraft_doc::Orientation::Vertical => Rect::new(g.position, span.y0, g.position, span.y1),
};
let b = slot.xf.transform_rect_bbox(line);
if b.x0 <= r.x1 && b.x1 >= r.x0 && b.y0 <= r.y1 && b.y1 >= r.y0 {
out.push(json!({"spread": slot.spread, "page": pi, "index": gi}));
}
}
}
}
out
}
pub struct SelectionTool {
direct: bool,
drag: Drag,
@@ -892,6 +929,7 @@ impl Tool for SelectionTool {
x_edges,
y_edges,
exclude: &exclude,
exclude_guides: &[],
copying: ev.mods.alt && !content,
lengths: [None, None],
angle: None,
@@ -965,6 +1003,7 @@ impl Tool for SelectionTool {
x_edges,
y_edges,
exclude: &exclude,
exclude_guides: &[],
copying: false,
lengths: [axis_length(x_edges, proposed.width(), pad_x), axis_length(y_edges, proposed.height(), pad_y)],
angle: None,
@@ -1017,6 +1056,7 @@ impl Tool for SelectionTool {
x_edges: [false, false, false],
y_edges: [false, false, false],
exclude: &exclude,
exclude_guides: &[],
copying: false,
lengths: [None, None],
angle: Some(resulting),
@@ -1055,6 +1095,7 @@ impl Tool for SelectionTool {
x_edges: [true, true, true],
y_edges: [true, true, true],
exclude: &exclude,
exclude_guides: &[],
copying: false,
lengths: [None, None],
angle: None,
@@ -1112,7 +1153,12 @@ impl Tool for SelectionTool {
}
}
}
vec![Action::Exec("selection.set".into(), json!({"ids": ids, "add": ev.mods.shift}))]
let guides = if ids.is_empty() { guides_crossing(cx, r) } else { vec![] };
if guides.is_empty() {
vec![Action::Exec("selection.set".into(), json!({"ids": ids, "add": ev.mods.shift}))]
} else {
vec![Action::Exec("guide.select".into(), json!({"guides": guides, "add": ev.mods.shift}))]
}
}
Drag::None | Drag::Place { .. } => vec![],
}
@@ -1133,7 +1179,20 @@ impl Tool for SelectionTool {
}
let mv = |dx: f64, dy: f64| vec![Action::Exec("transform.move".into(), json!({"dx": dx, "dy": dy, "copy": mods.alt}))];
if cx.selection.items.is_empty() {
return vec![];
// Selected ruler guides: the arrows nudge them, Delete deletes them.
if cx.selection.guides.is_empty() {
return vec![];
}
let nudge = |dx: f64, dy: f64| vec![Action::Exec("guide.moveBy".into(), json!({"dx": dx, "dy": dy}))];
return match key {
ToolKey::Left => nudge(-inc, 0.0),
ToolKey::Right => nudge(inc, 0.0),
ToolKey::Up => nudge(0.0, -inc),
ToolKey::Down => nudge(0.0, inc),
ToolKey::Delete | ToolKey::Backspace => vec![Action::Exec("edit.clear".into(), json!({}))],
ToolKey::Escape => vec![Action::Exec("guide.deselect".into(), json!({}))],
_ => vec![],
};
}
match key {
ToolKey::Left => mv(-inc, 0.0),
+52 -6
View File
@@ -31,6 +31,7 @@ pub fn snap_point(cx: &ToolContext, sr: SpreadRef, p: Point) -> (Point, Vec<Over
x_edges: [true, true, true],
y_edges: [true, true, true],
exclude: &[],
exclude_guides: &[],
copying: false,
lengths: [None, None],
angle: None,
@@ -395,9 +396,10 @@ fn grid_origin(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis) -> Option<f6
/// Lines are `page.y0 + start + n * increment` for n >= 0 while the line is inside the page
/// (the bottom edge is not a line, matching the canvas). An increment of 0 or less skips the
/// pass. `relative_to` and `view_threshold` are not read, and hiding the baseline grid does not
/// turn this off. Only a page the rect crosses on x contributes.
/// turn this off. Only a page the rect crosses on x contributes. A dragged ruler guide
/// ([`Gesture::Guide`]) doesn't snap to it.
fn pass_baseline(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) -> Option<AxisHit> {
if !matches!(axis, Axis::Y) || !cx.snap.snap_to_guides {
if !matches!(axis, Axis::Y) || !cx.snap.snap_to_guides || matches!(req.gesture, Gesture::Guide) {
return None;
}
let baseline = &cx.doc.settings.baseline_grid;
@@ -1000,7 +1002,8 @@ fn push_parent_lengths(out: &mut Vec<f64>, cx: &ToolContext, req: &SnapRequest<'
///
/// A page guide attracts when the moving rect overlaps that page on the other axis. A spread
/// guide uses the spread bounds. Locked guides are included, and so are the guides a page shows
/// from its parent page. A guide is a target only when `Guide::visible_in` accepts it.
/// from its parent page. A guide is a target only when `Guide::visible_in` accepts it, and not
/// when the request excludes it (the guides being dragged).
fn pass_guides(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) -> Option<AxisHit> {
if !cx.snap.snap_to_guides || !cx.snap.show_guides {
return None;
@@ -1010,10 +1013,15 @@ fn pass_guides(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) ->
let flags = axis_flags(req, axis);
let (moving_lo, moving_hi) = perp_ends(req.rect, axis);
let mut best: Option<Best> = None;
for page in &sp.pages {
for (pi, page) in sp.pages.iter().enumerate() {
let page_bounds = page.bounds();
// The page's own guides, then those it shows from its parent page.
for guide in page.guides.iter().cloned().chain(cx.doc.parent_guides(page)) {
// The page's own guides, less the excluded ones, then those it shows from its parent page.
let own = page
.guides
.iter()
.enumerate()
.filter(|&(gi, _)| !req.exclude_guides.contains(&designcraft_doc::GuideSel { spread: req.spread, page: pi, index: gi }));
for guide in own.map(|(_, g)| g.clone()).chain(cx.doc.parent_guides(page)) {
if !guide.visible_in(cx.doc) || !guide.position.is_finite() || guide.orientation != axis_orientation(axis) {
continue;
}
@@ -1427,6 +1435,7 @@ mod tests {
x_edges: [false, false, false],
y_edges: [true, true, true],
exclude: &[],
exclude_guides: &[],
copying: false,
lengths: [None, None],
angle: None,
@@ -1443,6 +1452,7 @@ mod tests {
x_edges: [true, true, true],
y_edges: [false, false, false],
exclude,
exclude_guides: &[],
copying: false,
lengths: [None, None],
angle: None,
@@ -1475,6 +1485,7 @@ mod tests {
x_edges: [false, false, false],
y_edges: [true, true, true],
exclude: &[moving],
exclude_guides: &[],
copying: false,
lengths: [None, None],
angle: None,
@@ -1504,6 +1515,7 @@ mod tests {
x_edges: [true, true, true],
y_edges: [false, false, false],
exclude: &[moving],
exclude_guides: &[],
copying: false,
lengths: [None, None],
angle: None,
@@ -1515,6 +1527,28 @@ mod tests {
assert!(hit.guides.is_empty());
}
/// A dragged guide is not its own target: excluded, the guide at 104 doesn't pull a line at 102.
/// Nor does the baseline grid pull it.
#[test]
fn excluded_guide_is_not_a_target() {
let far = Rect::new(400.0, 400.0, 420.0, 420.0);
let (doc, _, _) = doc_with_items(far, far, 104.0);
let cache = Cache::new();
let layout = CanvasLayout::new(&doc, false);
let sel = Selection::default();
let cx = ctx_on(&doc, &sel, &cache, &layout);
let line = Rect::new(102.0, 100.0, 102.0, 300.0);
let dragged = [designcraft_doc::GuideSel { spread: SpreadRef::Doc(0), page: 0, index: 0 }];
let hit = snap(&cx, move_req(line, &[]));
assert!((hit.delta.x - 2.0).abs() < 1e-6, "{:?}", hit.delta);
let hit = snap(&cx, SnapRequest { exclude_guides: &dragged, ..move_req(line, &[]) });
assert_eq!(hit.delta.x, 0.0);
// A dragged guide passes the baseline grid's lines (36 + 12n), which a moved object meets.
let flat = Rect::new(100.0, 86.0, 300.0, 86.0);
assert!((snap(&cx, y_req(flat)).delta.y - -2.0).abs() < 1e-6);
assert_eq!(snap(&cx, SnapRequest { gesture: Gesture::Guide, ..y_req(flat) }).delta.y, 0.0);
}
#[test]
fn parent_centered_stroke_attracts_at_the_outer_edge() {
let mut doc = Document::new(&NewDocument::default());
@@ -1542,6 +1576,7 @@ mod tests {
x_edges: [true, false, false],
y_edges: [false, false, false],
exclude: &[],
exclude_guides: &[],
copying: false,
lengths: [None, None],
angle: None,
@@ -1569,6 +1604,7 @@ mod tests {
x_edges: [true, true, true],
y_edges: [false, false, false],
exclude: &[moving],
exclude_guides: &[],
copying: false,
lengths: [None, None],
angle: None,
@@ -1599,6 +1635,7 @@ mod tests {
x_edges: [false, false, true],
y_edges: [false, false, false],
exclude: &[moving],
exclude_guides: &[],
copying: false,
lengths: [None, None],
angle: None,
@@ -1630,6 +1667,7 @@ mod tests {
x_edges: [true, false, false],
y_edges: [false, false, false],
exclude: &[id],
exclude_guides: &[],
copying: true,
lengths: [None, None],
angle: None,
@@ -1666,6 +1704,7 @@ mod tests {
x_edges: [true, false, false],
y_edges: [false, false, false],
exclude: &[original],
exclude_guides: &[],
copying: true,
lengths: [None, None],
angle: None,
@@ -1793,6 +1832,7 @@ mod tests {
x_edges: [false, false, true],
y_edges: [false, false, false],
exclude: &[],
exclude_guides: &[],
copying: false,
lengths: [Some(197.0), None],
angle: None,
@@ -1836,6 +1876,7 @@ mod tests {
x_edges: [false, false, true],
y_edges: [false, false, false],
exclude: &[],
exclude_guides: &[],
copying: false,
lengths: [Some(82.0), None],
angle: None,
@@ -1871,6 +1912,7 @@ mod tests {
x_edges: [true, true, true],
y_edges: [false, false, false],
exclude: &[moving],
exclude_guides: &[],
copying: false,
lengths: [None, None],
angle: None,
@@ -2009,6 +2051,7 @@ mod tests {
x_edges: [true, true, true],
y_edges: [false, false, false],
exclude: &[],
exclude_guides: &[],
copying: false,
lengths: [Some(38.0), None],
angle: None,
@@ -2041,6 +2084,7 @@ mod tests {
x_edges: [false, false, false],
y_edges: [false, false, false],
exclude: &[],
exclude_guides: &[],
copying: false,
lengths: [None, None],
angle: Some(28.0),
@@ -2085,6 +2129,7 @@ mod tests {
x_edges: [false, false, false],
y_edges: [false, false, false],
exclude: &[],
exclude_guides: &[],
copying: false,
lengths: [None, None],
angle: Some(28.0),
@@ -2157,6 +2202,7 @@ mod tests {
x_edges: [false, false, false],
y_edges: [false, false, false],
exclude: &[],
exclude_guides: &[],
copying: false,
lengths: [None, None],
angle: Some(angle),
+2
View File
@@ -100,6 +100,7 @@ impl Tool for XformTool {
x_edges: [false, false, false],
y_edges: [false, false, false],
exclude: &exclude,
exclude_guides: &[],
copying: false,
lengths: [None, None],
angle: Some(resulting),
@@ -151,6 +152,7 @@ impl Tool for XformTool {
x_edges,
y_edges,
exclude: &exclude,
exclude_guides: &[],
copying: false,
lengths: [length_on(x_edges, w, pad_x), length_on(y_edges, h, pad_y)],
angle: None,
File diff suppressed because it is too large Load Diff
+8 -8
View File
@@ -1155,7 +1155,7 @@ mod tests {
/// The application bar's search field (German interface).
fn search_node<'h>(h: &'h egui_kittest::Harness<'static, test_window::Window>) -> egui_kittest::Node<'h> {
h.get_by(|n| n.role() == egui::accesskit::Role::TextInput && n.placeholder() == Some("Suchen"))
h.get_by(|n| n.role() == egui::accesskit::Role::TextInput && n.placeholder() == Some(crate::i18n::tr("de", "Search")))
}
/// A document with two frames, the Selection tool, German menus, and the search field clicked.
@@ -1187,12 +1187,12 @@ mod tests {
#[test]
fn the_search_field_finds_and_runs_menu_commands() {
let mut h = searching();
h.event(egui::Event::Text("gruppier".into()));
let group = crate::i18n::tr("de", "Group");
h.event(egui::Event::Text(group.to_lowercase()));
h.run_steps(4);
assert!(row(&h, "Gruppieren").is_some(), "the German label is listed");
assert!(row(&h, "Gruppierung aufheben").is_some());
assert!(row(&h, group).is_some(), "the German label is listed");
// Nothing is selected, so Group is off and Return doesn't run it.
assert!(row(&h, "Gruppieren").is_some_and(|n| n.accesskit_node().is_disabled()));
assert!(row(&h, group).is_some_and(|n| n.accesskit_node().is_disabled()));
h.key_press(egui::Key::Enter);
h.run_steps(4);
assert_eq!(items(&h), (2, 0), "a disabled command doesn't run");
@@ -1205,7 +1205,7 @@ mod tests {
h.key_press(egui::Key::Enter);
h.run_steps(4);
assert_eq!(items(&h).0, 1, "the two frames are grouped");
assert!(row(&h, "Gruppieren").is_none(), "the matches closed");
assert!(row(&h, group).is_none(), "the matches closed");
assert!(!h.ctx.memory(|m| m.has_focus(super::search_id())), "the field gave the keyboard back");
}
@@ -1227,11 +1227,11 @@ mod tests {
assert_eq!(items(&h), (2, 2), "nothing was deleted or deselected");
h.event(egui::Event::Text("zz-no-such-command".into()));
h.run_steps(4);
assert!(h.query_by_label("Keine passenden Befehle").is_some());
assert!(h.query_by_label(crate::i18n::tr("de", "No matching commands")).is_some());
// Escape closes the matches and clears the field without touching the document.
h.key_press(egui::Key::Escape);
h.run_steps(4);
assert!(h.query_by_label("Keine passenden Befehle").is_none());
assert!(h.query_by_label(crate::i18n::tr("de", "No matching commands")).is_none());
assert!(!h.ctx.text_edit_focused());
assert_eq!(h.state().app.session.tool_id(), "rectangleFrame");
assert_eq!(items(&h), (2, 2));
+11 -4
View File
@@ -315,9 +315,16 @@ fn keyboard_shortcuts(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
if !msg.is_empty() {
ui.label(egui::RichText::new(msg).color(egui::Color32::from_rgb(240, 180, 60)));
}
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Reset All to Defaults"))).clicked() {
let _ = app.run("window.resetShortcuts", json!({}));
}
ui.horizontal(|ui| {
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Reset All to Defaults"))).clicked() {
let _ = app.run("window.resetShortcuts", json!({}));
}
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Import InDesign Set…"))).clicked()
&& let Some(msg) = crate::shortcut_import::pick_and_import(app)
{
d.fields.insert("message".into(), json!(msg));
}
});
}
/// File › Print, with the printers the system knows.
@@ -8956,7 +8963,7 @@ mod tests {
assert_eq!(app.ui.language, "de");
assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("preferences"));
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
let heading = visible_label(&labels, screen, "Sprache der Oberfläche");
let heading = visible_label(&labels, screen, crate::i18n::tr("de", "Interface Language"));
let combo = visible_label(&labels, screen, "Deutsch");
// The names stay endonyms in the German interface.
+327
View File
@@ -0,0 +1,327 @@
//! Ruler guides selected like objects (#1282): click, Shift-click, marquee, drag, keys, and the
//! X/Y fields.
use egui::{Event, Modifiers, PointerButton};
use egui_kittest::Harness;
use super::*;
fn harness(app: DesignApp) -> Harness<'static, DesignApp> {
let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut DesignApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
h.run_steps(4);
h
}
/// A page with vertical guides at x 200 and 300 and a horizontal one at y 400.
fn with_guides() -> Harness<'static, DesignApp> {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
for (orientation, position) in [("vertical", 200.0), ("vertical", 300.0), ("horizontal", 400.0)] {
app.run("guide.add", json!({"orientation": orientation, "position": position})).unwrap();
}
harness(app)
}
/// Screen position of spread 0's point (x, y).
fn screen_of(h: &Harness<'_, DesignApp>, x: f64, y: f64) -> Pos2 {
let app = h.state();
let st = app.session.active().unwrap();
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
xf.to_screen(CanvasLayout::new(&st.doc, st.editing_parents).xf(SpreadRef::Doc(0)) * Point::new(x, y))
}
fn press(h: &mut Harness<'_, DesignApp>, pos: Pos2, pressed: bool, mods: Modifiers) {
h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: mods });
h.run_steps(2);
}
fn click(h: &mut Harness<'_, DesignApp>, pos: Pos2, mods: Modifiers) {
h.event(Event::ModifiersChanged(mods));
h.hover_at(pos);
h.step();
press(h, pos, true, mods);
press(h, pos, false, mods);
h.event(Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(2);
}
fn drag(h: &mut Harness<'_, DesignApp>, a: Pos2, b: Pos2) {
h.hover_at(a);
h.step();
press(h, a, true, Modifiers::NONE);
for k in 1..=4 {
h.hover_at(a + (b - a) * (k as f32 / 4.0));
h.step();
}
press(h, b, false, Modifiers::NONE);
h.run_steps(2);
}
fn selected(h: &Harness<'_, DesignApp>) -> Vec<usize> {
h.state().session.doc().unwrap().selection.guides.iter().map(|g| g.index).collect()
}
fn positions(h: &Harness<'_, DesignApp>) -> Vec<f64> {
h.state().session.doc().unwrap().doc.spreads[0].pages[0].guides.iter().map(|g| g.position).collect()
}
#[test]
fn clicking_guides_selects_them_and_shift_click_adds_or_removes() {
let mut h = with_guides();
let id = h.state_mut().run("frame.create", json!({"rect": [400, 100, 500, 200]})).unwrap()["id"].as_u64().unwrap();
h.state_mut().run("selection.set", json!({"ids": [id]})).unwrap();
let at = screen_of(&h, 200.0, 600.0);
click(&mut h, at, Modifiers::NONE);
assert_eq!(selected(&h), [0]);
assert!(h.state().session.doc().unwrap().selection.items.is_empty(), "the object is deselected");
assert_eq!(positions(&h), [200.0, 300.0, 400.0], "a click doesn't move the guide");
let at = screen_of(&h, 100.0, 400.0);
click(&mut h, at, Modifiers::SHIFT);
assert_eq!(selected(&h), [0, 2]);
let at = screen_of(&h, 200.0, 650.0);
click(&mut h, at, Modifiers::SHIFT);
assert_eq!(selected(&h), [2]);
// A click on empty canvas, or on an object, clears the guide selection.
let at = screen_of(&h, 550.0, 650.0);
click(&mut h, at, Modifiers::NONE);
assert!(selected(&h).is_empty());
let at = screen_of(&h, 300.0, 600.0);
click(&mut h, at, Modifiers::NONE);
assert_eq!(selected(&h), [1]);
let at = screen_of(&h, 450.0, 150.0);
click(&mut h, at, Modifiers::NONE);
assert!(selected(&h).is_empty());
assert_eq!(h.state().session.doc().unwrap().selection.items, [designcraft_doc::ItemId(id)]);
// With View › Lock Guides on, guides can't be selected, and selected ones are deselected.
h.state_mut().run("guide.selectAll", json!({})).unwrap();
h.state_mut().ui.guides_locked = true;
h.run_steps(2);
assert!(selected(&h).is_empty());
let at = screen_of(&h, 200.0, 600.0);
click(&mut h, at, Modifiers::NONE);
assert!(selected(&h).is_empty());
}
#[test]
fn dragging_a_selected_guide_moves_all_selected_guides_as_one_step() {
let mut h = with_guides();
h.state_mut().run("guide.select", json!({"guides": [{"page": 0, "index": 0}, {"page": 0, "index": 2}]})).unwrap();
let undo = h.state().session.doc().unwrap().history.undo.len();
let (a, b) = (screen_of(&h, 200.0, 600.0), screen_of(&h, 250.0, 620.0));
drag(&mut h, a, b);
let p = positions(&h);
assert!((p[0] - 250.0).abs() < 1.0 && p[1] == 300.0 && (p[2] - 420.0).abs() < 1.0, "{p:?}");
assert_eq!(h.state().session.doc().unwrap().history.undo.len(), undo + 1);
assert_eq!(selected(&h), [0, 2]);
h.state_mut().run("edit.undo", json!({})).unwrap();
assert_eq!(positions(&h), [200.0, 300.0, 400.0]);
// Dragging an unselected guide selects it and moves only it.
let (a, b) = (screen_of(&h, 300.0, 600.0), screen_of(&h, 350.0, 600.0));
drag(&mut h, a, b);
assert_eq!(selected(&h), [1]);
let p = positions(&h);
assert!(p[0] == 200.0 && (p[1] - 350.0).abs() < 1.0 && p[2] == 400.0, "{p:?}");
}
#[test]
fn a_marquee_selects_guides_only_when_it_touches_no_object() {
let mut h = with_guides();
// Across both vertical guides, below the horizontal one, on empty page.
let (a, b) = (screen_of(&h, 150.0, 500.0), screen_of(&h, 350.0, 550.0));
drag(&mut h, a, b);
assert_eq!(selected(&h), [0, 1]);
let id = h.state_mut().run("frame.create", json!({"rect": [220, 520, 260, 540]})).unwrap()["id"].as_u64().unwrap();
h.state_mut().run("edit.deselectAll", json!({})).unwrap();
let (a, b) = (screen_of(&h, 150.0, 500.0), screen_of(&h, 350.0, 550.0));
drag(&mut h, a, b);
assert!(selected(&h).is_empty());
assert_eq!(h.state().session.doc().unwrap().selection.items, [designcraft_doc::ItemId(id)]);
}
#[test]
fn keys_nudge_and_delete_selected_guides() {
let mut h = with_guides();
h.state_mut().run("guide.select", json!({"guides": [{"page": 0, "index": 1}, {"page": 0, "index": 2}]})).unwrap();
let inc = h.state().session.doc().unwrap().doc.settings.keyboard_increment;
let vi = h.state().view_info();
let key = |h: &mut Harness<'_, DesignApp>, k: ToolKey, shift: bool| {
let m = Mods { shift, alt: false, cmd: false, ctrl: false, space: false };
assert!(h.state_mut().session.tool_key(k, m, vi).unwrap());
};
key(&mut h, ToolKey::Right, false);
key(&mut h, ToolKey::Down, true);
assert_eq!(positions(&h), [200.0, 300.0 + inc, 400.0 + 10.0 * inc]);
key(&mut h, ToolKey::Delete, false);
assert_eq!(positions(&h), [200.0]);
h.state_mut().run("edit.undo", json!({})).unwrap();
assert_eq!(positions(&h).len(), 3);
assert_eq!(selected(&h), [1, 2]);
key(&mut h, ToolKey::Escape, false);
assert!(selected(&h).is_empty());
}
#[test]
fn the_guide_field_measures_from_the_zero_point() {
let mut h = with_guides();
h.state_mut().run("view.zeroPoint", json!({"at": [100, 50]})).unwrap();
h.state_mut().run("guide.select", json!({"page": 0, "index": 0})).unwrap();
let f = crate::panels::guide_field(h.state()).unwrap();
assert_eq!((f.row(), f.value), (0, 100.0));
h.state_mut().run("guide.move", f.move_to(36.0)).unwrap();
assert_eq!(positions(&h)[0], 136.0);
h.state_mut().run("guide.select", json!({"page": 0, "index": 2})).unwrap();
let f = crate::panels::guide_field(h.state()).unwrap();
assert_eq!((f.row(), f.value), (1, 350.0));
// Several guides: no field value.
h.state_mut().run("guide.selectAll", json!({})).unwrap();
assert!(crate::panels::guide_field(h.state()).is_none());
}
#[test]
fn select_all_guides_is_in_the_edit_menu_on_the_spread_in_view() {
let mut h = with_guides();
assert_eq!(crate::menus::default_shortcut("guide.selectAll"), Some("Cmd+Alt+G"));
crate::menus::activate(h.state_mut(), "guide.selectAll", &serde_json::Value::Null);
assert_eq!(selected(&h), [0, 1, 2]);
h.state_mut().ui.guides = false;
assert!(!crate::menus::menu_enabled(h.state(), "guide.selectAll"));
}
#[test]
fn a_guide_over_a_frame_takes_the_click() {
let mut h = with_guides();
h.state_mut().run("guide.deselect", json!({})).unwrap();
// The vertical guide at x 200 crosses the frame.
let id = h.state_mut().run("frame.create", json!({"rect": [150, 500, 260, 560]})).unwrap()["id"].as_u64().unwrap();
h.state_mut().run("edit.deselectAll", json!({})).unwrap();
let bounds = |h: &Harness<'_, DesignApp>| h.state().session.doc().unwrap().doc.spreads[0].items[0].bounds();
let before = bounds(&h);
let at = screen_of(&h, 200.0, 530.0);
click(&mut h, at, Modifiers::NONE);
assert_eq!(selected(&h), [0]);
assert!(h.state().session.doc().unwrap().selection.items.is_empty(), "the frame is not selected");
// Dragging it there moves the guide, not the frame.
let (a, b) = (screen_of(&h, 200.0, 530.0), screen_of(&h, 230.0, 530.0));
drag(&mut h, a, b);
assert!((positions(&h)[0] - 230.0).abs() < 1.0, "{:?}", positions(&h));
assert_eq!(bounds(&h), before);
assert!(h.state().session.doc().unwrap().selection.items.is_empty());
// Away from the guide, a click inside the frame selects it.
let at = screen_of(&h, 160.0, 530.0);
click(&mut h, at, Modifiers::NONE);
assert_eq!(h.state().session.doc().unwrap().selection.items, [designcraft_doc::ItemId(id)]);
assert!(selected(&h).is_empty());
}
#[test]
fn a_guide_dragged_out_of_a_ruler_is_the_selection() {
let mut h = with_guides();
h.state_mut().run("guide.select", json!({"page": 0, "index": 0})).unwrap();
let rect = h.state().canvas_rect.unwrap();
let ruler = pos2(rect.min.x + 200.0, rect.min.y - RULER / 2.0);
let dest = screen_of(&h, 250.0, 500.0);
drag(&mut h, ruler, dest);
let p = positions(&h);
assert!(p.len() == 4 && (p[3] - 500.0).abs() < 1.0, "{p:?}");
assert_eq!(selected(&h), [3]);
}
#[test]
fn dragging_one_of_several_selected_guides_previews_them_all() {
let mut h = with_guides();
h.state_mut().run("guide.select", json!({"guides": [{"page": 0, "index": 0}, {"page": 0, "index": 2}]})).unwrap();
let (a, b) = (screen_of(&h, 200.0, 600.0), screen_of(&h, 250.0, 620.0));
let g =
GuideDrag { orientation: designcraft_doc::Orientation::Vertical, from: Some((SpreadRef::Doc(0), 0, 0)), at: Some(b), mods: Modifiers::NONE };
let press = GuidePress { origin: a, shift: false };
let app = h.state();
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
let m = moving_guides(app, &xf, app.canvas_rect.unwrap(), &g, press).expect("moving");
assert!((m.dx - 50.0).abs() < 1.0 && (m.dy - 20.0).abs() < 1.0, "{} {}", m.dx, m.dy);
let mut moved: Vec<usize> = m.guides.iter().map(|g| g.index).collect();
moved.sort_unstable();
assert_eq!(moved, [0, 2]);
// The drop moves them by the same amounts.
let (dx, dy) = guide_drag_delta(app, &xf, &g, a).unwrap();
assert_eq!((dx, dy), (m.dx, m.dy));
}
/// Spread points per screen pixel at the harness's view.
fn pt_per_px(h: &Harness<'_, DesignApp>) -> f64 {
let app = h.state();
1.0 / Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).zoom
}
/// A letter page with two columns: the left column's right side.
fn with_columns() -> (Harness<'static, DesignApp>, f64) {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"preset": "Letter", "columns": 2, "gutter": 18, "margins": 54})).unwrap();
app.run("guide.add", json!({"orientation": "vertical", "position": 200.0})).unwrap();
let side = app.session.doc().unwrap().doc.spreads[0].pages[0].column_rects()[0].x1;
(harness(app), side)
}
/// A dragged guide snaps to a column guide as a moved object does (#1221).
#[test]
fn a_dragged_guide_snaps_to_a_column_guide() {
let (mut h, side) = with_columns();
let near = side + 1.5 * pt_per_px(&h);
let (a, b) = (screen_of(&h, 200.0, 600.0), screen_of(&h, near, 600.0));
drag(&mut h, a, b);
assert_eq!(positions(&h), [side]);
}
/// Snap to Guides and Smart Guides off: the guide goes where the pointer is.
#[test]
fn with_snapping_off_a_dragged_guide_stays_at_the_pointer() {
let (mut h, side) = with_columns();
h.state_mut().ui.snap_to_guides = false;
h.state_mut().ui.smart_guides = false;
let near = side + 1.5 * pt_per_px(&h);
let (a, b) = (screen_of(&h, 200.0, 600.0), screen_of(&h, near, 600.0));
drag(&mut h, a, b);
let p = positions(&h);
assert!((p[0] - near).abs() < 0.1 && p[0] != side, "{p:?} vs {near}");
}
/// A frame's edge catches a dragged guide, and the preview shows the alignment mark.
#[test]
fn a_dragged_guide_snaps_to_a_frame_edge_with_a_smart_guide() {
let mut h = with_guides();
h.state_mut().run("frame.create", json!({"rect": [400, 100, 500, 200]})).unwrap();
h.state_mut().run("edit.deselectAll", json!({})).unwrap();
let near = 400.0 + 1.5 * pt_per_px(&h);
let (a, b) = (screen_of(&h, 200.0, 600.0), screen_of(&h, near, 600.0));
let g =
GuideDrag { orientation: designcraft_doc::Orientation::Vertical, from: Some((SpreadRef::Doc(0), 0, 0)), at: Some(b), mods: Modifiers::NONE };
{
let app = h.state();
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
let spot = guide_spot(app, &xf, &g).unwrap();
assert_eq!(spot.sp.x, 400.0);
assert!(spot.marks.iter().any(|m| matches!(m, Overlay::Guide { .. })), "{:?}", spot.marks);
// Command suspends snapping.
let spot = guide_spot(app, &xf, &GuideDrag { mods: Modifiers::COMMAND, ..g.clone() }).unwrap();
assert!((spot.sp.x - near).abs() < 0.1 && spot.marks.is_empty());
}
drag(&mut h, a, b);
assert_eq!(positions(&h), [400.0, 300.0, 400.0]);
}
/// A new guide dragged out of a ruler snaps too: here to the horizontal guide at y 400.
#[test]
fn a_guide_dragged_out_of_a_ruler_snaps() {
let mut h = with_guides();
let rect = h.state().canvas_rect.unwrap();
let ruler = pos2(rect.min.x + 200.0, rect.min.y - RULER / 2.0);
let dest = screen_of(&h, 250.0, 400.0 + 1.5 * pt_per_px(&h));
drag(&mut h, ruler, dest);
assert_eq!(positions(&h), [200.0, 300.0, 400.0, 400.0]);
}
+183 -346
View File
@@ -141,7 +141,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Pages on Top", ["Seiten oben", "Pages en haut", "Páginas en la parte superior", "ページを上に表示", "页面在上"]),
(
"Parents on Top",
["Übergeordnete Seiten oben", "Parents en haut", "Páginas principales en la parte superior", "親ページを上に表示", "父页面在上"],
["Übergeordnete Seiten oben", "Gabarits en haut", "Páginas principales en la parte superior", "親ページを上に表示", "父页面在上"],
),
("Resize:", ["Größe ändern:", "Redimensionner :", "Cambiar tamaño:", "サイズ変更:", "调整大小:"]),
("Extra Small", ["Sehr klein", "Très petite", "Extra pequeño", "極小", "特小"]),
@@ -152,8 +152,8 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Jumbo", ["Riesig", "Géante", "Gigante", "最大", "巨大"]),
("Proportional", ["Proportional", "Proportionnel", "Proporcional", "比例", "按比例"]),
("Pages Fixed", ["Seiten fixiert", "Pages fixes", "Páginas fijas", "ページを固定", "页面固定"]),
("Parents Fixed", ["Übergeordnete fixiert", "Parents fixes", "Páginas principales fijas", "親ページを固定", "父页面固定"]),
("Parents", ["Übergeordnete Seiten", "Parents", "Páginas principales", "親ページ", "父页面"]),
("Parents Fixed", ["Übergeordnete fixiert", "Gabarits fixes", "Páginas principales fijas", "親ページを固定", "父页面固定"]),
("Parents", ["Übergeordnete Seiten", "Gabarits", "Páginas principales", "親ページ", "父页面"]),
(
"Bulleted & Numbered Lists",
[
@@ -320,6 +320,11 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Duplicate", ["Duplizieren", "Dupliquer", "Duplicar", "複製", "重复"]),
("Select All", ["Alles auswählen", "Tout sélectionner", "Seleccionar todo", "すべてを選択", "全选"]),
("Deselect All", ["Auswahl aufheben", "Tout désélectionner", "Deseleccionar todo", "すべての選択を解除", "取消全选"]),
(
"Select All Guides",
["Alle Hilfslinien auswählen", "Sélectionner tous les repères", "Seleccionar todas las guías", "すべてのガイドを選択", "选择所有参考线"],
),
("Ruler Guide", ["Hilfslinie", "Repère de règle", "Guía de regla", "定規ガイド", "标尺参考线"]),
("Find/Change…", ["Suchen/Ersetzen …", "Rechercher/Remplacer…", "Buscar/Cambiar…", "検索と置換…", "查找/更改…"]),
("Check Spelling…", ["Rechtschreibprüfung …", "Vérifier l'orthographe…", "Revisar ortografía…", "スペルチェック…", "拼写检查…"]),
("Preferences…", ["Voreinstellungen …", "Préférences…", "Preferencias…", "環境設定…", "首选项…"]),
@@ -494,7 +499,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
),
("ICF Top", ["ICF oben", "Haut ICF", "Parte superior ICF", "平均字面の上", "平均字面上"]),
("ICF Bottom", ["ICF unten", "Bas ICF", "Parte inferior ICF", "平均字面の下", "平均字面下"]),
("Gyoudori:", ["Gyoudori:", "Gyoudori:", "Gyoudori:", "行取り:", "行取:"]),
("Gyoudori:", ["Gyoudori:", "Gyoudori :", "Gyoudori:", "行取り:", "行取:"]),
(
"Use Paragraph Gyoudori",
["Absatz-Gyoudori verwenden", "Utiliser le gyoudori de paragraphe", "Usar gyoudori de párrafo", "段落行取りを使用", "使用段落行取"],
@@ -731,7 +736,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Editorial", ["Redaktion", "Éditorial", "Editorial", "エディトリアル", "编辑"]),
("Object & Layout", ["Objekt und Layout", "Objet et mise en page", "Objeto y diseño", "オブジェクト&レイアウト", "对象和版面"]),
("Styles", ["Formate", "Styles", "Estilos", "スタイル", "样式"]),
("Essentials Classic", ["Essentials Classic", "Essentials Classic", "Essentials Classic", "Essentials Classic", "基本功能(经典)"]),
("Essentials Classic", ["Essentials Classic", "Les indispensables (classique)", "Essentials Classic", "Essentials Classic", "基本功能(经典)"]),
("Split Window", ["Fenster teilen", "Fractionner la fenêtre", "Dividir ventana", "ウィンドウを分割", "拆分窗口"]),
("New Window", ["Neues Fenster", "Nouvelle fenêtre", "Nueva ventana", "新規ウィンドウ", "新建窗口"]),
(
@@ -870,7 +875,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
],
),
("Delete Spread", ["Druckbogen löschen", "Supprimer la planche", "Eliminar pliego", "スプレッドを削除", "删除跨页"]),
("Parent Pages", ["Übergeordnete Seiten", "Pages parents", "Páginas principales", "親ページ", "父页面"]),
("Parent Pages", ["Übergeordnete Seiten", "Gabarits", "Páginas principales", "親ページ", "父页面"]),
(
"Apply Parent to Pages",
[
@@ -1385,12 +1390,12 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Apply None (/)", ["Apply None (/)", "Appliquer Aucun (/)", "Apply None (/)", "Apply None (/)", "应用无 (/)"]),
(
"Default Fill and Stroke",
["Default Fill and Stroke", "Default Fill and Stroke", "Default Fill and Stroke", "Default Fill and Stroke", "默认填色和描边"],
["Default Fill and Stroke", "Fond et contour par défaut", "Default Fill and Stroke", "Default Fill and Stroke", "默认填色和描边"],
),
("Swap Fill and Stroke", ["Swap Fill and Stroke", "Swap Fill and Stroke", "Swap Fill and Stroke", "Swap Fill and Stroke", "交换填色和描边"]),
("Swap Fill and Stroke", ["Swap Fill and Stroke", "Permuter fond et contour", "Swap Fill and Stroke", "Swap Fill and Stroke", "交换填色和描边"]),
(
"Toggle Fill and Stroke",
["Toggle Fill and Stroke", "Toggle Fill and Stroke", "Toggle Fill and Stroke", "Toggle Fill and Stroke", "切换填色和描边"],
["Toggle Fill and Stroke", "Basculer fond/contour", "Toggle Fill and Stroke", "Toggle Fill and Stroke", "切换填色和描边"],
),
("Reverse the gradient", ["Reverse the gradient", "Inverser le dégradé", "Reverse the gradient", "Reverse the gradient", "反转渐变"]),
("Style source", ["Style source", "Source du style", "Style source", "Style source", "样式源"]),
@@ -2058,7 +2063,13 @@ const TABLE: &[(&str, [&str; 5])] = &[
),
(
"Show/Hide Document Grid",
["Show/Hide Document Grid", "Show/Hide Document Grid", "Show/Hide Document Grid", "Show/Hide Document Grid", "显示/隐藏文档网格"],
[
"Show/Hide Document Grid",
"Afficher/Masquer la grille du document",
"Show/Hide Document Grid",
"Show/Hide Document Grid",
"显示/隐藏文档网格",
],
),
(
"Show/Hide Frame Edges",
@@ -2710,7 +2721,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
"Select an object or text to color its fill or stroke.",
[
"Select an object or text to color its fill or stroke.",
"Select an object or text to color its fill or stroke.",
"Sélectionnez un objet ou du texte pour colorer son fond ou son contour.",
"Select an object or text to color its fill or stroke.",
"Select an object or text to color its fill or stroke.",
"选择一个对象或文本以设置其填色或描边颜色。",
@@ -2919,7 +2930,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Cap Height", ["Cap Height", "Hauteur de capitale", "Cap Height", "Cap Height", "大写字母高度"]),
("Leading", ["Leading", "Interligne", "Leading", "Leading", "行距"]),
("x Height", ["x Height", "Hauteur d'x", "x Height", "x Height", "x 字高"]),
("Em Box Height", ["Em Box Height", "Em Box Height", "Em Box Height", "Em Box Height", "全角字框高度"]),
("Em Box Height", ["Em Box Height", "Hauteur du cadratin", "Em Box Height", "Em Box Height", "全角字框高度"]),
("Fixed", ["Fixed", "Fixe", "Fixed", "Fixed", "固定"]),
("Never (continuous)", ["Never (continuous)", "Jamais (continu)", "Never (continuous)", "Never (continuous)", "从不(连续编号)"]),
("Page", ["Page", "Page", "Page", "Page", "页面"]),
@@ -3157,10 +3168,14 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Graphic…", ["Graphic…", "Graphique…", "Graphic…", "Graphic…", "图形…"]),
("Home", ["Home", "Accueil", "Home", "Home", "主页"]),
("Join:", ["Join:", "Sommet :", "Join:", "Join:", "连接:"]),
("Language", ["Language", "Langue", "Language", "Language", "语言"]),
("Language", ["Sprache", "Langue", "Idioma", "言語", "语言"]),
("Last Spread", ["Last Spread", "Dernière planche", "Last Spread", "Last Spread", "最后一个跨页"]),
("Load DTD…", ["Load DTD…", "Charger la DTD…", "Load DTD…", "Load DTD…", "载入 DTD…"]),
("Location:", ["Location:", "Emplacement :", "Location:", "Location:", "位置:"]),
(
"Lock Column Guides",
["Spaltenhilfslinien sperren", "Verrouiller les repères de colonne", "Bloquear guías de columna", "列ガイドをロック", "锁定栏参考线"],
),
("Lock Guides", ["Lock Guides", "Verrouiller les repères", "Lock Guides", "Lock Guides", "锁定参考线"]),
("Miter Limit:", ["Miter Limit:", "Limite de pointe :", "Miter Limit:", "Miter Limit:", "斜接限制:"]),
(
@@ -3498,8 +3513,8 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Ukrainian", ["Ukrainian", "Ukrainien", "Ukrainian", "Ukrainian", "乌克兰语"]),
("Cell Style", ["Cell Style", "Style de cellule", "Cell Style", "Cell Style", "单元格样式"]),
("Table Style", ["Table Style", "Style de tableau", "Table Style", "Table Style", "表样式"]),
("Col ←", ["Col ←", "Col ←", "Col ←", "Col ←", "左侧插入列"]),
("Col →", ["Col →", "Col →", "Col →", "Col →", "右侧插入列"]),
("Col ←", ["Col ←", "Col. ←", "Col ←", "Col ←", "左侧插入列"]),
("Col →", ["Col →", "Col. →", "Col →", "Col →", "右侧插入列"]),
("Row ↑", ["Row ↑", "Ligne ↑", "Row ↑", "Row ↑", "上方插入行"]),
("Row ↓", ["Row ↓", "Ligne ↓", "Row ↓", "Row ↓", "下方插入行"]),
("Hide Others", ["Hide Others", "Masquer les autres", "Hide Others", "Hide Others", "隐藏其他图层"]),
@@ -3529,7 +3544,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
"Show Parent Page Overlay",
[
"Überlagerung der übergeordneten Seite anzeigen",
"Afficher la superposition de la page parent",
"Afficher la superposition du gabarit",
"Mostrar superposición de página principal",
"親ページオーバーレイを表示",
"显示父页面叠加",
@@ -3537,7 +3552,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
),
(
"Controlled by Parent",
["Durch übergeordnete Seite gesteuert", "Contrôlé par le parent", "Controlado por principal", "親ページで制御", "由父页面控制"],
["Durch übergeordnete Seite gesteuert", "Contrôlé par le gabarit", "Controlado por principal", "親ページで制御", "由父页面控制"],
),
("US Business Card", ["US-Visitenkarte", "Carte de visite US", "Tarjeta de visita EE. UU.", "US 名刺", "美国名片"]),
(
@@ -4222,8 +4237,8 @@ const TABLE: &[(&str, [&str; 5])] = &[
"Bullets and Numbering…",
["Aufzählungszeichen und Nummerierung…", "Puces et numérotation…", "Viñetas y numeración…", "箇条書き記号と番号付け…", "项目符号和编号…"],
),
("List:", ["Liste:", "Liste:", "Lista:", "リスト:", "列表:"]),
("Mode:", ["Modus:", "Mode:", "Modo:", "モード:", "模式:"]),
("List:", ["Liste:", "Liste :", "Lista:", "リスト:", "列表:"]),
("Mode:", ["Modus:", "Mode :", "Modo:", "モード:", "模式:"]),
(
"Continue from Previous Number",
[
@@ -4590,47 +4605,61 @@ pub fn workspace_name<'a>(lang: &str, name: &'a str) -> &'a str {
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
/// Every per-language table `tr` looks up, by interface language.
const LANGUAGE_TABLES: &[(&str, &[(&str, &str)])] = &[
("ar", ar::TABLE),
("cs", cs::TABLE),
("de", de::TABLE),
("fr", fr::TABLE),
("it", it::TABLE),
("ja", ja::TABLE),
("ka", ka::TABLE),
("pl", pl::TABLE),
("pt-br", pt_br::TABLE),
("ru", ru::TABLE),
("uk", uk::TABLE),
];
/// Every per-language table `tr_context` looks up.
const CONTEXT_TABLES: &[(&str, &[(&str, &str, &str)])] =
&[("ar", ar::CONTEXT), ("cs", cs::CONTEXT), ("ja", ja::CONTEXT), ("pl", pl::CONTEXT), ("ru", ru::CONTEXT), ("uk", uk::CONTEXT)];
/// The languages of the shared tables' columns, in column order.
const SHARED_COLUMNS: [&str; 5] = ["de", "fr", "es", "ja", "zh"];
/// The German and Japanese tables translate the whole interface and win over the shared columns.
const WHOLE_INTERFACE: [&str; 2] = ["de", "ja"];
#[test]
fn every_interface_language_has_a_catalog() {
for (lang, i) in SHARED_COLUMNS.iter().zip(0..) {
assert_eq!(column(lang), Some(i), "{lang}");
}
for (code, name) in LANGUAGES.iter().filter(|(code, _)| !code.is_empty()) {
assert!(!name.is_empty(), "{code}");
assert!(LANGUAGE_TABLES.iter().any(|(lang, _)| lang == code) || column(code).is_some(), "{code} has no catalog in LANGUAGE_TABLES");
}
}
#[test]
fn cached_lookup_preserves_every_translation_across_language_switches() {
let own = |lang: &str, key: &str| {
LANGUAGE_TABLES.iter().find(|(code, _)| *code == lang).and_then(|(_, table)| table.iter().find(|(k, _)| *k == key)).map(|(_, text)| *text)
};
for (key, translations) in TABLE {
for (lang, expected) in ["de", "fr", "es", "ja", "zh"].into_iter().zip(translations) {
// `de::TABLE` and `ja::TABLE` translate the whole interface and win over the shared columns.
if lang != "de" && lang != "ja" {
assert_eq!(tr(lang, key), *expected, "{lang}: {key}");
}
for (lang, shared) in SHARED_COLUMNS.into_iter().zip(translations) {
// A language's own table is looked up before the shared column.
assert_eq!(tr(lang, key), own(lang, key).unwrap_or(*shared), "{lang}: {key}");
}
assert_eq!(tr("", key), *key);
}
for (key, expected) in de::TABLE {
assert_eq!(tr("de", key), *expected, "de: {key}");
}
for (key, expected) in ar::TABLE {
assert_eq!(tr("ar", key), *expected, "ar: {key}");
}
for (key, expected) in pt_br::TABLE {
assert_eq!(tr("pt-br", key), *expected, "pt-br: {key}");
}
for (key, expected) in it::TABLE {
assert_eq!(tr("it", key), *expected, "it: {key}");
}
for (key, expected) in uk::TABLE {
assert_eq!(tr("uk", key), *expected, "uk: {key}");
}
for (key, expected) in ru::TABLE {
assert_eq!(tr("ru", key), *expected, "ru: {key}");
}
for (key, expected) in cs::TABLE {
assert_eq!(tr("cs", key), *expected, "cs: {key}");
}
for (key, expected) in fr::TABLE {
assert_eq!(tr("fr", key), *expected, "fr: {key}");
}
for (key, expected) in pl::TABLE {
assert_eq!(tr("pl", key), *expected, "pl: {key}");
}
for (key, expected) in ka::TABLE {
assert_eq!(tr("ka", key), *expected, "ka: {key}");
for (lang, table) in LANGUAGE_TABLES {
for (key, expected) in *table {
assert_eq!(tr(lang, key), *expected, "{lang}: {key}");
assert_eq!(tr("", key), *key);
}
}
for (key, expected) in hu::TABLE {
assert_eq!(tr("hu", key), *expected, "hu: {key}");
@@ -4710,18 +4739,31 @@ mod tests {
assert!(it::TABLE[..i].iter().all(|(key, _)| key != en), "duplicate {en}");
assert!(!translation.is_empty(), "{en}");
}
// Italian entries are for keys the shared or Arabic table knows. A new English string
// falls back to English until it is translated, so it doesn't fail here.
for (en, _) in it::TABLE {
assert!(TABLE.iter().any(|(key, _)| key == en) || ar::TABLE.iter().any(|(key, _)| key == en), "it: unknown key {en}");
for (key, scope, row) in CONTEXT {
for (lang, shared) in SHARED_COLUMNS.into_iter().zip(row) {
// A language's own context table is looked up before the shared one.
let own = CONTEXT_TABLES
.iter()
.filter(|(code, _)| *code == lang)
.flat_map(|(_, table)| table.iter())
.find(|(k, s, _)| k == key && s == scope)
.map(|(_, _, text)| *text);
assert_eq!(tr_context(lang, key, scope), own.unwrap_or(*shared), "{lang}: {key} ({scope})");
}
}
// Every row is unique and complete.
for (i, (en, t)) in TABLE.iter().enumerate() {
assert!(TABLE[..i].iter().all(|(e, _)| e != en), "duplicate {en}");
assert!(t.iter().all(|x| !x.is_empty()), "{en}");
// An unknown context, or a language without one, keeps the plain translation.
let keys = CONTEXT.iter().map(|(key, _, _)| *key).chain(CONTEXT_TABLES.iter().flat_map(|(_, table)| table.iter().map(|(key, _, _)| *key)));
for key in keys {
for lang in LANGUAGES.iter().map(|(code, _)| *code).chain(["en"]) {
assert_eq!(tr_context(lang, key, "no such context"), tr(lang, key), "{lang}: {key}");
}
assert_eq!(tr_context("", key, "count"), key);
}
}
/// Every catalog: no duplicate or stale keys, no empty translations, and placeholders, padding,
/// ellipses and label colons as in English. A string a catalog doesn't translate yet falls back
/// to English, so missing translations don't fail here.
#[test]
fn a_column_count_and_a_list_number_read_differently() {
for (lang, count, numeral) in [
@@ -4735,118 +4777,90 @@ mod tests {
] {
assert_eq!((tr_context(lang, "Number:", "count"), tr(lang, "Number:")), (count, numeral), "{lang}");
}
assert_eq!(tr_context("en", "Number:", "count"), "Number:");
assert_eq!(tr("de", "Gutter:"), "Spaltenabstand:");
// The Pages panel menu.
for key in [
"Insert Pages…",
"Move Pages…",
"New Parent…",
"Override All Parent Page Items",
"Remove All Local Overrides",
"Detach All Objects from Parent",
"Create Alternate Layout…",
"Allow Selected Spread to Shuffle",
] {
assert_ne!(tr("pt-br", key), key);
let shared: HashSet<_> = TABLE.iter().map(|(key, _)| *key).collect();
assert_eq!(shared.len(), TABLE.len(), "duplicate keys in the shared table");
catalogs.push(("shared", shared.clone()));
for (lang, i) in SHARED_COLUMNS.into_iter().zip(0..) {
entries.extend(TABLE.iter().filter_map(|(key, row)| row.get(i).map(|text| (lang, *key, *text))));
entries.extend(CONTEXT.iter().filter_map(|(key, _, row)| row.get(i).map(|text| (lang, *key, *text))));
}
let mut contexts = vec![("shared", CONTEXT.iter().map(|(key, scope, _)| (*key, *scope)).collect::<Vec<_>>())];
for (lang, table) in CONTEXT_TABLES {
contexts.push((*lang, table.iter().map(|(key, scope, _)| (*key, *scope)).collect()));
entries.extend(table.iter().map(|(key, _, text)| (*lang, *key, *text)));
}
assert_ne!(tr("ar", "Apply Parent to Pages"), "Apply Parent to Pages");
}
#[test]
fn french_catalog_has_no_duplicate_or_stale_keys_and_keeps_placeholders() {
let catalog: HashMap<_, _> = fr::TABLE.iter().copied().collect();
assert_eq!(catalog.len(), fr::TABLE.len(), "duplicate French keys");
let shared: std::collections::HashSet<_> = TABLE.iter().map(|(key, _)| *key).collect();
// `fr::TABLE` is looked up first: a key there would hide the shared table's French column.
let both: Vec<_> = catalog.keys().filter(|k| shared.contains(*k)).collect();
assert!(both.is_empty(), "French keys also in the shared table: {both:?}");
let mut keys = std::collections::HashSet::new();
for table in [ar::TABLE, de::TABLE, it::TABLE, ja::TABLE, pt_br::TABLE, uk::TABLE, ru::TABLE, cs::TABLE] {
keys.extend(table.iter().map(|(key, _)| *key));
// A per-language table is looked up before the shared table, so a key in both hides the
// shared column, except in the tables that translate the whole interface.
for (lang, table) in LANGUAGE_TABLES.iter().filter(|(lang, _)| column(lang).is_some() && !WHOLE_INTERFACE.contains(lang)) {
let both: Vec<_> = table.iter().map(|(key, _)| *key).filter(|key| shared.contains(key)).collect();
assert!(both.is_empty(), "{lang} keys also in the shared table: {both:?}");
}
let stale: Vec<_> = catalog.keys().filter(|k| !keys.contains(*k)).collect();
assert!(stale.is_empty(), "French entries for unknown keys: {stale:?}");
let placeholders = |s: &str| {
let mut names = s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::<Vec<_>>();
names.sort();
// A key is current while the interface or another catalog has it.
let interface = interface_strings();
let current = |key: &str, own: &str| interface.contains(key) || catalogs.iter().any(|(name, keys)| *name != own && keys.contains(key));
for (name, keys) in &catalogs {
let mut stale: Vec<_> = keys.iter().filter(|key| !current(key, name)).collect();
stale.sort();
assert!(stale.is_empty(), "{name}: entries for unknown keys: {stale:?}");
}
for (name, keys) in &contexts {
let unique: HashSet<_> = keys.iter().collect();
assert_eq!(unique.len(), keys.len(), "{name}: duplicate context keys");
let stale: Vec<_> = keys.iter().filter(|(key, _)| !current(key, "")).collect();
assert!(stale.is_empty(), "{name}: contexts for unknown keys: {stale:?}");
}
let placeholders = |s: &'static str| {
let mut names: Vec<_> = s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name)).collect();
names.sort_unstable();
names
};
let shared_french = TABLE.iter().filter_map(|(key, row)| row.get(1).map(|text| (*key, *text)));
for (key, translation) in fr::TABLE.iter().copied().chain(shared_french) {
assert!(!translation.trim().is_empty(), "empty {key}");
assert_eq!(key.starts_with(' '), translation.starts_with(' '), "leading space: {key}");
assert_eq!(key.ends_with(' '), translation.ends_with(' '), "trailing space: {key}");
assert_eq!(placeholders(key), placeholders(translation), "placeholders: {key}");
let ellipsis = |s: &str| s.ends_with('…') || s.ends_with("...");
let colon = |s: &str| s.ends_with(':') || s.ends_with(':');
// Full-width punctuation carries its own spacing.
let full_width = |c: char| ('\u{3000}'..='\u{303F}').contains(&c) || ('\u{FF00}'..='\u{FFEF}').contains(&c);
for (lang, key, text) in entries {
assert!(!text.trim().is_empty(), "{lang}: empty translation of {key:?}");
assert_eq!(placeholders(key), placeholders(text), "{lang}: placeholders of {key:?}");
// Dialog ellipses, label colons and padded captions stay as in English.
assert_eq!(ellipsis(key), ellipsis(text), "{lang}: ellipsis of {key:?}");
assert_eq!(colon(key), colon(text), "{lang}: colon of {key:?}");
if !text.starts_with(full_width) {
assert_eq!(key.starts_with(' '), text.starts_with(' '), "{lang}: leading space of {key:?}");
}
if !text.ends_with(full_width) {
assert_eq!(key.ends_with(' '), text.ends_with(' '), "{lang}: trailing space of {key:?}");
}
}
assert_eq!(tr("fr", "Group"), "Associer");
}
#[test]
fn ukrainian_catalog_covers_existing_keys_and_preserves_placeholders() {
let catalog: std::collections::HashMap<_, _> = uk::TABLE.iter().copied().collect();
assert_eq!(catalog.len(), uk::TABLE.len(), "duplicate Ukrainian keys");
let mut keys = std::collections::HashSet::new();
keys.extend(TABLE.iter().map(|(key, _)| *key));
keys.extend(ar::TABLE.iter().map(|(key, _)| *key));
keys.extend(pt_br::TABLE.iter().map(|(key, _)| *key));
// A new English string falls back to English until it is translated, so only stale
// entries (keys no catalog knows any more) fail.
let stale: Vec<_> = catalog.keys().filter(|k| !keys.contains(*k)).collect();
assert!(stale.is_empty(), "Ukrainian entries for unknown keys: {stale:?}");
let placeholders =
|s: &str| s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::<Vec<_>>();
for (key, translation) in uk::TABLE {
assert!(!translation.trim().is_empty(), "empty {key}");
let mut source = placeholders(key);
let mut translated = placeholders(translation);
source.sort();
translated.sort();
assert_eq!(source, translated, "placeholders: {key}");
fn only_arabic_lays_out_right_to_left() {
for (code, _) in LANGUAGES {
assert_eq!(is_rtl(code), *code == "ar", "{code}");
}
}
/// Built-in style and workspace names follow the interface language; names people give their
/// own styles and workspaces are document data and stay as typed.
#[test]
fn only_built_in_style_and_workspace_names_are_translated() {
for (code, _) in LANGUAGES {
for name in [designcraft_doc::NO_PARA_STYLE, designcraft_doc::BASIC_PARAGRAPH, designcraft_doc::NO_CHAR_STYLE] {
assert_eq!(style_name(code, name), tr(code, name), "{code}");
}
assert_eq!(workspace_name(code, "Essentials"), tr(code, "Essentials"), "{code}");
for custom in ["Group", "Columns", "My layout style"] {
assert_eq!(style_name(code, custom), custom, "{code}");
assert_eq!(workspace_name(code, custom), custom, "{code}");
}
}
assert_eq!(tr_context("uk", "Columns", "table"), "Стовпці");
assert_eq!(tr("uk", "Columns"), "Колонки");
assert_eq!(tr_context("uk", "Group", "selection"), "Група");
// Step and Repeat's grid columns are not a text frame's.
assert_eq!(tr_context("uk", "Columns:", "grid"), "Стовпці:");
assert_eq!(tr_context("ja", "Columns:", "grid"), "列:");
assert_eq!(tr("ja", "Columns:"), "段:");
assert_eq!(tr("uk", "Group"), "Згрупувати");
assert_eq!(tr_context("", "Columns", "table"), "Columns");
assert_eq!(tr_context("ja", "Span Columns", "paragraphLayout"), "段抜き");
assert_eq!(tr("ja", "Span Columns"), "段抜きと段分割");
assert_eq!(tr_context("uk", "Custom caption", "custom"), "Custom caption");
assert_eq!(tr("uk", "through"), "включно");
assert_eq!(tr("uk", "up to"), "не включно");
assert!(!is_rtl("uk"));
assert_eq!(tr("uk", "Type"), "Текст");
assert_eq!(tr("uk", "Spread"), "Розворот");
assert_eq!(tr("uk", "Story"), "Текстовий матеріал");
assert_eq!(tr("uk", "Footer Rows"), "Рядки нижнього колонтитула");
assert_eq!(style_name("uk", "[Basic Paragraph]"), "[Основний абзац]");
assert_eq!(style_name("uk", "My layout style"), "My layout style");
assert_eq!(workspace_name("uk", "Essentials"), "Основне");
assert_eq!(workspace_name("uk", "My workspace"), "My workspace");
}
#[test]
fn georgian_catalog_preserves_placeholders_and_counts() {
let catalog: HashMap<_, _> = ka::TABLE.iter().copied().collect();
assert_eq!(catalog.len(), ka::TABLE.len(), "duplicate Georgian keys");
let placeholders = |s: &str| {
s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::<std::collections::BTreeSet<_>>()
};
for (key, translation) in ka::TABLE {
assert!(!translation.trim().is_empty(), "empty {key}");
assert_eq!(placeholders(key), placeholders(translation), "placeholders: {key}");
assert_eq!(key.ends_with('…'), translation.ends_with('…'), "ellipsis: {key}");
// Georgian has no capitals in running text: Mtavruli never appears in the interface.
assert!(!translation.chars().any(|c| ('\u{1C90}'..='\u{1CBF}').contains(&c)), "Mtavruli: {key}");
}
assert_eq!(tr("ka", "File"), "ფაილი");
assert_eq!(tr("ka", "Save As…"), "შენახვა როგორც…");
assert_eq!(tr("ka", "Unknown label"), "Unknown label");
assert!(!is_rtl("ka"));
fn georgian_counts_use_category_captions_for_every_number() {
for n in [1, 2, 5, 21] {
assert_eq!(count_label("ka", "pages", n), format!("გვერდი: {n}"));
}
@@ -4864,62 +4878,6 @@ mod tests {
assert_eq!(count_label("", "objects", 2), "2 objects");
}
#[test]
fn russian_catalog_covers_existing_keys_and_preserves_placeholders() {
let catalog: std::collections::HashMap<_, _> = ru::TABLE.iter().copied().collect();
assert_eq!(catalog.len(), ru::TABLE.len(), "duplicate Russian keys");
let mut keys = std::collections::HashSet::new();
keys.extend(TABLE.iter().map(|(key, _)| *key));
keys.extend(ar::TABLE.iter().map(|(key, _)| *key));
keys.extend(pt_br::TABLE.iter().map(|(key, _)| *key));
keys.extend(it::TABLE.iter().map(|(key, _)| *key));
keys.extend(ja::TABLE.iter().map(|(key, _)| *key));
keys.extend(uk::TABLE.iter().map(|(key, _)| *key));
// A new English string falls back to English until it is translated, so only stale
// entries (keys no catalog knows any more) fail.
let stale: Vec<_> = catalog.keys().filter(|k| !keys.contains(*k)).collect();
assert!(stale.is_empty(), "Russian entries for unknown keys: {stale:?}");
let placeholders =
|s: &str| s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::<Vec<_>>();
for (key, translation) in ru::TABLE {
assert!(!translation.trim().is_empty(), "empty {key}");
assert_eq!(key.starts_with(' '), translation.starts_with(' '), "leading space: {key}");
assert_eq!(key.ends_with(' '), translation.ends_with(' '), "trailing space: {key}");
assert!(!translation.contains(['ґ', 'є', 'і', 'ї', 'Ґ', 'Є', 'І', 'Ї']), "Ukrainian letter in {key}");
let mut source = placeholders(key);
let mut translated = placeholders(translation);
source.sort();
translated.sort();
assert_eq!(source, translated, "placeholders: {key}");
}
}
#[test]
fn russian_is_selectable_ltr_and_resolves_contexts_styles_and_menu_paths() {
assert!(LANGUAGES.contains(&("ru", "Русский")));
assert!(!is_rtl("ru"));
assert_eq!(tr("ru", "File"), "Файл");
assert_eq!(tr("ru", "Type"), "Текст");
assert_eq!(tr("ru", "Spread"), "Разворот");
assert_eq!(tr("ru", "Story"), "Материал");
assert_eq!(tr("ru", "Unknown label"), "Unknown label");
assert_eq!(tr_context("ru", "Columns", "table"), "Столбцы");
assert_eq!(tr("ru", "Columns"), "Колонки");
assert_eq!(tr_context("ru", "Group", "selection"), "Группа");
assert_eq!(tr("ru", "Group"), "Сгруппировать");
assert_eq!(tr_context("ru", "Custom caption", "custom"), "Custom caption");
assert_eq!(tr("ru", "through"), "включительно");
assert_eq!(tr("ru", "up to"), "не включая");
assert_eq!(tr("ru", "Edit/Spelling"), "Редактирование/Орфография");
assert_eq!(tr("ru", "Type/Insert Special Character/Quotation Marks"), "Текст/Вставить специальный символ/Кавычки");
assert_eq!(style_name("ru", "[Basic Paragraph]"), "[Основной абзац]");
assert_eq!(style_name("ru", "My layout style"), "My layout style");
assert_eq!(workspace_name("ru", "Essentials"), "Основное");
assert_eq!(workspace_name("ru", "My workspace"), "My workspace");
// Fixed standard names stay identical.
assert_eq!(tr("ru", "PDF/X-4"), "PDF/X-4");
}
#[test]
fn russian_counts_use_category_captions_for_every_number() {
assert!(category_first("ru") && category_first("uk") && !category_first("de") && !category_first(""));
@@ -4932,45 +4890,6 @@ mod tests {
}
}
#[test]
fn polish_catalog_covers_existing_keys_and_preserves_placeholders() {
let catalog: HashMap<_, _> = pl::TABLE.iter().copied().collect();
assert_eq!(catalog.len(), pl::TABLE.len(), "duplicate Polish keys");
let mut keys = std::collections::HashSet::new();
keys.extend(TABLE.iter().map(|(key, _)| *key));
for table in [ar::TABLE, pt_br::TABLE, ja::TABLE, it::TABLE, uk::TABLE, de::TABLE, fr::TABLE, ru::TABLE, cs::TABLE] {
keys.extend(table.iter().map(|(key, _)| *key));
}
// A new English string falls back to English until it is translated; only stale entries fail.
let stale: Vec<_> = catalog.keys().filter(|k| !keys.contains(*k)).collect();
assert!(stale.is_empty(), "Polish entries for unknown keys: {stale:?}");
let placeholders =
|s: &str| s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::<Vec<_>>();
for (key, translation) in pl::TABLE {
assert!(!translation.trim().is_empty(), "empty {key}");
let mut source = placeholders(key);
let mut translated = placeholders(translation);
source.sort();
translated.sort();
assert_eq!(source, translated, "placeholders: {key}");
assert_eq!(key.ends_with('…'), translation.ends_with('…'), "ellipsis: {key}");
}
assert!(LANGUAGES.contains(&("pl", "Polski")));
assert!(!is_rtl("pl"));
assert_eq!(tr("pl", "File"), "Plik");
assert_eq!(tr("pl", "New Document…"), "Nowy dokument…");
assert_eq!(tr("pl", "Spread"), "Rozkładówka");
assert_eq!(tr("pl", "Unknown label"), "Unknown label");
assert_eq!(style_name("pl", "[Basic Paragraph]"), "[Podstawowy akapit]");
assert_eq!(workspace_name("pl", "Essentials"), "Podstawowe");
// A table's columns and grid columns aren't a text frame's; a selection's group isn't the action.
assert_eq!(tr("pl", "Columns"), "Łamy");
assert_eq!(tr_context("pl", "Columns", "table"), "Kolumny");
assert_eq!(tr_context("pl", "Columns:", "grid"), "Łamy:");
assert_eq!(tr("pl", "Group"), "Grupuj");
assert_eq!(tr_context("pl", "Group", "selection"), "Grupa");
}
#[test]
fn polish_counts_use_category_captions_for_every_number() {
for n in [0, 1, 2, 5, 12, 22, 25] {
@@ -4981,15 +4900,10 @@ mod tests {
assert_eq!(tr("pl", "{pages} Pages in {spreads} Spreads"), "Strony: {pages}, rozkładówki: {spreads}");
}
#[test]
fn czech_catalog_has_no_stale_keys_and_keeps_placeholders_and_markers() {
let catalog: HashMap<_, _> = cs::TABLE.iter().copied().collect();
assert_eq!(catalog.len(), cs::TABLE.len(), "duplicate Czech keys");
let mut keys = std::collections::HashSet::new();
keys.extend(TABLE.iter().map(|(key, _)| *key));
for table in [ar::TABLE, it::TABLE, ja::TABLE, pt_br::TABLE, uk::TABLE] {
keys.extend(table.iter().map(|(key, _)| *key));
}
/// Every English string the interface passes to `tr`, as far as the command and tool tables and
/// the list of UI-only labels know them.
fn interface_strings() -> HashSet<&'static str> {
let mut keys = HashSet::new();
// Command labels and menu names reach `tr` from the engine and the UI command table.
for spec in designcraft_engine::command_specs() {
keys.insert(spec.label);
@@ -5009,6 +4923,8 @@ mod tests {
"(unchanged)",
"[No Language]",
"Cell Number:",
"Differs from this document",
"Digits:",
"Change fill to:",
"Chinese: Simplified",
"Chinese: Traditional",
@@ -5044,6 +4960,7 @@ mod tests {
"Found",
"Grab bag",
"Key Object",
"Load Styles",
"layer",
"layers",
"Lines +/−",
@@ -5059,6 +4976,7 @@ mod tests {
"Redefine Document Style",
"Report an issue",
"Reverse the order",
"Sort Entries in Alphabetical Order",
"Story {story} ← story {parent}",
"Story Editor — {count} words",
"Text Anchor",
@@ -5077,41 +4995,7 @@ mod tests {
"{name}: import cancelled while a dialog is open; choose the file again",
"{name}: {count} step(s) done",
]);
// A new English string falls back to English until it is translated, so only stale
// entries (keys nothing passes to `tr` any more) fail.
let stale: Vec<_> = catalog.keys().filter(|k| !keys.contains(*k)).collect();
assert!(stale.is_empty(), "Czech entries for unknown keys: {stale:?}");
let placeholders =
|s: &str| s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::<Vec<_>>();
for (key, translation) in cs::TABLE {
assert!(!translation.trim().is_empty(), "empty {key}");
let mut source = placeholders(key);
let mut translated = placeholders(translation);
source.sort();
translated.sort();
assert_eq!(source, translated, "placeholders: {key}");
// Dialog ellipses, label colons and padded button captions stay as in English.
assert_eq!(key.ends_with('…'), translation.ends_with('…'), "ellipsis: {key}");
assert_eq!(key.ends_with(':'), translation.ends_with(':'), "colon: {key}");
assert_eq!(key.starts_with(' '), translation.starts_with(' '), "leading space: {key}");
assert_eq!(key.ends_with(' '), translation.ends_with(' '), "trailing space: {key}");
}
assert!(!is_rtl("cs"));
assert_eq!(tr("cs", "File"), "Soubor");
assert_eq!(tr("cs", "New Document…"), "Nový dokument…");
assert_eq!(tr("cs", "Spread"), "Dvoustrana");
assert_eq!(tr("cs", "Bleed"), "Spadávka");
assert_eq!(tr("cs", "Leading"), "Proklad");
assert_eq!(tr("cs", "Unknown label"), "Unknown label");
assert_eq!(tr_context("cs", "Group", "selection"), "Skupina");
assert_eq!(tr_context("cs", "Name", "contributors"), "Jméno");
assert_eq!(tr_context("cs", "Name", "unknown"), "Název");
assert_eq!(tr("cs", "Group"), "Seskupit");
assert_eq!(tr_context("cs", "Columns", "table"), tr("cs", "Columns"));
assert_eq!(style_name("cs", "[Basic Paragraph]"), "[Základní odstavec]");
assert_eq!(style_name("cs", "My layout style"), "My layout style");
assert_eq!(workspace_name("cs", "Essentials"), "Základní");
assert_eq!(workspace_name("cs", "My workspace"), "My workspace");
keys
}
#[test]
@@ -5138,51 +5022,4 @@ mod tests {
assert!(forms.contains(&tr("cs", noun)), "{noun}: the catalog row is one of the plural forms");
}
}
#[test]
fn czech_translates_every_tool_name() {
for tool in designcraft_tools::TOOL_GROUPS.iter().flat_map(|group| group.iter()) {
assert_ne!(tr("cs", tool.label), tool.label, "{}", tool.id);
assert_eq!(tr("", tool.label), tool.label);
}
}
#[test]
fn japanese_vertical_type_commands_are_translated() {
for (en, ja) in [
("Story Direction", "組み方向"),
("Horizontal", "横書き"),
("Vertical", "縦書き"),
("Tate-Chu-Yoko", "縦中横"),
("Ruby…", "ルビ…"),
("Kenten", "圏点"),
("Warichu", "割り注"),
] {
assert_eq!(tr("ja", en), ja);
}
}
#[test]
fn pdf_export_dialog_strings_are_translated() {
// Translated labels in every supported language.
assert_ne!(tr("zh", "Crop Marks"), "Crop Marks");
assert_ne!(tr("de", "Tagged PDF"), "Tagged PDF");
assert_ne!(tr("ar", "Include document bleed"), "Include document bleed");
assert_ne!(tr("pt-br", "Compress images"), "Compress images");
// English returns the key unchanged.
assert_eq!(tr("", "Export PDF"), "Export PDF");
// An unknown string still falls through unchanged.
assert_eq!(tr("de", "Low Quality Print"), "Low Quality Print");
// Fixed standard names stay identical in every language.
assert_eq!(tr("ja", "PDF/X-4"), "PDF/X-4");
}
#[test]
fn style_row_menu_strings_are_translated_in_every_language() {
for (code, _) in LANGUAGES.iter().filter(|(c, _)| !c.is_empty()) {
for key in ["Redefine Style", "Alt-click clears overrides", "Apply \"{name}\", Clear Overrides"] {
assert_ne!(tr(code, key), key, "{code}: {key}");
}
}
}
}
+4
View File
@@ -147,6 +147,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Duplicate", "تكرار"),
("Select All", "تحديد الكل"),
("Deselect All", "إلغاء تحديد الكل"),
("Select All Guides", "تحديد كل الأدلة"),
("Ruler Guide", "دليل المسطرة"),
("Find/Change…", "البحث/التغيير…"),
("Find/Change", "البحث/التغيير"),
("Check Spelling…", "تدقيق إملائي…"),
@@ -393,6 +395,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Adjust Layout", "ضبط التخطيط"),
("Smart Guides", "أدلة ذكية"),
("Lock Guides", "قفل الأدلة"),
("Lock Column Guides", "قفل أدلة الأعمدة"),
("Show Document Grid", "إظهار شبكة المستند"),
("Show Baseline Grid", "إظهار شبكة خطوط الأساس"),
("Options", "خيارات"),
@@ -1603,6 +1606,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Move Anchors", "تحريك نقاط الارتساء"),
("Move Column", "نقل عمود"),
("Move Guide", "تحريك دليل"),
("Move Column Guide", "تحريك دليل العمود"),
("Move Layer", "نقل طبقة"),
("Move Pages…", "نقل صفحات…"),
("Move to Layer", "نقل إلى طبقة"),
+4
View File
@@ -37,6 +37,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Duplicate", "Duplikovat"),
("Select All", "Vybrat vše"),
("Deselect All", "Zrušit výběr"),
("Select All Guides", "Vybrat všechna vodítka"),
("Ruler Guide", "Vodítko pravítka"),
("Find/Change…", "Najít/Změnit…"),
("Check Spelling…", "Zkontrolovat pravopis…"),
("Preferences…", "Předvolby…"),
@@ -773,6 +775,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Move Anchors", "Přesunout kotvy"),
("Move Column", "Přesunout sloupec"),
("Move Guide", "Přesunout vodítko"),
("Move Column Guide", "Přesunout vodítko sloupce"),
("Move Layer", "Přesunout vrstvu"),
("Move Pages…", "Přesunout stránky…"),
("Move to Layer", "Přesunout do vrstvy"),
@@ -1093,6 +1096,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Load DTD…", "Načíst DTD…"),
("Location:", "Umístění:"),
("Lock Guides", "Zamknout vodítka"),
("Lock Column Guides", "Zamknout vodítka sloupců"),
("Miter Limit:", "Limit ostrého spoje:"),
("New Article from Selection", "Nový článek z výběru"),
("New Note at Cursor", "Nová poznámka u kurzoru"),
+4 -1
View File
@@ -64,6 +64,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Duplicate", "Duplizieren"),
("Select All", "Alles auswählen"),
("Deselect All", "Auswahl aufheben"),
("Select All Guides", "Alle Hilfslinien auswählen"),
("Ruler Guide", "Hilfslinie"),
("Find/Change…", "Suchen/Ersetzen …"),
("Check Spelling…", "Rechtschreibprüfung …"),
("Preferences…", "Voreinstellungen …"),
@@ -830,6 +832,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Move Anchors", "Anker verschieben"),
("Move Column", "Spalte verschieben"),
("Move Guide", "Hilfslinie verschieben"),
("Move Column Guide", "Spaltenhilfslinie verschieben"),
("Move Layer", "Ebene verschieben"),
("Move Pages…", "Seiten verschieben …"),
("Move to Layer", "Auf Ebene verschieben"),
@@ -1151,6 +1154,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Load DTD…", "DTD laden …"),
("Location:", "Position:"),
("Lock Guides", "Hilfslinien sperren"),
("Lock Column Guides", "Spaltenhilfslinien sperren"),
("Miter Limit:", "Gehrungsgrenze:"),
("New Article from Selection", "Neuer Artikel aus Auswahl"),
("New Note at Cursor", "Neue Notiz an der Einfügemarke"),
@@ -2033,7 +2037,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Print Layer", "Ebene drucken"),
("Suppress Text Wrap When Layer is Hidden", "Textumfluss bei ausgeblendeter Ebene unterdrücken"),
// Remaining interface strings.
("A custom name", "Ein eigener Name"),
("Close Panel", "Bedienfeld schließen"),
("Default Fill and Stroke", "Standardfläche und -kontur"),
("Distribute Rows Evenly", "Zeilen gleichmäßig verteilen"),
+220
View File
@@ -1,57 +1,118 @@
//! French translations of the strings the shared table in `i18n.rs` has no row for,
//! written from the English source using ordinary French desktop-publishing vocabulary.
pub(super) const TABLE: &[(&str, &str)] = &[
("% of all commits", "% de tous les commits"),
("(any)", "(quelconque)"),
("(unchanged)", "(inchangé)"),
("90° CCW", "90° sens antihoraire"),
("90° CW", "90° sens horaire"),
("About", "À propos"),
("Activate Panel", "Activer le panneau"),
("Add Anchor Point Tool", "Outil Ajout de point d'ancrage"),
("Add Tab", "Ajouter une tabulation"),
("Align To", "Aligner sur"),
("Alignment", "Alignement"),
("All Documents", "Tous les documents"),
("Alpha Channel", "Couche alpha"),
("Alternate layout", "Variante de mise en page"),
("Anchored Object Options", "Options d'objet ancré"),
("Apply Numbering & Section Options", "Appliquer les options de numérotation et de section"),
("Apply Text Attributes (Eyedropper)", "Appliquer les attributs de texte (Pipette)"),
("ArtCraft App Page", "Page de l'application ArtCraft"),
("ArtCraft Website", "Site web d'ArtCraft"),
("Auto Size", "Taille automatique"),
("Auto Tate-Chu-Yoko", "Tate-Chu-Yoko automatique"),
("Binary assets added", "Fichiers binaires ajoutés"),
("Binary assets removed", "Fichiers binaires supprimés"),
("Binding", "Reliure"),
("Break Thread", "Rompre le chaînage"),
("Bullet Character", "Caractère de puce"),
("CMYK:", "CMJN :"),
("Cancel Preview", "Annuler l'aperçu"),
("Cell Number:", "Numéro de mobile :"),
("Change Panel Layout", "Modifier la disposition des panneaux"),
("Change Tab", "Modifier la tabulation"),
("Change To", "Remplacer par"),
("Change fill to:", "Remplacer le fond par :"),
("Character Style", "Style de caractère"),
("Check Spelling", "Vérifier l'orthographe"),
("Chinese: Simplified", "Chinois : simplifié"),
("Chinese: Traditional", "Chinois : traditionnel"),
("Clear All Tabs", "Effacer toutes les tabulations"),
("Clear Object Style Overrides", "Effacer les remplacements du style d'objet"),
("Clear Rotation", "Effacer la rotation"),
("Clipping Path Options…", "Options de masque…"),
("Close Panel", "Fermer le panneau"),
("Close Window", "Fermer la fenêtre"),
("Closed Path", "Tracé fermé"),
("Color Blindness – Deuteranopia", "Daltonisme – Deutéranopie"),
("Color Blindness – Protanopia", "Daltonisme – Protanopie"),
("Color Theme Tool", "Outil Thème de couleur"),
("Color Theme…", "Thème de couleur…"),
("Colour", "Couleur"),
("Colour Picker", "Sélecteur de couleurs"),
("Column Break", "Saut de colonne"),
("Commit Preview", "Valider l'aperçu"),
("Community", "Communauté"),
("Company", "Entreprise"),
("Conditional Text", "Texte conditionnel"),
("Content Collector Tool", "Outil Collecteur de contenu"),
("Content Placer Tool", "Outil Placeur de contenu"),
("Contributors", "Contributeurs"),
("Convert Direction Point Tool", "Outil Conversion de point directeur"),
("Convert Point", "Convertir le point"),
("Convert Rows", "Convertir les lignes"),
("Copyright Symbol", "Symbole du copyright"),
("Corner Options", "Options d'arrondi"),
("Couldn't write recovery data: {error}", "Impossible d'écrire les données de récupération : {error}"),
("Create Guides", "Créer des repères"),
("Dark", "Foncé"),
("Data Fields", "Champs de données"),
("Data Merge Options", "Options de fusion des données"),
("Define Cross-Reference Format", "Définir un format de référence croisée"),
("Define Lists", "Définir des listes"),
("Define Text Variable", "Définir une variable de texte"),
("Delete Anchor Point Tool", "Outil Suppression de point d'ancrage"),
("Delete Character Style", "Supprimer le style de caractère"),
("Delete Document Preset", "Supprimer le paramètre prédéfini de document"),
("Delete Parent", "Supprimer le gabarit"),
("Delete Type from Path", "Supprimer le texte curviligne"),
("Deselect Guides", "Désélectionner les repères"),
("DesignCraft Website", "Site web de DesignCraft"),
("DesignCraft on GitHub", "DesignCraft sur GitHub"),
("DesignCraft page", "Page DesignCraft"),
("Detect Edges", "Détecter les contours"),
("Direct Selection Tool", "Outil Sélection directe"),
("Discretionary Hyphen", "Trait d'union conditionnel"),
("Display name", "Nom affiché"),
("Distribute Rows Evenly", "Répartir les lignes uniformément"),
("Document CMYK", "CMJN du document"),
("Document Endnote Options", "Options de notes de fin de document"),
("Document Footnote Options", "Options de notes de bas de page du document"),
("Document RGB", "RVB du document"),
("Don't Save", "Ne pas enregistrer"),
("Done", "Terminé"),
("Double Left Quotation Marks", "Guillemets doubles ouvrants"),
("Double Right Quotation Marks", "Guillemets doubles fermants"),
("Drag Rows or Columns", "Faire glisser des lignes ou des colonnes"),
("Edit/Interface Language", "Édition/Langue de l'interface"),
("Edit/Spelling", "Édition/Orthographe"),
("Edit/Transparency Blend Space", "Édition/Espace de fusion de la transparence"),
("Edit/Transparency Flattener Presets", "Édition/Préréglages d'aplatissement de la transparence"),
("Ellipse Frame Tool", "Outil Bloc elliptique"),
("Ellipse Tool", "Outil Ellipse"),
("Ellipsis", "Points de suspension"),
("Em Dash", "Tiret cadratin"),
("Em Space", "Espace cadratin"),
("Email Address:", "Adresse e-mail :"),
("Email:", "E-mail :"),
("En Dash", "Tiret demi-cadratin"),
("En Space", "Espace demi-cadratin"),
(
"Equal fixed heights for the selected rows; cell text may become overset.",
"Hauteurs fixes identiques pour les lignes sélectionnées ; le texte des cellules peut déborder.",
),
("Erase Tool", "Outil Gomme"),
("Export EPUB", "Exporter en EPUB"),
("Export EPUB (Fixed Layout)", "Exporter en EPUB (mise en page fixe)"),
("Export EPUB (Reflowable)", "Exporter en EPUB (redistribuable)"),
@@ -59,50 +120,99 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Export IDML", "Exporter en IDML"),
("Export PDF (Interactive)", "Exporter en PDF (interactif)"),
("Export Page as PNG", "Exporter la page en PNG"),
("Export PowerPoint…", "Exporter en PowerPoint…"),
("Export Text", "Exporter le texte"),
("Export XML", "Exporter en XML"),
(
"Export a PDF, IDML or package to share — no cloud account needed.",
"Exportez un PDF, un fichier IDML ou un assemblage à partager — aucun compte cloud requis.",
),
("Extract Color Theme", "Extraire le thème de couleur"),
("Eyedropper Tool", "Outil Pipette"),
("Figure Space", "Espace chiffre"),
("File/Export", "Fichier/Exporter"),
("Find What", "Rechercher"),
("Find fill:", "Rechercher le fond :"),
("First commit", "Premier commit"),
("Flush Space", "Espace sans alinéa"),
("Forced Line Break", "Saut de ligne forcé"),
("Formatting Affects Text", "La mise en forme agit sur le texte"),
("Found", "Trouvé"),
("Frame Break", "Saut de bloc"),
("Free Transform Tool", "Outil Transformation manuelle"),
("GREP", "GREP"),
("Gap Tool", "Outil Espace"),
("Generate Index", "Générer l'index"),
("Generate Live Caption", "Générer une légende dynamique"),
("Get Cell Stroke", "Obtenir le contour de cellule"),
("Get Tabs", "Obtenir les tabulations"),
("Global Light", "Lumière globale"),
("Grab bag", "Liste"),
("Gradient Feather Tool", "Outil Contour progressif dégradé"),
("Gradient Swatch Tool", "Outil Dégradé de nuance"),
("Graphic", "Graphique"),
("Group with", "Grouper avec"),
("Hair Space", "Espace ultrafine"),
("Hand Tool", "Outil Main"),
("Hide Guides", "Masquer les repères"),
("High Contrast", "Contraste élevé"),
("High Resolution", "Haute résolution"),
("I, II, III, IV...", "I, II, III, IV..."),
("Import InDesign Shortcut Set", "Importer un jeu de raccourcis InDesign"),
("Import XML", "Importer XML"),
("Import cancelled: the originating document has closed", "Importation annulée : le document d'origine a été fermé"),
("Indent", "Retrait"),
("Indent to Here", "Retrait jusqu'à ce point"),
("Ink Limit 300%", "Limite d'encre 300 %"),
("Insert Anchored Object", "Insérer un objet ancré"),
("Insert Cross-Reference", "Insérer une référence croisée"),
("Insert Field", "Insérer un champ"),
("Insert Math Expression", "Insérer une expression mathématique"),
("Insert Pages", "Insérer des pages"),
("Internet Standard RGB (sRGB)", "RVB standard Internet (sRGB)"),
("Invalid panel layout; restored the default dock", "Disposition des panneaux non valide ; disposition par défaut restaurée"),
("Join", "Joindre"),
("Join the ArtCraft Discord", "Rejoindre le Discord d'ArtCraft"),
("Join the community", "Rejoindre la communauté"),
("Kerning", "Crénage"),
("Kerning/Tracking Step", "Pas de crénage/d'approche"),
("Key Object", "Objet clé"),
("Last commit", "Dernier commit"),
("Layout/Index", "Page/Index"),
("Layout/Pages", "Page/Pages"),
("Legacy Macintosh RGB", "RVB Macintosh hérité"),
("Light", "Clair"),
("Line Tool", "Outil Trait"),
("Line delta", "Différence de lignes"),
("Lines +/−", "Lignes +/−"),
("Lines added", "Lignes ajoutées"),
("Lines deleted", "Lignes supprimées"),
("List Composite Fonts", "Lister les polices composites"),
("List Styles in File", "Lister les styles du fichier"),
("Load DTD", "Charger la DTD"),
("Load Styles…", "Charger les styles…"),
("Load Swatches", "Charger des nuances"),
("Low Resolution", "Basse résolution"),
("Margins:", "Marges :"),
("Maximize/Restore Window", "Agrandir/Restaurer la fenêtre"),
("Measure Tool", "Outil Mesure"),
("Medium Dark", "Moyennement foncé"),
("Medium Light", "Moyennement clair"),
("Medium Resolution", "Résolution moyenne"),
("Menus", "Menus"),
("Merged PRs", "PR fusionnées"),
("Message:", "Message :"),
("Minimize Window", "Réduire la fenêtre"),
("Models", "Modèles"),
("Monitor RGB", "RVB du moniteur"),
("Move Column Guide", "Déplacer le repère de colonne"),
("Move Guides", "Déplacer les repères"),
("Move Pages", "Déplacer des pages"),
("Move Panel", "Déplacer le panneau"),
("Move Tab", "Déplacer la tabulation"),
("Move Window", "Déplacer la fenêtre"),
("Name (A–Z)", "Nom (A–Z)"),
("New Cell Style", "Nouveau style de cellule"),
("New Condition", "Nouvelle condition"),
("New Hyperlink", "Nouvel hyperlien"),
("New Hyperlink Destination", "Nouvelle destination d'hyperlien"),
@@ -110,13 +220,20 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("New Page Reference", "Nouvelle référence de page"),
("New Parent", "Nouveau gabarit"),
("New Stroke Style", "Nouveau style de contour"),
("New Table Style", "Nouveau style de tableau"),
("Next Page Number", "Numéro de la page suivante"),
("No contributor data was built into this copy.", "Aucune donnée sur les contributeurs n'est intégrée à cette copie."),
("No matches", "Aucune correspondance"),
("No model credits were built into this copy.", "Aucun crédit de modèle n'est intégré à cette copie."),
("Nonbreaking Hyphen", "Trait d'union insécable"),
("Nonbreaking Space", "Espace insécable"),
("None (keep transparency)", "Aucun (conserver la transparence)"),
("Note Tool", "Outil Note"),
("Number of Pages", "Nombre de pages"),
("Numbering & Section Options", "Options de numérotation et de section"),
("OK", "OK"),
("Object Style", "Style d'objet"),
("Object Style Options…", "Options de style d'objet…"),
("Object Styles", "Styles d'objet"),
("Object/Anchored Object", "Objet/Objet ancré"),
("Object/Arrange", "Objet/Disposition"),
@@ -132,64 +249,130 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Object/Transform", "Objet/Transformation"),
("Object/Transform Again", "Objet/Répéter la transformation"),
("On", "Activé"),
("Organization:", "Organisation :"),
("Orientation", "Orientation"),
("PDF import cancelled: the originating document has closed", "Importation PDF annulée : le document d'origine a été fermé"),
("PRs", "PR"),
("Package", "Assemblage"),
("Packaged {count} files into {dir}", "{count} fichiers assemblés dans {dir}"),
("Page Break", "Saut de page"),
("Page Options", "Options de page"),
("Page Tool", "Outil Page"),
("Panels", "Panneaux"),
("Paragraph Return", "Retour de paragraphe"),
("Paragraph Style", "Style de paragraphe"),
("Paragraph Symbol", "Symbole de paragraphe"),
("Parent Options…", "Options de gabarit…"),
("Pen Tool", "Outil Plume"),
("Pencil Tool", "Outil Crayon"),
("Phone:", "Téléphone :"),
("Picas", "Picas"),
("Pin Bar Position", "Épingler la position de la barre"),
("Place with Import Options", "Importer avec les options d'importation"),
("Polygon Frame Tool", "Outil Bloc polygonal"),
("Polygon Tool", "Outil Polygone"),
("Preflight", "Contrôle en amont"),
("Preflight Panel…", "Panneau Contrôle en amont…"),
("Press keys…", "Appuyez sur des touches…"),
("Preview Change", "Prévisualiser la modification"),
("Preview Record", "Aperçu de l'enregistrement"),
("Previous Page Number", "Numéro de la page précédente"),
("Print Booklet", "Imprimer le cahier"),
("Punctuation Space", "Espace de ponctuation"),
("Quarter Space", "Quart d'espace"),
("Quick Apply", "Application rapide"),
("RGB:", "RVB :"),
("Range:", "Plage :"),
("Real name", "Nom réel"),
("Recent Files", "Fichiers récents"),
("Rectangle Frame Tool", "Outil Bloc rectangulaire"),
("Rectangle Tool", "Outil Rectangle"),
("Redefine Character Style", "Redéfinir le style de caractère"),
("Redefine Paragraph Style", "Redéfinir le style de paragraphe"),
("Registered Trademark Symbol", "Symbole de marque déposée"),
("Relink", "Rééditer le lien"),
("Relink to Folder", "Rééditer le lien au dossier"),
("Relinked {count}", "Liens rétablis : {count}"),
("Reload Fonts", "Recharger les polices"),
("Remove Data Source", "Supprimer la source de données"),
("Remove Field", "Supprimer le champ"),
("Remove Frame from Thread", "Retirer le bloc du chaînage"),
("Reorder Objects", "Réorganiser les objets"),
("Report an Issue", "Signaler un problème"),
("Report an issue", "Signaler un problème"),
("Reset Bar Position", "Réinitialiser la position de la barre"),
("Resize Row or Column", "Redimensionner la ligne ou la colonne"),
("Resize Window", "Redimensionner la fenêtre"),
("Resize panel window", "Redimensionner la fenêtre du panneau"),
("Resize panels", "Redimensionner les panneaux"),
("Reverse the order", "Inverser l'ordre"),
("Right Indent Tab", "Tabulation de retrait à droite"),
("Rotate Tool", "Outil Rotation"),
("Rotation", "Rotation"),
("Ruler Zero Point", "Point zéro des règles"),
("Save As", "Enregistrer sous"),
("Save Swatches for Exchange", "Enregistrer les nuances pour échange"),
("Save a Copy", "Enregistrer une copie"),
("Scale Tool", "Outil Mise à l'échelle"),
("Scissors Tool", "Outil Ciseaux"),
("Scripts", "Scripts"),
("Section Marker", "Marque de section"),
("Section Symbol", "Symbole de section"),
("Select Guide", "Sélectionner le repère"),
("Select Page", "Sélectionner la page"),
("Select Paragraph", "Sélectionner le paragraphe"),
("Select Sentence", "Sélectionner la phrase"),
("Select Story", "Sélectionner l'article"),
("Select Text Unit", "Sélectionner une unité de texte"),
("Selection", "Sélection"),
("Selection Tool", "Outil Sélection"),
("Sent {count} page(s) to {printer}", "{count} page(s) envoyée(s) à {printer}"),
("Sentence case", "Majuscule en début de phrase"),
("Set Composite Font", "Définir la police composite"),
("Set OpenType Features", "Définir les fonctions OpenType"),
("Shear Tool", "Outil Déformation"),
("Simulate Paper Color", "Simuler la couleur du papier"),
("Single Left Quotation Mark", "Guillemet simple ouvrant"),
("Single Right Quotation Mark", "Guillemet simple fermant"),
("Sixth Space", "Sixième d'espace"),
("Smart Guide Options", "Options des repères commentés"),
("Smooth Tool", "Outil Lissage"),
("Space After", "Espace après"),
("Space Before", "Espace avant"),
("Start Page #", "N° de la première page"),
("Step and Repeat", "Exécuter et répéter"),
("Stop Preview", "Arrêter l'aperçu"),
("Story Column Direction", "Sens des colonnes de l'article"),
("Story Editor — {count} words", "Mode éditeur — {count} mots"),
("Story {story} ← story {parent}", "Article {story} ← article {parent}"),
("Straight Double Quotation Marks", "Guillemets droits doubles"),
("Straight Single Quotation Mark (Apostrophe)", "Guillemet droit simple (apostrophe)"),
("Subject:", "Objet :"),
("Subscript:", "Indice :"),
("Superscript:", "Exposant :"),
("Swap Text Fill and Stroke", "Permuter le fond et le contour du texte"),
("Tab", "Tabulation"),
("Table of Contents", "Table des matières"),
("Table/Convert Cell Type", "Tableau/Convertir le type de cellule"),
("Table/Delete", "Tableau/Supprimer"),
("Table/Insert", "Tableau/Insérer"),
("Table/Select", "Tableau/Sélectionner"),
("Text Anchor", "Ancre de texte"),
("Text:", "Texte :"),
("Thin Space", "Espace fine"),
("Third Space", "Tiers d'espace"),
("Thread Text Frames", "Chaîner les blocs de texte"),
("Title Case", "Majuscule à chaque mot"),
("To Body", "En lignes de corps"),
("To Footer", "En lignes de pied"),
("To Header", "En lignes d'en-tête"),
("Trademark Symbol", "Symbole de marque"),
("Transform Again Individually", "Répéter la transformation individuellement"),
("Transform Sequence Again", "Répéter la séquence de transformations"),
("Transform Sequence Again Individually", "Répéter la séquence de transformations individuellement"),
("Type Tool", "Outil Texte"),
("Type on a Path Options", "Options de texte curviligne"),
("Type on a Path Tool", "Outil Texte curviligne"),
("Type/Bulleted and Numbered Lists", "Texte/Listes à puces et numérotées"),
("Type/Change Case", "Texte/Modifier la casse"),
("Type/Hyperlinks & Cross-References", "Texte/Hyperliens et références croisées"),
@@ -210,8 +393,20 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("URL:", "URL :"),
("Unassigned", "Non attribué"),
("Units", "Unités"),
("Unsaved Changes", "Modifications non enregistrées"),
("Update Data Source", "Mettre à jour la source de données"),
("Username", "Nom d'utilisateur"),
("Validate from Root Element", "Valider à partir de l'élément racine"),
("Vertical Type Tool", "Outil Texte vertical"),
("Vertical Type on a Path Tool", "Outil Texte curviligne vertical"),
("View/Display Performance", "Affichage/Performances d'affichage"),
("View/Extras", "Affichage/Extras"),
("View/Grids & Guides", "Affichage/Grilles et repères"),
("View/Proof Setup", "Affichage/Format d'épreuve"),
("View/Rotate Spread", "Affichage/Faire pivoter la planche"),
("View/Screen Mode", "Affichage/Mode de l'écran"),
("View/Separations Preview", "Affichage/Aperçu des séparations"),
("View/Structure", "Affichage/Structure"),
("Window/Arrange", "Fenêtre/Disposition"),
("Window/Interactive", "Fenêtre/Interactif"),
("Window/Interface Color Theme", "Fenêtre/Thème de couleur de l'interface"),
@@ -220,6 +415,31 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Window/Type & Tables", "Fenêtre/Texte et tableaux"),
("Window/Utilities", "Fenêtre/Utilitaires"),
("Working CMYK", "CMJN de travail"),
("Zoom Tool", "Outil Zoom"),
("[No Language]", "[Aucune langue]"),
("i, ii, iii, iv...", "i, ii, iii, iv..."),
("image editing", "retouche d'images"),
("layer", "calque"),
("layers", "calques"),
("lowercase", "minuscules"),
("motion graphics and visual effects", "animation graphique et effets visuels"),
("photo library and raw development", "photothèque et développement raw"),
("reading, organizing and protecting PDFs", "lecture, organisation et protection de PDF"),
("the default printer", "l'imprimante par défaut"),
("vector illustration", "illustration vectorielle"),
("video editing, color and sound", "montage vidéo, couleur et son"),
("{contributors} contributors · {commits} commits", "{contributors} contributeurs · {commits} commits"),
("{count} conflict(s): {names}", "{count} conflit(s) : {names}"),
("{count} match(es)", "{count} correspondance(s)"),
("{count} replacement(s) made", "{count} remplacement(s) effectué(s)"),
("{label}: {name} (double-click: Color Picker)", "{label} : {name} (double-clic : Sélecteur de couleurs)"),
(
"{name}: import cancelled while a dialog is open; choose the file again",
"{name} : importation annulée car une boîte de dialogue était ouverte ; choisissez à nouveau le fichier",
),
("{name}: {count} step(s) done", "{name} : {count} étape(s) effectuée(s)"),
(
"“{}” has changes that are not saved. Save them before closing?",
"« {} » contient des modifications non enregistrées. Les enregistrer avant de fermer ?",
),
];
+4
View File
@@ -143,6 +143,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Duplicate", "Duplica"),
("Select All", "Seleziona tutto"),
("Deselect All", "Deseleziona tutto"),
("Select All Guides", "Seleziona tutte le guide"),
("Ruler Guide", "Guida del righello"),
("Find/Change…", "Trova/Sostituisci…"),
("Check Spelling…", "Controllo ortografico…"),
("Preferences…", "Preferenze…"),
@@ -903,6 +905,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Move Anchors", "Sposta ancoraggi"),
("Move Column", "Sposta colonna"),
("Move Guide", "Sposta guida"),
("Move Column Guide", "Sposta guida colonna"),
("Move Layer", "Sposta livello"),
("Move Pages…", "Sposta pagine…"),
("Move to Layer", "Sposta su livello"),
@@ -1233,6 +1236,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Load DTD…", "Carica DTD…"),
("Location:", "Posizione:"),
("Lock Guides", "Blocca guide"),
("Lock Column Guides", "Blocca guide colonne"),
("Miter Limit:", "Limite spigolo:"),
("New Article from Selection", "Nuovo articolo dalla selezione"),
("New Note at Cursor", "Nuova nota al cursore"),
+4
View File
@@ -145,6 +145,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Duplicate", "複製"),
("Select All", "すべてを選択"),
("Deselect All", "すべての選択を解除"),
("Select All Guides", "すべてのガイドを選択"),
("Ruler Guide", "定規ガイド"),
("Find/Change…", "検索と置換…"),
("Check Spelling…", "スペルチェック…"),
("Preferences…", "環境設定…"),
@@ -1018,6 +1020,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Move Anchors", "アンカーポイントを移動"),
("Move Column", "列を移動"),
("Move Guide", "ガイドを移動"),
("Move Column Guide", "列ガイドを移動"),
("Move Layer", "レイヤーを移動"),
("Move Pages…", "ページを移動…"),
("Move to Layer", "レイヤーへ移動"),
@@ -1349,6 +1352,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Load DTD…", "DTD を読み込み…"),
("Location:", "位置:"),
("Lock Guides", "ガイドをロック"),
("Lock Column Guides", "列ガイドをロック"),
("Miter Limit:", "マイターの上限:"),
("New Article from Selection", "選択範囲から新規アーティクルを作成"),
("New Note at Cursor", "カーソル位置に新規注釈"),
+4
View File
@@ -66,6 +66,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Duplicate", "დუბლირება"),
("Select All", "ყველაფრის მონიშვნა"),
("Deselect All", "ყველა მონიშვნის მოხსნა"),
("Select All Guides", "ყველა დამხმარე ხაზის მონიშვნა"),
("Ruler Guide", "სახაზავის დამხმარე ხაზი"),
("Find/Change…", "ძებნა/ჩანაცვლება…"),
("Check Spelling…", "მართლწერის შემოწმება…"),
("Preferences…", "პარამეტრები…"),
@@ -835,6 +837,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Move Anchors", "საყრდენი წერტილების გადაადგილება"),
("Move Column", "სვეტის გადატანა"),
("Move Guide", "დამხმარე ხაზის გადაადგილება"),
("Move Column Guide", "სვეტის დამხმარე ხაზის გადაადგილება"),
("Move Layer", "შრის გადაადგილება"),
("Move Pages…", "გვერდების გადატანა…"),
("Move to Layer", "შრეზე გადატანა"),
@@ -1170,6 +1173,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Load DTD…", "DTD-ის ჩატვირთვა…"),
("Location:", "მდებარეობა:"),
("Lock Guides", "დამხმარე ხაზების ჩაკეტვა"),
("Lock Column Guides", "სვეტების დამხმარე ხაზების ჩაკეტვა"),
("Miter Limit:", "მახვილის ზღვარი:"),
("New Article from Selection", "ახალი სტატია მონიშნულიდან"),
("New Note at Cursor", "ახალი შენიშვნა კურსორთან"),
+4
View File
@@ -33,6 +33,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Duplicate", "Powiel"),
("Select All", "Zaznacz wszystko"),
("Deselect All", "Usuń zaznaczenie"),
("Select All Guides", "Zaznacz wszystkie linie pomocnicze"),
("Ruler Guide", "Linia pomocnicza"),
("Find/Change…", "Znajdź/zmień…"),
("Check Spelling…", "Sprawdź pisownię…"),
("Preferences…", "Preferencje…"),
@@ -786,6 +788,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Move Anchors", "Przesuń kotwice"),
("Move Column", "Przesuń kolumnę"),
("Move Guide", "Przesuń linię pomocniczą"),
("Move Column Guide", "Przesuń linię pomocniczą łamu"),
("Move Layer", "Przesuń warstwę"),
("Move Pages…", "Przesuń strony…"),
("Move to Layer", "Przenieś na warstwę"),
@@ -1103,6 +1106,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Load DTD…", "Wczytaj DTD…"),
("Location:", "Położenie:"),
("Lock Guides", "Zablokuj linie pomocnicze"),
("Lock Column Guides", "Zablokuj linie pomocnicze łamów"),
("Miter Limit:", "Limit ostrych narożników:"),
("New Article from Selection", "Nowy artykuł z zaznaczenia"),
("New Note at Cursor", "Nowa notatka przy kursorze"),

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