From 4a077986bf8c5060db885c4e0bb0f7d63b963970 Mon Sep 17 00:00:00 2001 From: Cameron Gilroy Date: Wed, 7 Oct 2026 15:48:41 +1100 Subject: [PATCH 01/13] Data merge: design for linked sources, placeholders, and tiling --- .../specs/2026-10-07-data-merge-design.md | 208 ++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-07-data-merge-design.md diff --git a/docs/superpowers/specs/2026-10-07-data-merge-design.md b/docs/superpowers/specs/2026-10-07-data-merge-design.md new file mode 100644 index 000000000..a3a6846af --- /dev/null +++ b/docs/superpowers/specs/2026-10-07-data-merge-design.md @@ -0,0 +1,208 @@ +# Data merge + +Design for phase 1 of data merge. Approved in conversation on 2026-10-07. + +Phase 1 extends the existing `data.merge` command. One template, one linked table, a temporary preview, then a new document. Each record gets a copy of the template, or the items on a single page are tiled. + +Phase 2 is not part of this spec. It adds more than one source, JSON, filter and sort, and a drawn grid. Phase 1 stores a source id on every placeholder so that work can land later without rewriting placeholders. Tiling stays when the grid arrives. + +## What exists today + +`crates/engine/src/cmd/datamerge.rs` takes an inline `csv` string or `rows` array, copies one template spread once per record inside the same document, and replaces `<>` in story text. One undo step. There is no file on disk, no Excel, no pictures, no QR codes, no hyperlinks, no preview, and no tiling. + +`crates/textimport` already reads the first worksheet of an `.xlsx` file as a table of cached cell values. Place uses that path. Data merge needs rows with empty cells preserved, so place keeps its own reader. + +Pictures go through `file.place` and the Links panel. Hyperlinks are `hyperlink.create` (`url`, `email`, or `page`). QR codes are `object.qrCode` (vector path, types `url`, `text`, `sms`, `email`, `vcard`). Frame fitting is `Fitting` in `crates/doc`: `none`, `fillProportionally`, `fitProportionally`, `fitContentToFrame`, `centerContent`. + +The engine `Session` holds a list of documents and an active index. A merge adds a document. It does not replace the template. + +## Document model + +New fields on `Document` use serde defaults so older files open. Empty data merge state is omitted from the save. + +`DataMerge` on the document: + +- `sources`: a list. Phase 1 has zero or one entry. +- `placeholders`: the list below. +- `options`: the last merge options, saved with the document. + +`DataSource`: + +- `id` +- `path`: absolute path, if the source is a file +- `relative_path`: path relative to the document file, set when the document has been saved +- `name`: file name, used when the source was passed as bytes and has no path +- `delimiter`: `comma`, `tab`, or `semicolon` (text only) +- `sheet`: worksheet name, or absent for the first sheet (Excel only) +- `fields`: `{ name, kind }` where `kind` is `text`, `image`, or `qr` +- `rows`: cached rows, each the same length as `fields`. Missing cells are `""` +- `fingerprint`: file size and modification time of the last successful read, if there is a path +- `status`: `ok`, `missing`, or `modified` + +`Placeholder`: + +- `id` +- `source_id` +- `field`: the name, without `@` or `#` +- `role`: `text`, `image`, `qr`, or `hyperlink` +- `anchor`: either `{ story, start, end }` in Unicode scalar offsets, matching `HyperlinkSource::Text`, or `{ item }` for a frame + +`MergeOptions`, with these defaults: + +- `records`: `all`. Also `one` with a 1-based record number, or `range` with a range string +- `per_page`: `single` or `multiple`. Default `single` +- `arrange`: `rows` or `columns`. Default `rows`. Used only when `per_page` is `multiple` +- `insets`: top, right, bottom, left, in points. Default 36, 36, 36, 36. Used only when tiling +- `column_spacing` and `row_spacing`: points. Default 0. Used only when tiling +- `fitting`: `fitProportionally` (default), `fillProportionally`, `fitContentToFrame`, or `none` +- `center`: bool, default false. Applied after fitting, by moving the graphic to the middle of the frame +- `link_images`: bool, default true +- `limit`: absent, or a positive count taken after the record selection + +Preview is session state on the open document, not a field of `Document`. It stores the 1-based record being shown, or nothing. Saving writes the unfilled template. `data.preview.stop`, closing the document, and a successful merge all clear it. + +## Reading a source + +`data.source.select` accepts `path`, or `bytes` plus `name`. The name's extension selects the reader. `delimiter` (`comma`, `tab`, `semicolon`) overrides sniffing for text. `sheet` names an Excel worksheet. The first sheet in workbook order is the default. + +Text is UTF-8. A leading byte-order mark is stripped. Any other encoding is an error that names the file. The command does not guess a legacy encoding. + +Delimiter sniff, only when the command does not set one: + +- `.tsv` or `.tab`: tab +- `.csv`: comma +- `.txt`: tab if the header line contains a tab; otherwise semicolon if that line contains a semicolon and no comma; otherwise comma +- anything else with text contents: the same sniff as `.txt` + +Excel (`.xlsx` only): + +- Cached values. The reader does not evaluate formulas. A formula cell with no cached value is empty. +- A date is the stored number, not a formatted date. +- Booleans are `TRUE` and `FALSE`. Whole numbers drop a trailing `.0`. +- Empty cells inside the header's width are `""`. +- `.xlsm` and `.xls` are errors. + +Rows: + +- The first row is field names, trimmed. An empty name is an error. A duplicate name is an error. +- A leading apostrophe on a header is ignored. Then a leading `@` sets `image` and a leading `#` sets `qr`. The mark is not part of the name. Any other field is `text`. +- Each later row is one record. A short row is padded with `""`. A row whose cells are all empty is skipped. Cells past the header are ignored, and the result's `warnings` names the row. +- More than 100,000 data rows or 200 columns is an error. The command does not allocate them. + +Inline `csv` on `data.merge` and `data.fields` uses this same text parser and does not create a source. Inline `rows` is an array of objects. The field list is the key order of the first object. Later objects are read by those keys. A missing key is `""`. An extra key is ignored, with a warning. `@` and `#` on a key work the same way as a header cell. Non-strings are stringified with JSON's string form for numbers and bools, and rejected for arrays and objects. + +`data.source.select` returns `{ fields, records, warnings }`. `fields` is `{ name, kind }`. `records` is the data-row count. After a file read, status is `ok` and the fingerprint is stored. On open, and before a merge, if the path is set: missing file sets `missing`; a different size or modification time sets `modified`. A merge uses the cached rows either way and adds a warning for `missing` or `modified`. `data.source.update` re-reads. With no path, update is an error. `data.source.remove` drops the source and leaves placeholders in place. + +When the document is saved, `relative_path` is refreshed from the document's path. On open, a missing absolute path is tried again as `relative_path` beside the document. + +## Placeholders + +`data.placeholder.add` takes `field`, `role`, and a target. The target is the text caret (`story` plus `at`) or a frame (`item`). The field must exist on the document's source. Phase 1 has one source, and the placeholder stores that source id. + +Defaults the panel uses, which the command may also set by omitting `role`: + +- Caret in a story: `text` +- A selected frame and an `image` field: `image` +- A selected frame and a `qr` field: `qr` +- A plain `text` field on a frame is `image` only when `role` is `image`. The panel calls that Bind to frame. +- `hyperlink` is always explicit. + +Inserting `text` writes `<>` at the caret, using the character style already at that point, and anchors the placeholder to that range. `<<` and `>>` are ASCII 0x3C and 0x3E. Inserting `image` or `qr` anchors the placeholder to the frame and shows `<>` as the frame's label. It does not place a picture yet. Pictures and QR codes are frames, not inline in a story. Inserting `hyperlink` anchors a text range or a frame and does not change the visible text. + +`data.placeholder.remove` deletes the object. A text placeholder also deletes the `<>` characters it owns. Deleting those characters by editing deletes the placeholder. The role on the placeholder wins over the column kind. An `image` column inserted as `text` merges as the path string. A `text` column with role `image` merges as a picture. A `qr` column inserted as `text` merges as the payload string. + +A typed `<>` that is not covered by a text placeholder is still replaced with the field's text at preview and at merge. It never becomes a picture, a QR code, or a hyperlink. An unknown `<>` is left as typed. + +## Filling one record + +Used by preview and by the merge. Record numbers are 1-based positions in the cached rows after empty rows are skipped. + +- `text`: replace the anchored characters, or the typed `<>`, with the cell. Keep the character style of the first character of that range. +- `image`: resolve the cell as a file path. The first existing file wins: the path as given if it is absolute, then the path beside the data file, then the path beside the saved document. `http` and `https` are not downloaded. They are missing images. Place into that frame. `link_images` true creates a normal link. False embeds the bytes in the document assets and creates no external link. Fitting and `center` come from the merge options. A missing or unreadable file leaves the frame empty, adds `{ record, field, path }` to `missingImages`, and continues. +- `qr`: build a vector code with the same encoder as `object.qrCode` type `text`, using the cell as `content`, inside the frame, black. An empty cell leaves the frame empty and adds a warning. +- `hyperlink`: do not change visible text. If this same range is also a text placeholder, the text rule still runs. Trim the cell. Empty adds no link. If the value contains `://` or whitespace, the destination is a URL. Otherwise, if it contains exactly one `@` and no `:`, the destination is an email. Otherwise it is a URL. Create one hyperlink on that anchor for this record. A failed create adds a warning and continues. + +A placeholder whose frame or story lives on a parent spread is not filled. The merge copies it onto the new document's parent still showing `<>`, and adds one warning. + +## Preview + +`data.preview` takes `record`. The record stepper clamps to the cached rows. Preview fills the live template, and the session keeps a stash of the unfilled document. Save, `data.preview.stop`, switching away, removing the source, and `data.merge` restore that stash before they continue. Any other edit stops preview first, so the edit applies to the unfilled template. The file on disk never contains the preview text. + +## Merge + +`data.merge` reads the linked source when `path`, `bytes`, `csv`, and `rows` are all absent. Passing one of those reads it for that run only and does not attach a source. Attaching is `data.source.select`. + +The template document is not modified. Its undo stack is unchanged. Preview is cleared before the copy is made, so the new document is filled from the unfilled template, not from the preview. The command builds a new document, appends it to the session, and makes it active. The new document is a normal document: edit it and save it, or close it and discard the run. + +Record selection, applied to the cached rows: + +- `all`: every row +- `one`: that 1-based row. Out of range is an error +- `range`: a comma-separated list of tokens. A token is `N` or `A-B`, both ends inclusive, optional whitespace. `A` greater than `B` is an error. A number outside `1..=row count` is an error. Duplicates are dropped. Order is the order of first appearance +- Then `limit`, if set, keeps the first N selected records. `0` is an error + +No selected records is an error. No document is created. + +`per_page: single` copies the whole template once per record. A 3-page template writes 3 pages per record, all filled with that record. Pages, parent pages, styles, swatches, layers, and sections are copied. Document-page placeholders are filled. Parent placeholders stay unfilled. + +`per_page: multiple` is tiling. It is an error, and no document is created, when facing pages are on or the template has more than one page. The block is every item on the document page whose bounds intersect the page, except ruler guides. Pasteboard items that miss the page are drawn on the first merged page only. Ruler guides are drawn on every merged page and are not stepped. + +The block's width and height are the union of those items. `step_x` is that width plus `column_spacing`. `step_y` is that height plus `row_spacing`. The first record is placed at the block's original position, even when that position crosses an inset. Later records use whole steps `(col, row)` from that origin, with `col` and `row` starting at 0. + +Rows first increases `col` until the candidate's right edge would pass `page_width - right_inset`, then sets `col` to 0 and increases `row`. Columns first increases `row` until the candidate's bottom edge would pass `page_height - bottom_inset`, then sets `row` to 0 and increases `col`. A candidate that fails the other inset (`row` against the bottom, or `col` against the right) starts a new page, and that record is `(0, 0)` on the new page. The left and top insets do not move the origin. If a second copy cannot be placed on the first page at all, every record gets its own page, with one warning. + +Each merged page has the template page's size, margins, and guides. Pages after the first repeat the block and the guides, not the pasteboard-only items. The last page may be partly empty. No empty records are added to fill a grid. + +The command returns: + +- `records`: how many records were written +- `pages`: pages in the new document +- `missingImages`: `{ record, field, path }` +- `oversetStories`: how many stories in the new document are overset +- `warnings`: strings + +Overset text does not fail the merge. The old `spread` parameter is removed. + +## Panel + +Window > Utilities > Data Merge opens the panel. It does not merge by itself. The panel reads engine state and calls `app.run`. + +The panel shows the source file name, or "No data source", plus Update and Remove. The field list shows a kind icon drawn in code (a T, a picture mark, a module grid), the field name, and how many placeholders use that field. Preview is a checkbox. The stepper is first, previous, a record number, next, and last. It is disabled until preview is on. Create Merged Document runs `data.merge` with the saved options. Those options are edited in that dialog: which records, records per page, and, when multiple is selected, arrange, insets, and spacing. Image options are fitting, center, and link images, plus the optional limit. + +Colors come from `theme::Tokens`. New strings go through the existing i18n table. No Adobe icons. + +On the web, and from the control channel, the caller passes `csv`, `rows`, or `bytes` plus `name`. There is no filesystem dialog. + +## Errors + +Every command returns `Result`. A bad path, a bad sheet name, a bad encoding, a bad header, a bad range, a tiling refusal, and the row or column cap are errors and create no document. A missing image, an empty QR cell, a failed hyperlink, a modified source, an ignored extra cell, and a parent placeholder are warnings on a successful merge. + +No `unwrap`, `expect`, `panic`, or `unsafe` in the new code. Sizes and offsets use checked arithmetic. The row and column caps bound the allocation. Locks follow the crate's poison policy. + +## Tests + +Engine tests, with synthetic strings and tiny files in memory: + +- Quoted commas, doubled quotes, and a quoted newline stay one field. The existing comma parser already covers the first two. +- Tab and semicolon files, and `.txt` sniffing for each. +- Excel: first sheet, a named sheet, a cached number, an empty formula cell, a boolean, and a date serial left as digits. +- A non-UTF-8 file, an empty header, and a duplicate header are errors. +- `@Photo` and `#Code` become fields `Photo`/`image` and `Code`/`qr`. +- Typed `<>` is replaced. Typed `<<Nope>>` stays. A text placeholder keeps its character style. +- Image resolution: absolute, beside the data file, beside the document. A missing file continues, with `missingImages` set and an empty frame. An `https` value is missing and is not fetched. +- A `text` field with role `image` places a picture. An `image` field with role `text` inserts the path. +- A QR placeholder encodes the cell text. An empty cell warns. +- Hyperlink: `a@b.c` is email, `https://example.com` is a URL, empty adds no link. +- Single-record merge of a 2-page template and 3 records writes 6 pages. The template's page count and undo length are unchanged. The new document is active. +- Tiling a single non-facing page, six records, rows first, produces the measured grid: three across, then the next row, step equal to the block plus spacing. A block that crosses the bottom inset starts a new page. +- Tiling a facing-page document, and tiling a two-page document, each return an error and add no document. +- Update re-reads a changed fingerprint. Merge with `modified` warns and uses the cache. Remove leaves placeholders. +- 100,001 rows is an error. +- Preview of record 2 shows that row. Stop restores `<<Name>>`. The saved document bytes do not contain the preview text. +- Inline `rows` still merges, including a non-string number. + +UI: the panel lists fields from `data.fields`, and Create Merged Document calls `data.merge`. A headless `designcraft-cli` run covers the command. Wasm still accepts `rows`. + +## Out of scope + +Multiple sources, enabling and disabling sources, JSON, filter rules, sort, skip-on-warning, source filename and merge index fields, a drawn grid (rows, columns, gutter, record offset, record advance, start corner), QR codes from an unmarked column via the QR tool, inline pictures in a story, per-record parent-page fields, downloading remote images, evaluating Excel formulas, formatting Excel dates, `.xls`, and merging straight to PDF. From b3dafb8244c5a6414c15284a79d0db4e14864b68 Mon Sep 17 00:00:00 2001 From: Cameron Gilroy <git@camerongilroy.com> Date: Wed, 7 Oct 2026 21:22:06 +1100 Subject: [PATCH 02/13] Data merge: store a linked source, placeholders, and options A document keeps one data source, the placeholders that point at its fields, and the last merge layout. Empty merge state is omitted from the file. New documents and IDML imports start with that state empty. --- crates/doc/src/build.rs | 3 +- crates/doc/src/datamerge.rs | 268 ++++++++++++++++++++++++++++++++++++ crates/doc/src/lib.rs | 8 ++ crates/idml/src/import.rs | 1 + 4 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 crates/doc/src/datamerge.rs diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index ea58043c0..62d39f280 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -11,7 +11,7 @@ use crate::ids::{ItemId, LayerId, PageId, SpreadId}; use crate::item::Item; use crate::page::{Columns, Margins, Page, PageSide, ParentInfo, Section, Spread}; use crate::styles::Styles; -use crate::{DocError, DocSettings, Document, Intent, LAYER_COLORS, Layer, ParaFormat, Result, SpreadRef}; +use crate::{DataMerge, DocError, DocSettings, Document, Intent, LAYER_COLORS, Layer, ParaFormat, Result, SpreadRef}; /// A New Document preset. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -145,6 +145,7 @@ impl Document { }], assets: BTreeMap::new(), hyperlinks: vec![], + data_merge: DataMerge::default(), bookmarks: vec![], user_words: vec![], hyphenation_exceptions: vec![], diff --git a/crates/doc/src/datamerge.rs b/crates/doc/src/datamerge.rs new file mode 100644 index 000000000..2bf06c846 --- /dev/null +++ b/crates/doc/src/datamerge.rs @@ -0,0 +1,268 @@ +//! Data merge: one linked table, placeholders, and the last merge options. +//! +//! Phase 1 stores at most one source. The list is the seam for a later multi-source manager. +//! Preview is session state and is not stored here. + +use serde::{Deserialize, Serialize}; + +use crate::{ItemId, StoryId}; + +/// How a column is read when a placeholder does not say otherwise. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum DataFieldKind { + #[default] + Text, + Image, + Qr, +} + +impl DataFieldKind { + pub fn as_str(self) -> &'static str { + match self { + DataFieldKind::Text => "text", + DataFieldKind::Image => "image", + DataFieldKind::Qr => "qr", + } + } +} + +/// A column in a data source. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DataField { + pub name: String, + pub kind: DataFieldKind, +} + +/// Text delimiter. Excel sources leave this unused. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Delimiter { + #[default] + Comma, + Tab, + Semicolon, +} + +impl Delimiter { + pub fn as_str(self) -> &'static str { + match self { + Delimiter::Comma => "comma", + Delimiter::Tab => "tab", + Delimiter::Semicolon => "semicolon", + } + } + + pub fn parse(s: &str) -> Option<Self> { + match s { + "comma" => Some(Delimiter::Comma), + "tab" => Some(Delimiter::Tab), + "semicolon" => Some(Delimiter::Semicolon), + _ => None, + } + } + + pub fn char(self) -> char { + match self { + Delimiter::Comma => ',', + Delimiter::Tab => '\t', + Delimiter::Semicolon => ';', + } + } +} + +/// File size and modification time of the last successful read. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Fingerprint { + pub size: u64, + pub mtime: i64, +} + +/// Whether the file on disk still matches the cache. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SourceStatus { + #[default] + Ok, + Missing, + Modified, +} + +/// One linked table. Rows are the cache. Each row is the same length as `fields`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DataSource { + pub id: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub relative_path: Option<String>, + pub name: String, + #[serde(default)] + pub delimiter: Delimiter, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sheet: Option<String>, + pub fields: Vec<DataField>, + pub rows: Vec<Vec<String>>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fingerprint: Option<Fingerprint>, + #[serde(default)] + pub status: SourceStatus, + /// Extra-cell notes from the last read, repeated on a later merge. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub warnings: Vec<String>, +} + +/// What a placeholder does at fill time. This wins over the column kind. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PlaceholderRole { + Text, + Image, + Qr, + Hyperlink, +} + +impl PlaceholderRole { + pub fn as_str(self) -> &'static str { + match self { + PlaceholderRole::Text => "text", + PlaceholderRole::Image => "image", + PlaceholderRole::Qr => "qr", + PlaceholderRole::Hyperlink => "hyperlink", + } + } + + pub fn parse(s: &str) -> Option<Self> { + match s { + "text" => Some(PlaceholderRole::Text), + "image" => Some(PlaceholderRole::Image), + "qr" => Some(PlaceholderRole::Qr), + "hyperlink" => Some(PlaceholderRole::Hyperlink), + _ => None, + } + } +} + +/// Where the placeholder sits. Offsets match `HyperlinkSource::Text` (story byte offsets). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum PlaceholderAnchor { + Text { story: StoryId, start: usize, end: usize }, + Item { id: ItemId }, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Placeholder { + pub id: u64, + pub source_id: u64, + pub field: String, + pub role: PlaceholderRole, + pub anchor: PlaceholderAnchor, +} + +/// Last merge options, saved with the document. The merge command reads these and does not +/// write them back, so a one-off run leaves the template's undo stack alone. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MergeOptions { + /// `all`, `one`, or `range`. + #[serde(default = "all_records")] + pub records: String, + /// 1-based record used when `records` is `one`. + #[serde(default = "one_u32")] + pub one: u32, + /// Range string used when `records` is `range`. + #[serde(default)] + pub range: String, + /// `single` or `multiple`. + #[serde(default = "single_page")] + pub per_page: String, + /// `rows` or `columns`. Used when tiling. + #[serde(default = "arrange_rows")] + pub arrange: String, + /// Top, right, bottom, left, in points. + #[serde(default = "default_insets")] + pub insets: [f64; 4], + #[serde(default)] + pub column_spacing: f64, + #[serde(default)] + pub row_spacing: f64, + /// `fitProportionally`, `fillProportionally`, `fitContentToFrame`, or `none`. + #[serde(default = "fit_prop")] + pub fitting: String, + #[serde(default)] + pub center: bool, + #[serde(default = "yes")] + pub link_images: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option<u32>, +} + +fn all_records() -> String { + "all".into() +} +fn one_u32() -> u32 { + 1 +} +fn single_page() -> String { + "single".into() +} +fn arrange_rows() -> String { + "rows".into() +} +fn default_insets() -> [f64; 4] { + [36.0, 36.0, 36.0, 36.0] +} +fn fit_prop() -> String { + "fitProportionally".into() +} +fn yes() -> bool { + true +} + +impl Default for MergeOptions { + fn default() -> Self { + MergeOptions { + records: all_records(), + one: 1, + range: String::new(), + per_page: single_page(), + arrange: arrange_rows(), + insets: default_insets(), + column_spacing: 0.0, + row_spacing: 0.0, + fitting: fit_prop(), + center: false, + link_images: true, + limit: None, + } + } +} + +impl MergeOptions { + pub fn is_default(&self) -> bool { + self == &MergeOptions::default() + } +} + +/// Document data-merge state. Empty state is omitted from the save. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DataMerge { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub sources: Vec<DataSource>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub placeholders: Vec<Placeholder>, + #[serde(default, skip_serializing_if = "MergeOptions::is_default")] + pub options: MergeOptions, +} + +impl DataMerge { + pub fn is_empty(&self) -> bool { + self.sources.is_empty() && self.placeholders.is_empty() && self.options.is_default() + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 271126d53..23d08930e 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -13,6 +13,7 @@ pub mod anchored; pub mod arrow; pub mod attrs; pub mod build; +pub mod datamerge; mod edit; pub mod endnotes; pub mod ids; @@ -34,6 +35,10 @@ use std::sync::Arc; pub use anchored::{AnchorPosition, AnchoredObject, OBJECT_MARK}; pub use attrs::*; +pub use datamerge::{ + DataField, DataFieldKind, DataMerge, DataSource, Delimiter, Fingerprint, MergeOptions, Placeholder, PlaceholderAnchor, PlaceholderRole, + SourceStatus, +}; pub use designcraft_color as color; pub use designcraft_geom as geom; pub use edit::{ItemLoc, ItemPath, SpreadRef, item_hit as edit_hit}; @@ -482,6 +487,9 @@ pub struct Document { /// Hyperlinks (Window → Interactive → Hyperlinks). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub hyperlinks: Vec<Hyperlink>, + /// Data merge: the linked table, placeholders, and the last merge options. + #[serde(default, skip_serializing_if = "DataMerge::is_empty")] + pub data_merge: DataMerge, /// PDF bookmarks (Window → Interactive → Bookmarks). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub bookmarks: Vec<Bookmark>, diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 8cf17fe04..ac6b0d8a6 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -2123,6 +2123,7 @@ impl<'r> Importer<'r> { sections: std::mem::take(&mut self.sections), assets: std::mem::take(&mut self.assets), hyperlinks: vec![], + data_merge: Default::default(), bookmarks: vec![], user_words: vec![], hyphenation_exceptions: vec![], From a83572a3b0987ace436fd7357ceb055b87966af4 Mon Sep 17 00:00:00 2001 From: Cameron Gilroy <git@camerongilroy.com> Date: Wed, 7 Oct 2026 21:22:06 +1100 Subject: [PATCH 03/13] Data merge: read delimited text and an Excel sheet as rows Records keep empty cells inside the header width and cap the table at 100,000 rows and 200 columns. Place as a table still uses the existing Excel import, which drops empty cells. --- crates/textimport/src/lib.rs | 12 +++ crates/textimport/src/xlsx.rs | 167 +++++++++++++++++++++++++++++++++- 2 files changed, 178 insertions(+), 1 deletion(-) diff --git a/crates/textimport/src/lib.rs b/crates/textimport/src/lib.rs index fd328a822..8dc50ea7e 100644 --- a/crates/textimport/src/lib.rs +++ b/crates/textimport/src/lib.rs @@ -14,6 +14,18 @@ mod rtf; pub mod tagged; mod xlsx; +/// Data-merge row cap (header not included). More than this is an error. +pub const DATA_MERGE_MAX_ROWS: usize = 100_000; +/// Data-merge column cap. More than this is an error. +pub const DATA_MERGE_MAX_COLS: usize = 200; + +/// Worksheet rows for data merge. Empty cells inside the header width are kept. +/// `sheet` names a worksheet; `None` is the first sheet in workbook order. +/// Place keeps [`xlsx::import`], which drops empty cells. +pub fn xlsx_records(bytes: &[u8], sheet: Option<&str>) -> Result<Vec<Vec<String>>, ImportError> { + xlsx::records(bytes, sheet) +} + use designcraft_doc::{CharAttrs, ParaAttrs, Story}; /// A paragraph or character style found in the source. diff --git a/crates/textimport/src/xlsx.rs b/crates/textimport/src/xlsx.rs index b131698c3..5f93d3458 100644 --- a/crates/textimport/src/xlsx.rs +++ b/crates/textimport/src/xlsx.rs @@ -1,5 +1,8 @@ //! Excel workbooks (`.xlsx`) → a story holding one table: the first worksheet's used range, cell -//! values as shown text (shared and inline strings, numbers, booleans). +//! values as shown text (shared and inline strings, numbers, booleans). Data merge uses +//! [`records`], which keeps empty cells and can name a sheet. + +use std::collections::BTreeMap; use designcraft_doc::{CharFormat, ParaFormat, Story, StoryId, Table}; @@ -50,6 +53,118 @@ fn value(c: &El, shared: &[String]) -> String { } } +/// One worksheet as a grid of cached cell text, empty cells kept, for data merge. +/// The first used row is the header. Later rows that are entirely empty are left out. +/// Cells past the header are kept so the caller can warn. Over 200 columns or 100,000 +/// data rows is an error and the grid is not built. +pub fn records(bytes: &[u8], sheet: Option<&str>) -> Result<Vec<Vec<String>>, ImportError> { + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| ImportError::Corrupt(e.to_string()))?; + let sheet_part = sheet_target(&mut zip, sheet)?; + let sheet_xml = parse(&part(&mut zip, &sheet_part).ok_or_else(|| ImportError::Corrupt(format!("no {sheet_part} (not an Excel workbook)")))?)?; + let shared: Vec<String> = part(&mut zip, "xl/sharedStrings.xml") + .map(|x| parse(&x)) + .transpose()? + .map(|sst| sst.els().filter(|e| e.name == "si").map(El::text).collect()) + .unwrap_or_default(); + let mut cells: Vec<(usize, usize, String)> = Vec::new(); + if let Some(data) = sheet_xml.child("sheetData") { + for (ri, row) in data.els().filter(|e| e.name == "row").enumerate() { + for (ci, c) in row.els().filter(|e| e.name == "c").enumerate() { + let (r, col) = c.attr("r").and_then(cell_ref).unwrap_or((ri, ci)); + cells.push((r, col, value(c, &shared))); + } + } + } + if cells.is_empty() { + return Err(ImportError::Corrupt("the worksheet is empty".into())); + } + let header_row = cells.iter().map(|c| c.0).min().unwrap_or(0); + let mut min_c = usize::MAX; + let mut max_c = 0usize; + let mut header_used = false; + for (r, c, _) in &cells { + if *r == header_row { + header_used = true; + min_c = min_c.min(*c); + max_c = max_c.max(*c); + } + } + if !header_used || min_c == usize::MAX { + return Err(ImportError::Corrupt("the worksheet has no header".into())); + } + let width = max_c.saturating_sub(min_c).saturating_add(1); + if width > crate::DATA_MERGE_MAX_COLS { + return Err(ImportError::Corrupt(format!("the sheet has {width} columns; the limit is {}", crate::DATA_MERGE_MAX_COLS))); + } + // Group by row. Gaps (empty rows) are not materialised. + let mut by_row: BTreeMap<usize, Vec<(usize, String)>> = BTreeMap::new(); + for (r, c, v) in cells { + by_row.entry(r).or_default().push((c, v)); + } + let mut data_rows = 0usize; + for (r, cols) in &by_row { + if *r == header_row { + continue; + } + let any = cols.iter().any(|(_, v)| !v.is_empty()); + if any { + data_rows = data_rows.saturating_add(1); + } + } + if data_rows > crate::DATA_MERGE_MAX_ROWS { + return Err(ImportError::Corrupt(format!("the sheet has {data_rows} data rows; the limit is {}", crate::DATA_MERGE_MAX_ROWS))); + } + let mut out = Vec::with_capacity(data_rows.saturating_add(1)); + for (r, cols) in &by_row { + let mut row = vec![String::new(); width]; + let mut extra: Vec<String> = Vec::new(); + for (c, v) in cols { + if *c < min_c { + // Outside the header span. Keep one non-empty value so the caller can warn. + if !v.is_empty() { + extra.push(v.clone()); + } + continue; + } + let at = c - min_c; + if at < width { + if let Some(cell) = row.get_mut(at) { + *cell = v.clone(); + } + } else if !v.is_empty() { + extra.push(v.clone()); + } + } + if *r != header_row && row.iter().all(|c| c.is_empty()) && extra.is_empty() { + continue; + } + row.extend(extra); + out.push(row); + } + Ok(out) +} + +/// Workbook order, or the sheet named `wanted`. +fn sheet_target(zip: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>, wanted: Option<&str>) -> Result<String, ImportError> { + let wb = parse(&part(zip, "xl/workbook.xml").ok_or_else(|| ImportError::Corrupt("no xl/workbook.xml (not an Excel workbook)".into()))?)?; + let sheets: Vec<(String, String)> = wb + .child("sheets") + .map(|s| s.els().filter(|e| e.name == "sheet").filter_map(|e| Some((e.attr("name")?.to_string(), e.attr("id")?.to_string()))).collect()) + .unwrap_or_default(); + let (name, rid) = match wanted { + None => sheets.first().cloned().ok_or_else(|| ImportError::Corrupt("the workbook has no sheets".into()))?, + Some(w) => sheets.into_iter().find(|(n, _)| n == w).ok_or_else(|| ImportError::Corrupt(format!("no worksheet \"{w}\"")))?, + }; + let rels = parse(&part(zip, "xl/_rels/workbook.xml.rels").ok_or_else(|| ImportError::Corrupt("no workbook relationships".into()))?)?; + let target = rels + .els() + .find(|r| r.attr("Id") == Some(rid.as_str())) + .and_then(|r| r.attr("Target")) + .ok_or_else(|| ImportError::Corrupt(format!("worksheet \"{name}\" has no file")))?; + let target = target.trim_start_matches('/'); + Ok(if target.starts_with("xl/") { target.to_string() } else { format!("xl/{target}") }) +} + pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> { let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| ImportError::Corrupt(e.to_string()))?; let sheet_part = first_sheet(&mut zip).unwrap_or_else(|| "xl/worksheets/sheet1.xml".into()); @@ -163,4 +278,54 @@ mod tests { assert!(!imp.warnings.is_empty()); assert_eq!(cell_ref("XFDXFDXFDXFDXFDXFD1"), None); } + + fn sheets(parts: &[(&str, &str)]) -> Vec<u8> { + let mut buf = std::io::Cursor::new(Vec::new()); + { + let mut z = zip::ZipWriter::new(&mut buf); + let o = zip::write::SimpleFileOptions::default(); + for (name, body) in parts { + z.start_file(*name, o).unwrap(); + z.write_all(body.as_bytes()).unwrap(); + } + z.finish().unwrap(); + } + buf.into_inner() + } + + #[test] + fn records_keep_empty_cells_cached_values_and_a_named_sheet() { + let first = workbook(); + let rows = records(&first, None).unwrap(); + assert_eq!(rows[0], vec!["Item".to_string(), "Price".to_string()]); + assert_eq!(rows[1], vec!["Tea".to_string(), "3.5".to_string()]); + assert_eq!(rows[2][1], "12"); + let book = sheets(&[ + ( + "xl/workbook.xml", + r#"<workbook xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="Ignore" sheetId="1" r:id="rId1"/><sheet name="People" sheetId="2" r:id="rId2"/></sheets></workbook>"#, + ), + ( + "xl/_rels/workbook.xml.rels", + r#"<Relationships><Relationship Id="rId1" Type="x" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="x" Target="worksheets/sheet2.xml"/></Relationships>"#, + ), + ( + "xl/worksheets/sheet1.xml", + r#"<worksheet><sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>Nope</t></is></c></row></sheetData></worksheet>"#, + ), + ( + "xl/worksheets/sheet2.xml", + r#"<worksheet><sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>Name</t></is></c><c r="B1" t="inlineStr"><is><t>Qty</t></is></c><c r="C1" t="inlineStr"><is><t>On</t></is></c><c r="D1" t="inlineStr"><is><t>When</t></is></c></row><row r="2"><c r="A2" t="inlineStr"><is><t>Ada</t></is></c><c r="B2"><v>12.0</v></c><c r="C2" t="b"><v>1</v></c><c r="D2"><v>44927</v></c></row><row r="3"><c r="A3" t="inlineStr"><is><t>Grace</t></is></c><c r="C3"><f>1+1</f></c></row><row r="4"><c r="B4"><v>1</v></c></row></sheetData></worksheet>"#, + ), + ]); + let rows = records(&book, Some("People")).unwrap(); + assert_eq!(rows[0], vec!["Name".to_string(), "Qty".to_string(), "On".to_string(), "When".to_string()]); + assert_eq!(rows[1], vec!["Ada".to_string(), "12".to_string(), "TRUE".to_string(), "44927".to_string()]); + // Formula with no cached value is empty. The short row is padded. The all-empty gap is skipped + // only when every cell is empty; row 4 has a Qty, so it stays, and Name is empty. + assert_eq!(rows[2], vec!["Grace".to_string(), String::new(), String::new(), String::new()]); + assert_eq!(rows[3][0], ""); + assert_eq!(rows[3][1], "1"); + assert!(records(&book, Some("Missing")).is_err()); + } } From b41eba1cbaf759d1056367cc19ed96e3b2db8b34 Mon Sep 17 00:00:00 2001 From: Cameron Gilroy <git@camerongilroy.com> Date: Wed, 7 Oct 2026 21:22:06 +1100 Subject: [PATCH 04/13] Data merge: merge into a new document, with preview and tiling Select, update, and remove a linked source. Placeholders cover text, images, QR codes, and hyperlinks. Preview fills one record in the session and is left out of save and recovery. Merge copies the template into a new document, one record per page, or tiles frames on a single non-facing page. A missing or changed file is marked and the cached rows are still used. The template undo stack is left as it was. --- crates/engine/src/cmd/datamerge.rs | 162 ----- crates/engine/src/cmd/datamerge/fill.rs | 438 ++++++++++++ crates/engine/src/cmd/datamerge/mod.rs | 875 +++++++++++++++++++++++ crates/engine/src/cmd/datamerge/parse.rs | 547 ++++++++++++++ crates/engine/src/cmd/datamerge/tests.rs | 787 ++++++++++++++++++++ crates/engine/src/cmd/file.rs | 17 +- crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/lib.rs | 28 +- crates/engine/src/recovery.rs | 2 +- 9 files changed, 2691 insertions(+), 166 deletions(-) delete mode 100644 crates/engine/src/cmd/datamerge.rs create mode 100644 crates/engine/src/cmd/datamerge/fill.rs create mode 100644 crates/engine/src/cmd/datamerge/mod.rs create mode 100644 crates/engine/src/cmd/datamerge/parse.rs create mode 100644 crates/engine/src/cmd/datamerge/tests.rs diff --git a/crates/engine/src/cmd/datamerge.rs b/crates/engine/src/cmd/datamerge.rs deleted file mode 100644 index f0cc84ee1..000000000 --- a/crates/engine/src/cmd/datamerge.rs +++ /dev/null @@ -1,162 +0,0 @@ -//! Data Merge (Window → Utilities → Data Merge): a template spread with `<<Field>>` placeholders -//! in its stories is duplicated once per CSV record and the placeholders are replaced. One undo step. - -use designcraft_doc::{Content, ItemId, SpreadRef}; -use designcraft_geom::Vec2; -use serde_json::{Value, json}; - -use super::{CommandSpec, bad, cmd, has_doc, str_param}; -use crate::{Result, Session}; - -pub fn specs() -> Vec<CommandSpec> { - vec![ - cmd!( - "data.merge", - "Create Merged Document…", - ["Window", "Utilities", "Data Merge"], - None, - "{csv (header row first) | rows: [{field: value}], spread?: template spread index (default 0)} → {records, pages}", - has_doc, - merge - ), - cmd!(query "data.fields", "Data Merge Fields", [], None, "{csv} → field names", has_doc, |_, p| { - let rows = parse_csv(str_param(p, "csv").unwrap_or("")); - Ok(json!(rows.first().cloned().unwrap_or_default())) - }), - ] -} - -/// RFC 4180-ish CSV: quoted fields, doubled quotes, CRLF/LF. -pub fn parse_csv(s: &str) -> Vec<Vec<String>> { - let mut rows = Vec::new(); - let mut row = Vec::new(); - let mut field = String::new(); - let mut quoted = false; - let mut chars = s.chars().peekable(); - while let Some(c) = chars.next() { - if quoted { - match c { - '"' if chars.peek() == Some(&'"') => { - field.push('"'); - chars.next(); - } - '"' => quoted = false, - c => field.push(c), - } - continue; - } - match c { - '"' if field.is_empty() => quoted = true, - ',' => row.push(std::mem::take(&mut field)), - '\r' => {} - '\n' => { - row.push(std::mem::take(&mut field)); - if !(row.len() == 1 && row[0].is_empty()) { - rows.push(std::mem::take(&mut row)); - } else { - row.clear(); - } - } - c => field.push(c), - } - } - if !field.is_empty() || !row.is_empty() { - row.push(field); - rows.push(row); - } - rows -} - -fn merge(s: &mut Session, p: &Value) -> Result<Value> { - let records: Vec<Vec<(String, String)>> = if let Some(rows) = p.get("rows").and_then(Value::as_array) { - rows.iter() - .map(|r| { - r.as_object() - .map(|o| o.iter().map(|(k, v)| (k.clone(), v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string()))).collect()) - .unwrap_or_default() - }) - .collect() - } else { - let rows = parse_csv(str_param(p, "csv").ok_or_else(|| bad("data.merge", "give `csv` or `rows`"))?); - let Some((head, body)) = rows.split_first() else { return Err(bad("data.merge", "empty CSV")) }; - body.iter().map(|r| head.iter().cloned().zip(r.iter().cloned().chain(std::iter::repeat(String::new()))).collect()).collect() - }; - if records.is_empty() { - return Err(bad("data.merge", "no records")); - } - let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize; - s.edit(|d, sel| { - let template = d.spreads.get(si).cloned().ok_or_else(|| bad("data.merge", "no such spread"))?; - let tpl_ids: Vec<ItemId> = template.items.iter().map(|i| i.id).collect(); - let parent = template.pages.first().and_then(|p| p.parent); - let src = d.clone(); - // Spreads to fill: the template itself for record 0, then one copy per further record. - let mut targets: Vec<Vec<ItemId>> = vec![tpl_ids.clone()]; - for _ in 1..records.len() { - let after = d.page_count() - 1; - d.insert_pages(Some(after), template.pages.len(), parent)?; - let (nsi, _) = d.page_loc(after + 1).ok_or_else(|| bad("data.merge", "insert failed"))?; - let ids = super::object::duplicate_from(d, &src, &tpl_ids, SpreadRef::Doc(nsi), Vec2::ZERO)?; - targets.push(ids); - } - for (rec, ids) in records.iter().zip(&targets) { - let mut stories = Vec::new(); - for id in ids { - if let Some(it) = d.item(*id) { - it.walk(&mut |i| { - if let Content::Text(tf) = &i.content - && !stories.contains(&tf.story) - { - stories.push(tf.story); - } - }); - } - } - for sid in stories { - let Some(st) = d.story_mut(sid) else { continue }; - for (k, v) in rec { - let pat = format!("<<{k}>>"); - while let Some(pos) = st.text.find(&pat) { - st.replace(pos..pos + pat.len(), v); - } - } - } - } - *sel = Default::default(); - Ok(json!({"records": records.len(), "pages": d.page_count()})) - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn csv_parsing() { - let r = parse_csv("Name,Title\n\"Ada, Countess\",\"Analyst \"\"No. 1\"\"\"\r\nGrace,Admiral\n"); - assert_eq!(r, vec![vec!["Name", "Title"], vec!["Ada, Countess", "Analyst \"No. 1\""], vec!["Grace", "Admiral"]]); - } - - #[test] - fn merge_duplicates_pages_and_fills_fields() { - let mut s = Session::new(); - s.execute("file.new", &json!({"facingPages": false})).unwrap(); - s.execute("frame.create", &json!({"rect": [36, 36, 400, 100], "content": "text", "text": "Dear <<Name>>, welcome aboard as <<Title>>."})) - .unwrap(); - let r = s.execute("data.merge", &json!({"csv": "Name,Title\nAda,Analyst\nGrace,Admiral\nKatherine,Mathematician\n"})).unwrap(); - assert_eq!(r["records"], 3); - assert_eq!(r["pages"], 3); - let d = &s.doc().unwrap().doc; - let mut texts: Vec<String> = d.stories.values().map(|st| st.text.clone()).collect(); - texts.sort(); - assert_eq!( - texts, - vec![ - "Dear Ada, welcome aboard as Analyst.".to_string(), - "Dear Grace, welcome aboard as Admiral.".to_string(), - "Dear Katherine, welcome aboard as Mathematician.".to_string(), - ] - ); - assert_eq!(s.doc().unwrap().history.undo.len(), 2); // create frame + merge - } -} diff --git a/crates/engine/src/cmd/datamerge/fill.rs b/crates/engine/src/cmd/datamerge/fill.rs new file mode 100644 index 000000000..1d26e24c9 --- /dev/null +++ b/crates/engine/src/cmd/datamerge/fill.rs @@ -0,0 +1,438 @@ +//! Fill one data-merge record into a document, and copy template items for later records. +//! +//! Placeholders are addressed through an id map so each record's copy can be filled from the +//! unfilled template. A placeholder whose frame or story is not in the map is left as it is. + +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; + +use designcraft_doc::{ + Asset, AssetId, Content, DataField, Fill, Fitting, Graphic, Hyperlink, HyperlinkDest, HyperlinkSource, Item, ItemId, MergeOptions, + PlaceholderAnchor, PlaceholderRole, Shape, SpreadRef, Story, StoryId, +}; +use designcraft_geom::{Affine, Rect, Vec2}; + +use super::parse::image_candidates; +use crate::EngineError; + +#[derive(Clone, Debug, Default)] +pub struct Maps { + pub items: HashMap<ItemId, ItemId>, + pub stories: HashMap<StoryId, StoryId>, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct MissingImage { + pub record: u32, + pub field: String, + pub path: String, +} + +#[derive(Clone, Debug, Default)] +pub struct FillReport { + pub missing: Vec<MissingImage>, + pub warnings: Vec<String>, +} + +/// Document-spread items and their stories, each mapped to itself. +pub fn identity_map(doc: &designcraft_doc::Document) -> Maps { + let mut maps = Maps::default(); + for sp in &doc.spreads { + for it in &sp.items { + it.walk(&mut |i| { + maps.items.insert(i.id, i.id); + if let Content::Text(tf) = &i.content { + maps.stories.insert(tf.story, tf.story); + } + }); + } + } + maps +} + +/// Deep-copy one top-level item. Children stay in their own space; only this item is translated. +pub fn copy_item( + dst: &mut designcraft_doc::Document, + src: &designcraft_doc::Document, + it: &Item, + to: SpreadRef, + off: Vec2, + maps: &mut Maps, +) -> Result<(), EngineError> { + let mut copy = it.clone(); + copy.xf = Affine::translate(off) * copy.xf; + renumber(dst, src, &mut copy, maps); + if dst.layer(copy.layer).is_none() { + copy.layer = dst.default_layer(); + } + dst.insert_item(to, copy, None)?; + Ok(()) +} + +fn renumber(dst: &mut designcraft_doc::Document, src: &designcraft_doc::Document, it: &mut Item, maps: &mut Maps) { + let old = it.id; + let new_id = ItemId(dst.alloc()); + maps.items.insert(old, new_id); + it.id = new_id; + match &mut it.content { + Content::Text(tf) => { + let new_sid = if let Some(n) = maps.stories.get(&tf.story) { + *n + } else { + let n = StoryId(dst.alloc()); + let mut st = src.story(tf.story).cloned().unwrap_or_else(|| Story::new(n)); + st.id = n; + st.frames.clear(); + dst.stories.insert(n, Arc::new(st)); + maps.stories.insert(tf.story, n); + n + }; + tf.story = new_sid; + if let Some(st) = dst.story_mut(new_sid) { + st.frames.push(new_id); + } + } + Content::Graphic(g) => { + if let Some(a) = src.assets.get(&g.asset) { + dst.assets.entry(g.asset).or_insert_with(|| a.clone()); + } + } + Content::Group { items } => { + for child in items.iter_mut() { + renumber(dst, src, Arc::make_mut(child), maps); + } + } + Content::Unassigned => {} + } +} + +struct Rep { + story: StoryId, + start: usize, + end: usize, + text: String, +} + +/// Fill `row` (0-based) into the items `maps` points at. `record` is the 1-based source row. +pub fn fill_row( + doc: &mut designcraft_doc::Document, + fields: &[DataField], + row: &[String], + record: u32, + options: &MergeOptions, + maps: &Maps, + data_dir: Option<&Path>, + doc_dir: Option<&Path>, +) -> FillReport { + let mut report = FillReport::default(); + let mut parent = false; + let mut reps: Vec<Rep> = Vec::new(); + let mut links: Vec<(PlaceholderAnchor, HyperlinkDest, String)> = Vec::new(); + let placeholders = doc.data_merge.placeholders.clone(); + for ph in &placeholders { + if !fields.iter().any(|f| f.name == ph.field) { + continue; + } + let cell = cell_of(fields, row, &ph.field).to_string(); + match &ph.anchor { + PlaceholderAnchor::Text { story, start, end } => { + let Some(&mapped) = maps.stories.get(story) else { + if anchor_is_parent(doc, &ph.anchor) { + parent = true; + } + continue; + }; + match ph.role { + PlaceholderRole::Text => reps.push(Rep { story: mapped, start: *start, end: *end, text: cell }), + PlaceholderRole::Hyperlink => { + if let Some(dest) = classify_link(&cell) { + links.push((PlaceholderAnchor::Text { story: mapped, start: *start, end: *end }, dest, ph.field.clone())); + } + } + PlaceholderRole::Image | PlaceholderRole::Qr => {} + } + } + PlaceholderAnchor::Item { id } => { + let Some(&mapped) = maps.items.get(id) else { + if anchor_is_parent(doc, &ph.anchor) { + parent = true; + } + continue; + }; + match ph.role { + PlaceholderRole::Image => place_image(doc, mapped, &ph.field, &cell, record, options, data_dir, doc_dir, &mut report), + PlaceholderRole::Qr => place_qr(doc, mapped, &ph.field, &cell, record, &mut report), + PlaceholderRole::Hyperlink => { + if let Some(dest) = classify_link(&cell) { + links.push((PlaceholderAnchor::Item { id: mapped }, dest, ph.field.clone())); + } + } + PlaceholderRole::Text => {} + } + } + } + } + typed_reps(doc, fields, row, maps, &placeholders, &mut reps); + apply_reps(doc, &reps); + for (anchor, dest, field) in links { + let anchor = match anchor { + PlaceholderAnchor::Text { story, start, end } => { + let (start, end) = adjust_range(story, start, end, &reps); + PlaceholderAnchor::Text { story, start, end } + } + other => other, + }; + if !create_link(doc, anchor, dest) { + report.warnings.push(format!("Record {record}, field {field}: the hyperlink could not be created.")); + } + } + if parent { + report.warnings.push("A parent-page placeholder was left unfilled.".into()); + } + report +} + +fn typed_reps( + doc: &designcraft_doc::Document, + fields: &[DataField], + row: &[String], + maps: &Maps, + placeholders: &[designcraft_doc::Placeholder], + reps: &mut Vec<Rep>, +) { + let mut stories: Vec<(StoryId, StoryId)> = maps.stories.iter().map(|(a, b)| (*a, *b)).collect(); + stories.sort_by_key(|(a, _)| a.0); + stories.dedup(); + for (original, mapped) in stories { + let Some(st) = doc.story(mapped) else { continue }; + let covers: Vec<(usize, usize)> = placeholders + .iter() + .filter_map(|ph| match (&ph.role, &ph.anchor) { + (PlaceholderRole::Text, PlaceholderAnchor::Text { story, start, end }) if *story == original => Some((*start, *end)), + _ => None, + }) + .collect(); + for (start, end, name) in find_markers(&st.text) { + if covers.iter().any(|(a, b)| start < *b && end > *a) { + continue; + } + if !fields.iter().any(|f| f.name == name) { + continue; + } + reps.push(Rep { story: mapped, start, end, text: cell_of(fields, row, &name).to_string() }); + } + } +} + +fn find_markers(text: &str) -> Vec<(usize, usize, String)> { + let mut out = Vec::new(); + let mut i = 0; + while i < text.len() { + if text[i..].starts_with("<<") + && let Some(rel) = text[i + 2..].find(">>") + { + let name = &text[i + 2..i + 2 + rel]; + if !name.is_empty() && !name.contains(['<', '>', '\n']) { + let end = i + 2 + rel + 2; + out.push((i, end, name.to_string())); + i = end; + continue; + } + } + i += text[i..].chars().next().map(|c| c.len_utf8()).unwrap_or(1); + } + out +} + +fn apply_reps(doc: &mut designcraft_doc::Document, reps: &[Rep]) { + let mut stories: Vec<StoryId> = reps.iter().map(|r| r.story).collect(); + stories.sort(); + stories.dedup(); + for sid in stories { + let mut mine: Vec<&Rep> = reps.iter().filter(|r| r.story == sid).collect(); + mine.sort_by_key(|r| std::cmp::Reverse(r.start)); + let Some(st) = doc.story_mut(sid) else { continue }; + for r in mine { + if r.start <= r.end && r.end <= st.text.len() { + st.replace(r.start..r.end, &r.text); + } + } + } +} + +fn adjust_range(story: StoryId, start: usize, end: usize, reps: &[Rep]) -> (usize, usize) { + let mut mine: Vec<&Rep> = reps.iter().filter(|r| r.story == story).collect(); + mine.sort_by_key(|r| r.start); + let mut s = start; + let mut e = end; + for r in mine { + let old = r.end.saturating_sub(r.start); + let new = r.text.len(); + let delta = new as isize - old as isize; + if r.end <= start { + s = add_delta(s, delta); + e = add_delta(e, delta); + } else if r.start == start && r.end == end { + e = s.saturating_add(new); + } else if r.start >= end { + } else if r.end <= end { + e = add_delta(e, delta); + } + } + (s, e) +} + +pub fn add_delta(pos: usize, delta: isize) -> usize { + if delta >= 0 { pos.saturating_add(delta as usize) } else { pos.saturating_sub(delta.unsigned_abs()) } +} + +fn create_link(doc: &mut designcraft_doc::Document, anchor: PlaceholderAnchor, dest: HyperlinkDest) -> bool { + let source = match anchor { + PlaceholderAnchor::Item { id } => { + if doc.item(id).is_none() { + return false; + } + HyperlinkSource::Item { id } + } + PlaceholderAnchor::Text { story, start, end } => { + let Some(st) = doc.story(story) else { return false }; + if start > end || end > st.text.len() { + return false; + } + HyperlinkSource::Text { story, start, end } + } + }; + let id = doc.alloc(); + let name = match &dest { + HyperlinkDest::Url(u) | HyperlinkDest::Email(u) => u.clone(), + HyperlinkDest::Page(p) => format!("Page {}", p + 1), + }; + doc.hyperlinks.push(Hyperlink { id, name, source, dest }); + true +} + +fn place_image( + doc: &mut designcraft_doc::Document, + item: ItemId, + field: &str, + cell: &str, + record: u32, + options: &MergeOptions, + data_dir: Option<&Path>, + doc_dir: Option<&Path>, + report: &mut FillReport, +) { + let cands = image_candidates(cell, data_dir, doc_dir); + let found = cands.iter().find_map(|p| std::fs::read(p).ok().filter(|b| !b.is_empty()).map(|b| (p.clone(), b))); + let Some((path, bytes)) = found else { + if let Some(it) = doc.item_mut(item) { + it.content = Content::Unassigned; + } + report.missing.push(MissingImage { record, field: field.to_string(), path: cell.to_string() }); + report.warnings.push(format!("Record {record}, field {field}: missing image {cell}.")); + return; + }; + let Some((pw, ph)) = designcraft_render::image_size(&bytes) else { + if let Some(it) = doc.item_mut(item) { + it.content = Content::Unassigned; + } + report.missing.push(MissingImage { record, field: field.to_string(), path: cell.to_string() }); + report.warnings.push(format!("Record {record}, field {field}: missing image {cell}.")); + return; + }; + let Some(inner) = doc.item(item).map(|it| it.inner_bounds()) else { + report.warnings.push(format!("Record {record}, field {field}: missing image {cell}.")); + report.missing.push(MissingImage { record, field: field.to_string(), path: cell.to_string() }); + return; + }; + let (nw, nh) = (f64::from(pw), f64::from(ph)); + let mode = fitting_of(&options.fitting); + let aid = AssetId(doc.alloc()); + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| field.to_string()); + let link = options.link_images.then(|| path.to_string_lossy().into_owned()); + doc.assets.insert( + aid, + Arc::new(Asset { + id: aid, + name, + mime: designcraft_render::image_mime(&bytes).to_string(), + link, + data: Arc::new(bytes), + pixels: Some((pw, ph)), + page: 0, + }), + ); + let mut g = Graphic { asset: aid, size: (nw, nh), xf: Affine::translate((inner.x0, inner.y0)), auto_fit: mode, fit_align: 0, crop: [0.0; 4] }; + if let Some(xf) = g.fitted(inner, mode) { + g.xf = xf; + } + if options.center && nw > 0.0 && nh > 0.0 { + let bb = g.xf.transform_rect_bbox(Rect::new(0.0, 0.0, nw, nh)); + let c = inner.center(); + let m = bb.center(); + g.xf = Affine::translate((c.x - m.x, c.y - m.y)) * g.xf; + } + if let Some(it) = doc.item_mut(item) { + it.content = Content::Graphic(g); + } +} + +fn place_qr(doc: &mut designcraft_doc::Document, item: ItemId, field: &str, cell: &str, record: u32, report: &mut FillReport) { + if cell.is_empty() { + report.warnings.push(format!("Record {record}, field {field}: the QR cell is empty.")); + return; + } + let Some(it) = doc.item(item).cloned() else { + report.warnings.push(format!("Record {record}, field {field}: the QR content does not fit.")); + return; + }; + let Some(path) = designcraft_geom::qr::qr_path(cell, it.inner_bounds()) else { + report.warnings.push(format!("Record {record}, field {field}: the QR content does not fit.")); + return; + }; + let id = ItemId(doc.alloc()); + let mut code = Item::new(id, it.layer, Shape::Path, path); + code.fill = Fill::swatch(designcraft_color::swatch::BLACK); + code.alt_text = format!("QR code: {cell}"); + if let Some(frame) = doc.item_mut(item) { + frame.content = Content::Group { items: vec![Arc::new(code)] }; + } +} + +fn fitting_of(s: &str) -> Fitting { + match s { + "fillProportionally" => Fitting::FillProportionally, + "fitContentToFrame" => Fitting::FitContentToFrame, + "none" => Fitting::None, + _ => Fitting::FitProportionally, + } +} + +fn classify_link(cell: &str) -> Option<HyperlinkDest> { + let value = cell.trim(); + if value.is_empty() { + return None; + } + if value.contains("://") || value.chars().any(char::is_whitespace) { + return Some(HyperlinkDest::Url(value.to_string())); + } + if value.matches('@').count() == 1 && !value.contains(':') { + return Some(HyperlinkDest::Email(value.to_string())); + } + Some(HyperlinkDest::Url(value.to_string())) +} + +fn anchor_is_parent(doc: &designcraft_doc::Document, anchor: &PlaceholderAnchor) -> bool { + match anchor { + PlaceholderAnchor::Item { id } => matches!(doc.find(*id).map(|l| l.spread), Some(SpreadRef::Parent(_))), + PlaceholderAnchor::Text { story, .. } => { + let Some(st) = doc.story(*story) else { return false }; + !st.frames.is_empty() && st.frames.iter().all(|fid| matches!(doc.find(*fid).map(|l| l.spread), Some(SpreadRef::Parent(_)))) + } + } +} + +fn cell_of<'a>(fields: &[DataField], row: &'a [String], name: &str) -> &'a str { + fields.iter().position(|f| f.name == name).and_then(|i| row.get(i)).map(String::as_str).unwrap_or("") +} diff --git a/crates/engine/src/cmd/datamerge/mod.rs b/crates/engine/src/cmd/datamerge/mod.rs new file mode 100644 index 000000000..c698a5ea7 --- /dev/null +++ b/crates/engine/src/cmd/datamerge/mod.rs @@ -0,0 +1,875 @@ +//! Data merge commands: one linked table, placeholders, preview, and a new merged document. +//! +//! `data.merge` used to duplicate pages inside the template. It now leaves the template and its +//! undo stack alone and appends a new document. + +mod fill; +mod parse; + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use designcraft_doc::{ + DataField, DataFieldKind, DataSource, Delimiter, Document, HyperlinkSource, ItemId, MergeOptions, Page, Placeholder, PlaceholderAnchor, + PlaceholderRole, Selection, SourceStatus, SpreadRef, StoryId, +}; +use designcraft_geom::{Rect, Vec2}; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, cmd, has_doc, id_param, ok, str_param}; +use crate::{DocState, Result, Session}; +use fill::{FillReport, Maps, MissingImage, copy_item, fill_row, identity_map}; +use parse::{ + Table, TileInput, extension, fingerprint_of, relative_between, select_records, status_of, table_from_bytes, table_from_grid, table_from_objects, + tile, +}; + +const PARENT_WARNING: &str = "A parent-page placeholder was left unfilled."; + +pub fn specs() -> Vec<CommandSpec> { + vec![ + cmd!( + "data.source.select", + "Select Data Source…", + [], + None, + "{path | bytes|base64, name, delimiter?: comma|tab|semicolon, sheet?} → {fields, records, warnings}", + has_doc, + source_select + ), + cmd!("data.source.update", "Update Data Source", [], None, "{} → re-read the linked file", has_doc, source_update), + cmd!("data.source.remove", "Remove Data Source", [], None, "{} → drop the source; placeholders stay", has_doc, source_remove), + cmd!( + query "data.fields", + "Data Fields", + [], + None, + "{csv? | rows? | path? | bytes?, name?} → [{name, kind, uses}]; no params reads the linked source and does not attach one", + has_doc, + fields_cmd + ), + cmd!( + "data.placeholder.add", + "Insert Field", + [], + None, + "{field, role?: text|image|qr|hyperlink, story?, at?, end?, item?} → {id}", + has_doc, + placeholder_add + ), + cmd!("data.placeholder.remove", "Remove Field", [], None, "{id}", has_doc, placeholder_remove), + cmd!( + "data.options", + "Data Merge Options", + [], + None, + "{records?, one?, range?, perPage?, arrange?, insets?, columnSpacing?, rowSpacing?, fitting?, center?, linkImages?, limit?}", + has_doc, + options_cmd + ), + cmd!(query "data.preview", "Preview Record", [], None, "{record?: n} → fill the template with that record (session only)", has_doc, preview_cmd), + cmd!(noundo "data.preview.stop", "Stop Preview", [], None, "{} → restore the unfilled template", has_doc, |_s, _| ok()), + cmd!( + "data.merge", + "Create Merged Document…", + [], + None, + "{csv? | rows? | path? | bytes?, name?, records?, one?, range?, perPage?, arrange?, insets?, columnSpacing?, rowSpacing?, fitting?, center?, linkImages?, limit?} → {records, pages, missingImages, oversetStories, warnings}", + has_doc, + merge_cmd + ), + ] +} + +fn source_select(s: &mut Session, p: &Value) -> Result<Value> { + let (mut source, report) = read_source(p).map_err(|e| bad("data.source.select", e))?; + s.edit(move |d, _| { + source.id = d.data_merge.sources.first().map(|src| src.id).unwrap_or_else(|| d.alloc()); + d.data_merge.sources.clear(); + d.data_merge.sources.push(source); + Ok(report) + }) +} + +fn source_update(s: &mut Session, _p: &Value) -> Result<Value> { + let (path, name, delimiter, sheet) = { + let src = s.doc()?.doc.data_merge.sources.first().ok_or_else(|| bad("data.source.update", "select a data source"))?; + let path = src.path.clone().ok_or_else(|| bad("data.source.update", "this source has no file to update"))?; + (path, src.name.clone(), src.delimiter, src.sheet.clone()) + }; + let bytes = read_path(&path).map_err(|e| bad("data.source.update", e))?; + let table = parse_file(&bytes, &name, Some(delimiter), sheet.as_deref()).map_err(|e| bad("data.source.update", e))?; + let report = json!({ + "fields": field_pairs(&table.fields), + "records": table.rows.len(), + "warnings": table.warnings.clone(), + }); + s.edit(move |d, _| { + let src = d.data_merge.sources.first_mut().ok_or_else(|| bad("data.source.update", "select a data source"))?; + src.fields = table.fields; + src.rows = table.rows; + src.warnings = table.warnings; + src.delimiter = table.delimiter; + src.fingerprint = fingerprint_of(Path::new(&path)); + src.status = SourceStatus::Ok; + Ok(report) + }) +} + +fn source_remove(s: &mut Session, _p: &Value) -> Result<Value> { + s.edit(|d, _| { + d.data_merge.sources.clear(); + ok() + }) +} + +fn fields_cmd(s: &mut Session, p: &Value) -> Result<Value> { + if payload_present(p) { + let (table, _) = load_inline(p).map_err(|e| bad("data.fields", e))?; + return Ok(fields_json(&table.fields, |_| 0)); + } + let st = s.doc()?; + let Some(src) = st.doc.data_merge.sources.first() else { + return Ok(json!([])); + }; + let placeholders = st.doc.data_merge.placeholders.clone(); + Ok(fields_json(&src.fields, |name| placeholders.iter().filter(|ph| ph.field == name).count())) +} + +fn placeholder_add(s: &mut Session, p: &Value) -> Result<Value> { + let name = str_param(p, "field").ok_or_else(|| bad("data.placeholder.add", "missing `field`"))?.to_string(); + s.edit(|d, sel| add_placeholder(d, sel, p, &name)) +} + +fn placeholder_remove(s: &mut Session, p: &Value) -> Result<Value> { + let id = p.get("id").and_then(Value::as_u64).ok_or_else(|| bad("data.placeholder.remove", "missing `id`"))?; + s.edit(move |d, _| { + let Some(ph) = d.data_merge.placeholders.iter().find(|ph| ph.id == id).cloned() else { + return Err(bad("data.placeholder.remove", "no placeholder")); + }; + if let PlaceholderAnchor::Text { story, start, end } = ph.anchor { + let marker = format!("<<{}>>", ph.field); + let matches = d.story(story).and_then(|st| st.text.get(start..end)).is_some_and(|t| t == marker); + if matches { + if let Some(st) = d.story_mut(story) { + let end = end.min(st.text.len()); + let start = start.min(end); + st.replace(start..end, ""); + } + let removed = end.saturating_sub(start); + shift_after(d, story, end, -(removed as isize)); + } + } + d.data_merge.placeholders.retain(|ph| ph.id != id); + ok() + }) +} + +fn options_cmd(s: &mut Session, p: &Value) -> Result<Value> { + s.edit(|d, _| { + apply_options(&mut d.data_merge.options, p).map_err(|e| bad("data.options", e))?; + validate(&d.data_merge.options).map_err(|e| bad("data.options", e))?; + serde_json::to_value(&d.data_merge.options).map_err(|e| bad("data.options", e.to_string())) + }) +} + +fn preview_cmd(s: &mut Session, p: &Value) -> Result<Value> { + let st = s.doc_mut()?; + let source = st.doc.data_merge.sources.first().cloned().ok_or_else(|| bad("data.preview", "select a data source"))?; + if source.rows.is_empty() { + return Err(bad("data.preview", "no records")); + } + let max = source.rows.len() as u64; + let record = p.get("record").and_then(Value::as_u64).unwrap_or(1).clamp(1, max); + let base = if let Some(stash) = &st.preview_stash { + stash.clone() + } else { + let current = st.doc.clone(); + st.preview_stash = Some(current.clone()); + current + }; + let mut doc = (*base).clone(); + let options = doc.data_merge.options.clone(); + let maps = identity_map(&doc); + let row = source.rows.get((record as usize).saturating_sub(1)).cloned().unwrap_or_default(); + let data_dir = source.path.as_deref().and_then(parent_dir); + let doc_dir = st.path.as_deref().and_then(parent_dir); + fill_row(&mut doc, &source.fields, &row, record as u32, &options, &maps, data_dir.as_deref(), doc_dir.as_deref()); + st.doc = Arc::new(doc); + st.preview_record = Some(record as u32); + st.revision = st.revision.saturating_add(1); + Ok(json!({"record": record})) +} + +fn merge_cmd(s: &mut Session, p: &Value) -> Result<Value> { + if p.get("spread").is_some() { + return Err(bad("data.merge", "the spread parameter has been removed")); + } + let (table, mut warnings, data_dir, doc_dir, options, selected, src, linked_status) = { + let st = s.doc()?; + let (table, status_warnings, data_dir, linked_status) = if payload_present(p) { + let (table, dir) = load_inline(p).map_err(|e| bad("data.merge", e))?; + (table, Vec::new(), dir, None) + } else { + let (table, warnings, dir, status) = linked_table(&st.doc).map_err(|e| bad("data.merge", e))?; + (table, warnings, dir, Some(status)) + }; + let mut options = st.doc.data_merge.options.clone(); + apply_options(&mut options, p).map_err(|e| bad("data.merge", e))?; + validate(&options).map_err(|e| bad("data.merge", e))?; + let selected = + select_records(table.rows.len(), &options.records, options.one, &options.range, options.limit).map_err(|e| bad("data.merge", e))?; + if options.per_page == "multiple" && (st.doc.settings.facing_pages || st.doc.page_count() != 1) { + return Err(bad("data.merge", "multiple records on a page need one page with facing pages off")); + } + let doc_dir = st.path.as_deref().and_then(parent_dir); + let src = (*st.doc).clone(); + (table, status_warnings, data_dir, doc_dir, options, selected, src, linked_status) + }; + warnings.extend(table.warnings.iter().cloned()); + let mut merged = src.clone(); + let mut missing = Vec::new(); + if options.per_page == "multiple" { + tile_records(&mut merged, &src, &table, &selected, &options, data_dir.as_deref(), doc_dir.as_deref(), &mut warnings, &mut missing)?; + } else { + copy_records(&mut merged, &src, &table, &selected, &options, data_dir.as_deref(), doc_dir.as_deref(), &mut warnings, &mut missing)?; + } + // Status is written only after every error return. The new document then becomes active, + // so this template edit does not push an undo step. The fingerprint stays the last read. + if let Some(status) = linked_status { + remember_source_status(s, status); + set_source_status(&mut merged, status); + } + merged.data_merge.placeholders.clear(); + merged.title = format!("{} merged", src.title); + let pages = merged.page_count(); + let overset = overset_count(&merged); + let records = selected.len(); + let missing_json: Vec<Value> = missing.iter().map(|m| json!({"record": m.record, "field": m.field, "path": m.path})).collect(); + s.add_document(DocState::new(merged, None)); + Ok(json!({ + "records": records, + "pages": pages, + "missingImages": missing_json, + "oversetStories": overset, + "warnings": warnings, + })) +} + +fn copy_records( + dst: &mut Document, + src: &Document, + table: &Table, + selected: &[usize], + options: &MergeOptions, + data_dir: Option<&Path>, + doc_dir: Option<&Path>, + warnings: &mut Vec<String>, + missing: &mut Vec<MissingImage>, +) -> Result<()> { + let mut parent = false; + if let Some(&idx) = selected.first() { + let maps = identity_map(dst); + fill_at(dst, table, idx, options, &maps, data_dir, doc_dir, warnings, missing, &mut parent); + } + for &idx in selected.iter().skip(1) { + let maps = append_copy(dst, src)?; + fill_at(dst, table, idx, options, &maps, data_dir, doc_dir, warnings, missing, &mut parent); + } + Ok(()) +} + +fn append_copy(dst: &mut Document, src: &Document) -> Result<Maps> { + let start = dst.page_count(); + let n = src.page_count().max(1); + let parent = src.page(0).and_then(|p| p.parent); + dst.insert_pages(Some(start.saturating_sub(1)), n, parent)?; + for i in 0..n { + if let Some(pg) = src.page(i).cloned() { + assign_page(dst, start.saturating_add(i), &pg); + } + } + let mut maps = Maps::default(); + for (spi, sp) in src.spreads.iter().enumerate() { + for it in &sp.items { + let pi = sp.page_at_x(it.bounds().center().x).unwrap_or(0); + let abs = src.first_page_of_spread(spi).saturating_add(pi); + let dest_abs = start.saturating_add(abs); + let Some((dsi, _)) = dst.page_loc(dest_abs) else { continue }; + let src_x = src.page(abs).map(|p| p.x).unwrap_or(0.0); + let dst_x = dst.page(dest_abs).map(|p| p.x).unwrap_or(0.0); + copy_item(dst, src, it, SpreadRef::Doc(dsi), Vec2::new(dst_x - src_x, 0.0), &mut maps)?; + } + } + Ok(maps) +} + +fn tile_records( + dst: &mut Document, + src: &Document, + table: &Table, + selected: &[usize], + options: &MergeOptions, + data_dir: Option<&Path>, + doc_dir: Option<&Path>, + warnings: &mut Vec<String>, + missing: &mut Vec<MissingImage>, +) -> Result<()> { + let (block, ids) = block_of(src); + let (width, height) = src.page(0).map(|p| (p.width, p.height)).unwrap_or((612.0, 792.0)); + let plan = tile(&TileInput { + block, + page_width: width, + page_height: height, + insets: options.insets, + column_spacing: options.column_spacing, + row_spacing: options.row_spacing, + rows_first: options.arrange != "columns", + count: selected.len(), + }); + if plan.one_per_page { + warnings.push("Only one record fits on the page, so each record has its own page.".into()); + } + if plan.pages > 1 { + let parent = src.page(0).and_then(|p| p.parent); + let template = src.page(0).cloned(); + dst.insert_pages(Some(0), plan.pages.saturating_sub(1), parent)?; + if let Some(pg) = template { + for i in 1..plan.pages { + assign_page(dst, i, &pg); + } + } + } + let mut parent = false; + for hit in plan.hits { + let Some(&idx) = selected.get(hit.index) else { continue }; + if hit.index == 0 && hit.page == 0 && hit.col == 0 && hit.row == 0 { + let maps = identity_map(dst); + fill_at(dst, table, idx, options, &maps, data_dir, doc_dir, warnings, missing, &mut parent); + continue; + } + let Some((dsi, _)) = dst.page_loc(hit.page) else { continue }; + let mut maps = Maps::default(); + for id in &ids { + let Some(it) = src.item(*id) else { continue }; + copy_item(dst, src, it, SpreadRef::Doc(dsi), Vec2::new(hit.dx, hit.dy), &mut maps)?; + } + fill_at(dst, table, idx, options, &maps, data_dir, doc_dir, warnings, missing, &mut parent); + } + Ok(()) +} + +fn fill_at( + doc: &mut Document, + table: &Table, + idx: usize, + options: &MergeOptions, + maps: &Maps, + data_dir: Option<&Path>, + doc_dir: Option<&Path>, + warnings: &mut Vec<String>, + missing: &mut Vec<MissingImage>, + parent: &mut bool, +) { + let Some(row) = table.rows.get(idx) else { return }; + let report = fill_row(doc, &table.fields, row, (idx as u32).saturating_add(1), options, maps, data_dir, doc_dir); + absorb(report, warnings, missing, parent); +} + +fn absorb(report: FillReport, warnings: &mut Vec<String>, missing: &mut Vec<MissingImage>, parent: &mut bool) { + for w in report.warnings { + if w == PARENT_WARNING { + if *parent { + continue; + } + *parent = true; + } + warnings.push(w); + } + missing.extend(report.missing); +} + +fn block_of(doc: &Document) -> (Rect, Vec<ItemId>) { + let Some(page) = doc.page(0) else { + return (Rect::ZERO, Vec::new()); + }; + let pb = page.bounds(); + let mut block: Option<Rect> = None; + let mut ids = Vec::new(); + for sp in &doc.spreads { + for it in &sp.items { + let b = it.bounds(); + if b.x0 < pb.x1 && b.x1 > pb.x0 && b.y0 < pb.y1 && b.y1 > pb.y0 { + ids.push(it.id); + block = Some(match block { + Some(u) => u.union(b), + None => b, + }); + } + } + } + (block.unwrap_or(Rect::ZERO), ids) +} + +fn assign_page(dst: &mut Document, abs: usize, from: &Page) { + let Some((si, pi)) = dst.page_loc(abs) else { return }; + let sp = Arc::make_mut(&mut dst.spreads[si]); + let Some(p) = sp.pages.get_mut(pi) else { return }; + let size_changed = (p.width - from.width).abs() > 0.01 || (p.height - from.height).abs() > 0.01; + p.guides = from.guides.clone(); + p.parent = from.parent; + p.margins = from.margins; + p.columns = from.columns.clone(); + p.width = from.width; + p.height = from.height; + if size_changed { + sp.relayout(); + } +} + +fn overset_count(doc: &Document) -> usize { + doc.stories + .keys() + .filter(|sid| designcraft_compose::compose_story(doc, **sid, &designcraft_compose::ComposeOptions::default()).is_overset()) + .count() +} + +fn add_placeholder(d: &mut Document, sel: &Selection, p: &Value, name: &str) -> Result<Value> { + let (source_id, field) = { + let source = d.data_merge.sources.first().ok_or_else(|| bad("data.placeholder.add", "select a data source"))?; + let field = + source.fields.iter().find(|f| f.name == name).cloned().ok_or_else(|| bad("data.placeholder.add", format!("unknown field \"{name}\"")))?; + (source.id, field) + }; + let role_param = str_param(p, "role"); + let explicit_item = id_param(p, "item"); + let explicit_story = p.get("story").is_some(); + let use_item = if explicit_item.is_some() { + true + } else if explicit_story || role_param == Some("text") { + false + } else if matches!(role_param, Some("image" | "qr")) { + true + } else { + sel.text.is_none() && !sel.items.is_empty() + }; + let role = if let Some(s) = role_param { + PlaceholderRole::parse(s).ok_or_else(|| bad("data.placeholder.add", format!("unknown role \"{s}\"")))? + } else if use_item { + match field.kind { + DataFieldKind::Image => PlaceholderRole::Image, + DataFieldKind::Qr => PlaceholderRole::Qr, + DataFieldKind::Text => return Err(bad("data.placeholder.add", "say which role to use on this frame")), + } + } else { + PlaceholderRole::Text + }; + let anchor = if use_item { + if matches!(role, PlaceholderRole::Text) { + return Err(bad("data.placeholder.add", "text placeholders go in a story")); + } + let item = explicit_item.or_else(|| sel.items.first().copied()).ok_or_else(|| bad("data.placeholder.add", "select text or a frame"))?; + if d.item(item).is_none() { + return Err(bad("data.placeholder.add", format!("no object with id {}", item.0))); + } + if matches!(role, PlaceholderRole::Image | PlaceholderRole::Qr) + && let Some(it) = d.item_mut(item) + { + it.label = format!("<<{name}>>"); + } + PlaceholderAnchor::Item { id: item } + } else { + if matches!(role, PlaceholderRole::Image | PlaceholderRole::Qr) { + return Err(bad("data.placeholder.add", "image and qr placeholders bind to a frame")); + } + let (story, at, end) = text_target(p, sel).ok_or_else(|| bad("data.placeholder.add", "select text or a frame"))?; + if d.story(story).is_none() { + return Err(bad("data.placeholder.add", "no story")); + } + if role == PlaceholderRole::Text { + let marker = format!("<<{name}>>"); + let at = { + let text = d.story(story).map(|st| st.text.clone()).unwrap_or_default(); + designcraft_doc::floor_char_boundary(&text, at.min(text.len())) + }; + if let Some(st) = d.story_mut(story) { + st.replace(at..at, &marker); + } + shift_after(d, story, at, marker.len() as isize); + PlaceholderAnchor::Text { story, start: at, end: at.saturating_add(marker.len()) } + } else { + let text = d.story(story).map(|st| st.text.clone()).unwrap_or_default(); + let start = designcraft_doc::floor_char_boundary(&text, at.min(text.len())); + let end = designcraft_doc::floor_char_boundary(&text, end.min(text.len())).max(start); + PlaceholderAnchor::Text { story, start, end } + } + }; + let id = d.alloc(); + d.data_merge.placeholders.push(Placeholder { id, source_id, field: name.to_string(), role, anchor }); + Ok(json!({"id": id})) +} + +fn text_target(p: &Value, sel: &Selection) -> Option<(StoryId, usize, usize)> { + if let Some(n) = p.get("story").and_then(Value::as_u64) { + let at = p.get("at").and_then(Value::as_u64).unwrap_or(0) as usize; + let end = p.get("end").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(at); + return Some((StoryId(n), at, end.max(at))); + } + sel.text.map(|t| { + let r = t.range(); + (t.story, r.start, r.end) + }) +} + +fn shift_after(doc: &mut Document, story: StoryId, at: usize, delta: isize) { + if delta == 0 { + return; + } + for ph in &mut doc.data_merge.placeholders { + if let PlaceholderAnchor::Text { story: s, start, end } = &mut ph.anchor + && *s == story + && *start >= at + { + *start = fill::add_delta(*start, delta); + *end = fill::add_delta(*end, delta); + } + } + for link in &mut doc.hyperlinks { + if let HyperlinkSource::Text { story: s, start, end } = &mut link.source + && *s == story + && *start >= at + { + *start = fill::add_delta(*start, delta); + *end = fill::add_delta(*end, delta); + } + } +} + +/// After an edit, drop a text placeholder whose marker is gone unless the story still has exactly +/// one copy of it, and drop an item placeholder whose frame is gone. +pub(crate) fn sync_placeholders(doc: &mut Document) { + if doc.data_merge.placeholders.is_empty() { + return; + } + let old = std::mem::take(&mut doc.data_merge.placeholders); + let mut keep = Vec::with_capacity(old.len()); + for mut ph in old { + match ph.anchor.clone() { + PlaceholderAnchor::Item { id } => { + if doc.item(id).is_some() { + keep.push(ph); + } + } + PlaceholderAnchor::Text { story, start, end } => { + let text = doc.story(story).map(|st| st.text.clone()).unwrap_or_default(); + if ph.role != PlaceholderRole::Text { + if start <= end && end <= text.len() { + keep.push(ph); + } + continue; + } + let marker = format!("<<{}>>", ph.field); + if text.get(start..end) == Some(marker.as_str()) { + keep.push(ph); + continue; + } + let found = marker_ranges(&text, &marker); + if found.len() == 1 + && let Some(&(s, e)) = found.first() + { + ph.anchor = PlaceholderAnchor::Text { story, start: s, end: e }; + keep.push(ph); + } + } + } + } + doc.data_merge.placeholders = keep; +} + +fn marker_ranges(text: &str, marker: &str) -> Vec<(usize, usize)> { + let mut out = Vec::new(); + if marker.is_empty() { + return out; + } + let mut i = 0; + while i < text.len() { + if text[i..].starts_with(marker) { + let end = i.saturating_add(marker.len()); + out.push((i, end)); + i = end; + continue; + } + i = i.saturating_add(text[i..].chars().next().map(|c| c.len_utf8()).unwrap_or(1)); + } + out +} + +/// Refresh `relative_path` from the document file's folder. +pub(crate) fn refresh_relative_paths(doc: &mut Document, doc_path: &Path) { + let Some(dir) = doc_path.parent().filter(|p| !p.as_os_str().is_empty()) else { return }; + for src in &mut doc.data_merge.sources { + if let Some(path) = src.path.clone() { + src.relative_path = Some(relative_between(dir, Path::new(&path))); + } + } +} + +/// On open, compare the absolute path with its fingerprint. A missing absolute path is tried +/// again as `relative_path` beside the document. +pub(crate) fn resolve_sources_on_open(doc: &mut Document, doc_path: Option<&Path>) { + for src in &mut doc.data_merge.sources { + let abs_exists = src.path.as_deref().is_some_and(|p| Path::new(p).is_file()); + if abs_exists { + if let Some(path) = src.path.clone() { + src.status = status_of(Path::new(&path), src.fingerprint); + } + continue; + } + if let (Some(doc_path), Some(rel)) = (doc_path, src.relative_path.clone()) + && let Some(dir) = doc_path.parent() + { + let candidate = dir.join(&rel); + if candidate.is_file() { + let saved = candidate.to_string_lossy().into_owned(); + src.fingerprint = fingerprint_of(Path::new(&saved)); + src.status = SourceStatus::Ok; + src.relative_path = Some(relative_between(dir, Path::new(&saved))); + src.path = Some(saved); + continue; + } + } + if src.path.is_some() { + src.status = SourceStatus::Missing; + } + } +} + +fn read_source(p: &Value) -> std::result::Result<(DataSource, Value), String> { + let sheet = sheet_param(p); + let delimiter = delim_param(p)?; + let (bytes, name, path) = take_file(p)?; + let table = parse_file(&bytes, &name, delimiter, sheet.as_deref())?; + let fingerprint = path.as_deref().and_then(|path| fingerprint_of(Path::new(path))); + let report = json!({ + "fields": field_pairs(&table.fields), + "records": table.rows.len(), + "warnings": table.warnings.clone(), + }); + let source = DataSource { + id: 0, + path, + relative_path: None, + name, + delimiter: table.delimiter, + sheet, + fields: table.fields, + rows: table.rows, + fingerprint, + status: SourceStatus::Ok, + warnings: table.warnings, + }; + Ok((source, report)) +} + +fn linked_table(doc: &Document) -> std::result::Result<(Table, Vec<String>, Option<PathBuf>, SourceStatus), String> { + let src = doc.data_merge.sources.first().ok_or_else(|| "select a data source or pass csv or rows".to_string())?; + let mut warnings = Vec::new(); + let status = if let Some(path) = &src.path { + let status = status_of(Path::new(path), src.fingerprint); + match status { + SourceStatus::Modified => warnings.push("The data file changed since it was read. This merge used the cached rows.".into()), + SourceStatus::Missing => warnings.push("The data file is missing. This merge used the cached rows.".into()), + SourceStatus::Ok => {} + } + status + } else { + src.status + }; + let data_dir = src.path.as_deref().and_then(parent_dir); + let table = Table { fields: src.fields.clone(), rows: src.rows.clone(), warnings: src.warnings.clone(), delimiter: src.delimiter }; + Ok((table, warnings, data_dir, status)) +} + +fn set_source_status(doc: &mut Document, status: SourceStatus) -> bool { + let Some(source) = doc.data_merge.sources.first_mut() else { + return false; + }; + if source.status == status { + return false; + } + source.status = status; + true +} + +fn remember_source_status(s: &mut Session, status: SourceStatus) { + let Some(st) = s.active_mut() else { return }; + let mut doc = (*st.doc).clone(); + if !set_source_status(&mut doc, status) { + return; + } + st.doc = Arc::new(doc); + st.revision = st.revision.saturating_add(1); +} + +fn load_inline(p: &Value) -> std::result::Result<(Table, Option<PathBuf>), String> { + if let Some(rows) = p.get("rows") { + let arr = rows.as_array().ok_or_else(|| "rows must be an array".to_string())?; + return Ok((table_from_objects(arr)?, None)); + } + if let Some(csv) = p.get("csv") { + let text = csv.as_str().ok_or_else(|| "csv must be a string".to_string())?; + let name = str_param(p, "name").unwrap_or("data.csv"); + return Ok((table_from_bytes(text.as_bytes(), name, delim_param(p)?)?, None)); + } + let sheet = sheet_param(p); + let delimiter = delim_param(p)?; + let (bytes, name, path) = take_file(p)?; + let dir = path.as_deref().and_then(parent_dir); + Ok((parse_file(&bytes, &name, delimiter, sheet.as_deref())?, dir)) +} + +fn payload_present(p: &Value) -> bool { + p.get("rows").is_some() || p.get("csv").is_some() || p.get("bytes").is_some() || p.get("base64").is_some() || p.get("path").is_some() +} + +fn take_file(p: &Value) -> std::result::Result<(Vec<u8>, String, Option<String>), String> { + if let Some(path) = str_param(p, "path") + && !path.is_empty() + { + let bytes = read_path(path)?; + let name = Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string()); + return Ok((bytes, name, Some(path.to_string()))); + } + let encoded = str_param(p, "bytes").or_else(|| str_param(p, "base64")).ok_or_else(|| "pass path or bytes".to_string())?; + let name = str_param(p, "name").filter(|s| !s.is_empty()).ok_or_else(|| "missing `name`".to_string())?; + Ok((super::file::base64_decode(encoded), name.to_string(), None)) +} + +fn parse_file(bytes: &[u8], name: &str, delimiter: Option<Delimiter>, sheet: Option<&str>) -> std::result::Result<Table, String> { + let ext = extension(name); + if ext == "xls" || ext == "xlsm" { + return Err(format!("{ext} workbooks are not supported")); + } + if ext == "xlsx" { + let grid = designcraft_textimport::xlsx_records(bytes, sheet).map_err(|e| e.to_string())?; + return table_from_grid(grid, Delimiter::Comma); + } + table_from_bytes(bytes, name, delimiter) +} + +fn read_path(path: &str) -> std::result::Result<Vec<u8>, String> { + match std::fs::read(path) { + Ok(b) => Ok(b), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(format!("{path}: the data file is missing")), + Err(e) => Err(format!("{path}: {e}")), + } +} + +fn parent_dir(path: &str) -> Option<PathBuf> { + let parent = Path::new(path).parent()?; + if parent.as_os_str().is_empty() { None } else { Some(parent.to_path_buf()) } +} + +fn delim_param(p: &Value) -> std::result::Result<Option<Delimiter>, String> { + match str_param(p, "delimiter") { + None => Ok(None), + Some(s) => Delimiter::parse(s).map(Some).ok_or_else(|| format!("unknown delimiter \"{s}\"")), + } +} + +fn sheet_param(p: &Value) -> Option<String> { + str_param(p, "sheet").filter(|s| !s.is_empty()).map(str::to_string) +} + +fn field_pairs(fields: &[DataField]) -> Vec<Value> { + fields.iter().map(|f| json!({"name": f.name, "kind": f.kind.as_str()})).collect() +} + +fn fields_json(fields: &[DataField], uses: impl Fn(&str) -> usize) -> Value { + Value::Array(fields.iter().map(|f| json!({"name": f.name, "kind": f.kind.as_str(), "uses": uses(&f.name)})).collect()) +} + +fn apply_options(opt: &mut MergeOptions, p: &Value) -> std::result::Result<(), String> { + if let Some(v) = str_param(p, "records") { + opt.records = v.to_string(); + } + if let Some(n) = p.get("one").and_then(Value::as_u64) { + opt.one = n as u32; + } else if p.get("one").is_some() { + return Err("one must be a record number".into()); + } + if let Some(v) = str_param(p, "range") { + opt.range = v.to_string(); + } + if let Some(v) = str_param(p, "perPage") { + opt.per_page = v.to_string(); + } + if let Some(v) = str_param(p, "arrange") { + opt.arrange = v.to_string(); + } + if let Some(raw) = p.get("insets") { + let arr = raw.as_array().ok_or_else(|| "insets need four numbers".to_string())?; + if arr.len() != 4 { + return Err("insets need four numbers".into()); + } + let mut insets = [0.0; 4]; + for (i, v) in arr.iter().enumerate() { + insets[i] = v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| "insets need four numbers".to_string())?; + } + opt.insets = insets; + } + if let Some(n) = number_param(p, "columnSpacing")? { + opt.column_spacing = n; + } + if let Some(n) = number_param(p, "rowSpacing")? { + opt.row_spacing = n; + } + if let Some(v) = str_param(p, "fitting") { + opt.fitting = v.to_string(); + } + if let Some(v) = p.get("center").and_then(Value::as_bool) { + opt.center = v; + } + if let Some(v) = p.get("linkImages").and_then(Value::as_bool) { + opt.link_images = v; + } + if let Some(v) = p.get("limit") { + if v.is_null() { + opt.limit = None; + } else if let Some(n) = v.as_u64() { + opt.limit = Some(n as u32); + } else { + return Err("limit must be a number".into()); + } + } + Ok(()) +} + +fn number_param(p: &Value, key: &str) -> std::result::Result<Option<f64>, String> { + match p.get(key) { + None => Ok(None), + Some(v) => v.as_f64().filter(|n| n.is_finite()).map(Some).ok_or_else(|| format!("{key} must be a number")), + } +} + +fn validate(opt: &MergeOptions) -> std::result::Result<(), String> { + if !matches!(opt.records.as_str(), "all" | "one" | "range") { + return Err(format!("unknown records value \"{}\"", opt.records)); + } + if !matches!(opt.per_page.as_str(), "single" | "multiple") { + return Err(format!("unknown perPage value \"{}\"", opt.per_page)); + } + if !matches!(opt.arrange.as_str(), "rows" | "columns") { + return Err(format!("unknown arrange value \"{}\"", opt.arrange)); + } + if !matches!(opt.fitting.as_str(), "fitProportionally" | "fillProportionally" | "fitContentToFrame" | "none") { + return Err(format!("unknown fitting \"{}\"", opt.fitting)); + } + if opt.insets.iter().any(|n| !n.is_finite()) || !opt.column_spacing.is_finite() || !opt.row_spacing.is_finite() { + return Err("spacing and insets must be finite numbers".into()); + } + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/cmd/datamerge/parse.rs b/crates/engine/src/cmd/datamerge/parse.rs new file mode 100644 index 000000000..d2f91fe5c --- /dev/null +++ b/crates/engine/src/cmd/datamerge/parse.rs @@ -0,0 +1,547 @@ +//! Pure data-merge parsing: text tables, record selection, image paths, and tile slots. +//! +//! The command layer loads bytes and copies documents. These functions only look at strings, +//! rows, and rectangles, and they refuse a table that is over the row or column cap before +//! that table is returned. + +use std::path::{Path, PathBuf}; + +use designcraft_doc::{DataField, DataFieldKind, Delimiter, Fingerprint, SourceStatus}; +use designcraft_geom::Rect; +use designcraft_textimport::{DATA_MERGE_MAX_COLS, DATA_MERGE_MAX_ROWS}; +use serde_json::Value; + +/// A parsed table. `rows` does not include the header. Every row matches `fields` in length. +#[derive(Clone, Debug, PartialEq)] +pub struct Table { + pub fields: Vec<DataField>, + pub rows: Vec<Vec<String>>, + pub warnings: Vec<String>, + pub delimiter: Delimiter, +} + +#[derive(Clone, Debug)] +struct RawRow { + /// 1-based line where the record starts (the header is line 1 when it is the first record). + line: usize, + cells: Vec<String>, +} + +/// Where one merged record sits. `dx` and `dy` are the translation from the original block. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TileHit { + pub index: usize, + pub page: usize, + pub col: i32, + pub row: i32, + pub dx: f64, + pub dy: f64, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct TilePlan { + pub hits: Vec<TileHit>, + pub pages: usize, + /// A second copy did not fit, so every record has its own page. + pub one_per_page: bool, +} + +#[derive(Clone, Copy, Debug)] +pub struct TileInput { + pub block: Rect, + pub page_width: f64, + pub page_height: f64, + /// Top, right, bottom, left. + pub insets: [f64; 4], + pub column_spacing: f64, + pub row_spacing: f64, + pub rows_first: bool, + pub count: usize, +} + +/// Comma-separated records, for the existing parser test. Errors become an empty table. +#[cfg(test)] +pub fn parse_csv(s: &str) -> Vec<Vec<String>> { + parse_delimited(s, ',').map(|rows| rows.into_iter().map(|r| r.cells).collect()).unwrap_or_default() +} + +/// UTF-8 text (optional BOM) into a table. `delimiter` overrides sniffing. +pub fn table_from_bytes(bytes: &[u8], name: &str, delimiter: Option<Delimiter>) -> Result<Table, String> { + let text = decode_utf8(bytes, name)?; + let delim = match delimiter { + Some(d) => d, + None => sniff(name, &text), + }; + let raw = parse_delimited(&text, delim.char())?; + let mut table = table_from_raw(&raw)?; + table.delimiter = delim; + Ok(table) +} + +/// Grid rows whose first row is the header. Used for Excel and for tests. +pub fn table_from_grid(grid: Vec<Vec<String>>, delimiter: Delimiter) -> Result<Table, String> { + if grid.len() > DATA_MERGE_MAX_ROWS.saturating_add(1) { + return Err(format!("the file has more than {DATA_MERGE_MAX_ROWS} data rows")); + } + let raw = grid.into_iter().enumerate().map(|(i, cells)| RawRow { line: i + 1, cells }).collect::<Vec<_>>(); + let mut table = table_from_raw(&raw)?; + table.delimiter = delimiter; + Ok(table) +} + +/// Inline `rows`: an array of objects. The first object's key order is the field list. +pub fn table_from_objects(rows: &[Value]) -> Result<Table, String> { + if rows.is_empty() { + return Err("no records".into()); + } + if rows.len() > DATA_MERGE_MAX_ROWS { + return Err(format!("the file has more than {DATA_MERGE_MAX_ROWS} data rows")); + } + let first = rows.first().and_then(Value::as_object).ok_or_else(|| "each row must be an object".to_string())?; + let keys: Vec<String> = first.keys().cloned().collect(); + if keys.len() > DATA_MERGE_MAX_COLS { + return Err(format!("the file has {} columns; the limit is {DATA_MERGE_MAX_COLS}", keys.len())); + } + if keys.is_empty() { + return Err("a field name is empty".into()); + } + let mut fields = Vec::with_capacity(keys.len()); + let mut names = Vec::with_capacity(keys.len()); + for key in &keys { + let (name, kind) = classify_header(key)?; + if names.iter().any(|n| n == &name) { + return Err(format!("duplicate field name \"{name}\"")); + } + names.push(name.clone()); + fields.push(DataField { name, kind }); + } + let mut out = Vec::new(); + let mut warnings = Vec::new(); + for (i, row) in rows.iter().enumerate() { + let obj = row.as_object().ok_or_else(|| "each row must be an object".to_string())?; + let mut cells = Vec::with_capacity(keys.len()); + for key in &keys { + cells.push(cell_string(obj.get(key))?); + } + for key in obj.keys() { + if !keys.iter().any(|k| k == key) { + warnings.push(format!("Row {} has an extra field \"{key}\". It was ignored.", i + 1)); + } + } + if cells.iter().all(|c| c.is_empty()) { + continue; + } + out.push(cells); + } + Ok(Table { fields, rows: out, warnings, delimiter: Delimiter::Comma }) +} + +fn cell_string(v: Option<&Value>) -> Result<String, String> { + Ok(match v { + None | Some(Value::Null) => String::new(), + Some(Value::String(s)) => s.clone(), + Some(Value::Number(n)) => n.to_string(), + Some(Value::Bool(true)) => "true".into(), + Some(Value::Bool(false)) => "false".into(), + Some(Value::Array(_) | Value::Object(_)) => { + return Err("a cell must be a string, number, bool, or null".into()); + } + }) +} + +/// 0-based indexes into `count` rows, in first-seen order. +pub fn select_records(count: usize, records: &str, one: u32, range: &str, limit: Option<u32>) -> Result<Vec<usize>, String> { + let mut idx = Vec::new(); + match records { + "all" => idx.extend(0..count), + "one" => { + let i = record_index(one, count)?; + idx.push(i); + } + "range" => { + if range.trim().is_empty() { + return Err("no records".into()); + } + for token in range.split(',') { + let token = token.trim(); + if token.is_empty() { + continue; + } + if let Some((a, b)) = token.split_once('-') { + let a = parse_record_no(a.trim())?; + let b = parse_record_no(b.trim())?; + if a > b { + return Err(format!("range {a}-{b} is reversed")); + } + let mut n = a; + while n <= b { + let i = record_index(n, count)?; + if !idx.contains(&i) { + idx.push(i); + } + n = n.saturating_add(1); + } + } else { + let n = parse_record_no(token)?; + let i = record_index(n, count)?; + if !idx.contains(&i) { + idx.push(i); + } + } + } + } + other => return Err(format!("unknown records value \"{other}\"")), + } + if let Some(limit) = limit { + if limit == 0 { + return Err("limit must be at least 1".into()); + } + idx.truncate(limit as usize); + } + if idx.is_empty() { + return Err("no records".into()); + } + Ok(idx) +} + +fn parse_record_no(s: &str) -> Result<u32, String> { + s.parse::<u32>().map_err(|_| format!("bad record \"{s}\"")) +} + +fn record_index(n: u32, count: usize) -> Result<usize, String> { + if n == 0 || n as usize > count { + return Err(format!("record {n} is out of range")); + } + Ok((n as usize) - 1) +} + +/// Slots for `count` copies of `block` on a page of the given size. +pub fn tile(input: &TileInput) -> TilePlan { + let n = input.count; + if n == 0 { + return TilePlan { hits: Vec::new(), pages: 0, one_per_page: false }; + } + let step_x = input.block.width() + input.column_spacing; + let step_y = input.block.height() + input.row_spacing; + let right = input.page_width - input.insets[1]; + let bottom = input.page_height - input.insets[2]; + let fits_col = |col: i32| step_x > 0.01 && input.block.x1 + f64::from(col) * step_x <= right + 0.01; + let fits_row = |row: i32| step_y > 0.01 && input.block.y1 + f64::from(row) * step_y <= bottom + 0.01; + let second_fits = fits_col(1) || fits_row(1); + let mut hits = Vec::with_capacity(n); + let push = |hits: &mut Vec<TileHit>, index: usize, page: usize, col: i32, row: i32| { + hits.push(TileHit { index, page, col, row, dx: f64::from(col) * step_x, dy: f64::from(row) * step_y }); + }; + if n > 1 && !second_fits { + for i in 0..n { + push(&mut hits, i, i, 0, 0); + } + return TilePlan { hits, pages: n, one_per_page: true }; + } + let mut col = 0i32; + let mut row = 0i32; + let mut page = 0usize; + push(&mut hits, 0, 0, 0, 0); + for i in 1..n { + let (next_col, next_row, new_page) = next_slot(col, row, input.rows_first, &fits_col, &fits_row); + if new_page { + page = page.saturating_add(1); + col = 0; + row = 0; + } else { + col = next_col; + row = next_row; + } + push(&mut hits, i, page, col, row); + } + TilePlan { hits, pages: page.saturating_add(1), one_per_page: false } +} + +fn next_slot(col: i32, row: i32, rows_first: bool, fits_col: &impl Fn(i32) -> bool, fits_row: &impl Fn(i32) -> bool) -> (i32, i32, bool) { + if rows_first { + if fits_col(col.saturating_add(1)) { + return (col.saturating_add(1), row, false); + } + if fits_row(row.saturating_add(1)) { + return (0, row.saturating_add(1), false); + } + } else { + if fits_row(row.saturating_add(1)) { + return (col, row.saturating_add(1), false); + } + if fits_col(col.saturating_add(1)) { + return (col.saturating_add(1), 0, false); + } + } + (0, 0, true) +} + +/// Files to try for an image cell. `http` and `https` are not candidates. Relative paths are +/// not resolved against the process working directory. +pub fn image_candidates(cell: &str, data_dir: Option<&Path>, doc_dir: Option<&Path>) -> Vec<PathBuf> { + let cell = cell.trim(); + if cell.is_empty() { + return Vec::new(); + } + let lower = cell.to_ascii_lowercase(); + if lower.starts_with("http://") || lower.starts_with("https://") { + return Vec::new(); + } + let path = Path::new(cell); + let mut out = Vec::new(); + let mut push = |p: PathBuf| { + if !out.iter().any(|e| e == &p) { + out.push(p); + } + }; + if path.is_absolute() { + push(path.to_path_buf()); + } + if let Some(dir) = data_dir { + push(dir.join(cell)); + } + if let Some(dir) = doc_dir { + push(dir.join(cell)); + } + out +} + +/// `target` relative to `from_dir` (the document's folder). +pub fn relative_between(from_dir: &Path, target: &Path) -> String { + let from: Vec<_> = from_dir.components().collect(); + let to: Vec<_> = target.components().collect(); + let mut i = 0; + while i < from.len() && i < to.len() && from[i] == to[i] { + i += 1; + } + let mut out = PathBuf::new(); + for _ in i..from.len() { + out.push(".."); + } + for c in to.iter().skip(i) { + out.push(c); + } + if out.as_os_str().is_empty() { + target.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default() + } else { + out.to_string_lossy().into_owned() + } +} + +pub fn fingerprint_of(path: &Path) -> Option<Fingerprint> { + let meta = std::fs::metadata(path).ok()?; + let mtime = meta + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)) + .unwrap_or(0); + Some(Fingerprint { size: meta.len(), mtime }) +} + +pub fn status_of(path: &Path, previous: Option<Fingerprint>) -> SourceStatus { + let Some(now) = fingerprint_of(path) else { + return SourceStatus::Missing; + }; + match previous { + Some(old) if old.size != now.size || old.mtime != now.mtime => SourceStatus::Modified, + _ => SourceStatus::Ok, + } +} + +fn decode_utf8(bytes: &[u8], name: &str) -> Result<String, String> { + let bytes = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes); + let mut text = String::from_utf8(bytes.to_vec()).map_err(|_| format!("{name} is not UTF-8"))?; + if text.starts_with('\u{FEFF}') { + text.replace_range(..'\u{FEFF}'.len_utf8(), ""); + } + Ok(text) +} + +fn sniff(name: &str, text: &str) -> Delimiter { + let ext = extension(name); + if ext == "tsv" || ext == "tab" { + return Delimiter::Tab; + } + if ext == "csv" { + return Delimiter::Comma; + } + let header = first_physical_line(text); + if header.contains('\t') { + Delimiter::Tab + } else if header.contains(';') && !header.contains(',') { + Delimiter::Semicolon + } else { + Delimiter::Comma + } +} + +pub fn extension(name: &str) -> String { + Path::new(name).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase() +} + +fn first_physical_line(text: &str) -> &str { + text.split('\n').next().unwrap_or("").trim_end_matches('\r') +} + +/// Header cell: trim, drop one leading apostrophe, then `@` is image and `#` is qr. +fn classify_header(raw: &str) -> Result<(String, DataFieldKind), String> { + let trimmed = raw.trim(); + let rest = trimmed.strip_prefix('\'').unwrap_or(trimmed).trim(); + let (kind, name) = if let Some(n) = rest.strip_prefix('@') { + (DataFieldKind::Image, n.trim()) + } else if let Some(n) = rest.strip_prefix('#') { + (DataFieldKind::Qr, n.trim()) + } else { + (DataFieldKind::Text, rest) + }; + if name.is_empty() { + return Err("a field name is empty".into()); + } + Ok((name.to_string(), kind)) +} + +fn table_from_raw(raw: &[RawRow]) -> Result<Table, String> { + let Some(header) = raw.first() else { + return Err("the file has no header".into()); + }; + if header.cells.len() > DATA_MERGE_MAX_COLS { + return Err(format!("the file has {} columns; the limit is {DATA_MERGE_MAX_COLS}", header.cells.len())); + } + if header.cells.is_empty() { + return Err("a field name is empty".into()); + } + let mut fields = Vec::with_capacity(header.cells.len()); + let mut names = Vec::with_capacity(header.cells.len()); + for cell in &header.cells { + let (name, kind) = classify_header(cell)?; + if names.iter().any(|n| n == &name) { + return Err(format!("duplicate field name \"{name}\"")); + } + names.push(name.clone()); + fields.push(DataField { name, kind }); + } + let width = fields.len(); + let mut rows = Vec::new(); + let mut warnings = Vec::new(); + for raw_row in raw.iter().skip(1) { + if raw_row.cells.iter().all(|c| c.trim().is_empty()) { + continue; + } + if raw_row.cells.len() > width { + warnings.push(format!("Row {} has cells past the header. Those cells were ignored.", raw_row.line)); + } + let mut cells = Vec::with_capacity(width); + for i in 0..width { + cells.push(raw_row.cells.get(i).cloned().unwrap_or_default()); + } + rows.push(cells); + if rows.len() > DATA_MERGE_MAX_ROWS { + return Err(format!("the file has more than {DATA_MERGE_MAX_ROWS} data rows")); + } + } + Ok(Table { fields, rows, warnings, delimiter: Delimiter::Comma }) +} + +/// Split `s` on `delim`. Quoted fields keep commas, doubled quotes, and newlines. +/// More than 200 header columns, or the 100,001st non-empty data row, is an error and +/// the oversized row is not kept. +fn parse_delimited(s: &str, delim: char) -> Result<Vec<RawRow>, String> { + let mut rows: Vec<RawRow> = Vec::new(); + let mut row: Vec<String> = Vec::new(); + let mut field = String::new(); + let mut quoted = false; + let mut line = 1usize; + let mut row_line = 1usize; + let mut data_rows = 0usize; + let mut header_width: Option<usize> = None; + let mut discarding = false; + let mut chars = s.chars().peekable(); + + let finish = |row: &mut Vec<String>, + field: &mut String, + rows: &mut Vec<RawRow>, + data_rows: &mut usize, + header_width: &mut Option<usize>, + row_line: usize, + discarding: &mut bool| + -> Result<(), String> { + if !*discarding { + row.push(std::mem::take(field)); + } else { + field.clear(); + } + *discarding = false; + let empty = row.iter().all(|c| c.trim().is_empty()); + if empty { + row.clear(); + return Ok(()); + } + match *header_width { + None => { + if row.len() > DATA_MERGE_MAX_COLS { + return Err(format!("the file has {} columns; the limit is {DATA_MERGE_MAX_COLS}", row.len())); + } + *header_width = Some(row.len()); + rows.push(RawRow { line: row_line, cells: std::mem::take(row) }); + } + Some(width) => { + *data_rows = data_rows.saturating_add(1); + if *data_rows > DATA_MERGE_MAX_ROWS { + return Err(format!("the file has more than {DATA_MERGE_MAX_ROWS} data rows")); + } + if row.len() > width.saturating_add(1) { + row.truncate(width.saturating_add(1)); + } + rows.push(RawRow { line: row_line, cells: std::mem::take(row) }); + } + } + Ok(()) + }; + + while let Some(c) = chars.next() { + if c == '\n' { + line = line.saturating_add(1); + } + if quoted { + match c { + '"' if chars.peek() == Some(&'"') => { + field.push('"'); + chars.next(); + } + '"' => quoted = false, + _ => field.push(c), + } + continue; + } + if c == delim { + if discarding { + field.clear(); + continue; + } + row.push(std::mem::take(&mut field)); + if header_width.is_none() && row.len() > DATA_MERGE_MAX_COLS { + return Err(format!("the file has {} columns; the limit is {DATA_MERGE_MAX_COLS}", row.len())); + } + if header_width.is_some_and(|w| row.len() > w.saturating_add(1)) { + row.pop(); + discarding = true; + } + continue; + } + match c { + '"' if field.is_empty() && !discarding => quoted = true, + '\r' => {} + '\n' => { + finish(&mut row, &mut field, &mut rows, &mut data_rows, &mut header_width, row_line, &mut discarding)?; + row_line = line; + } + _ if discarding => {} + _ => field.push(c), + } + } + if !field.is_empty() || !row.is_empty() || discarding { + finish(&mut row, &mut field, &mut rows, &mut data_rows, &mut header_width, row_line, &mut discarding)?; + } + Ok(rows) +} diff --git a/crates/engine/src/cmd/datamerge/tests.rs b/crates/engine/src/cmd/datamerge/tests.rs new file mode 100644 index 000000000..f7fc09846 --- /dev/null +++ b/crates/engine/src/cmd/datamerge/tests.rs @@ -0,0 +1,787 @@ +//! Engine tests for data merge. They drive the commands, not a private copy of the parser. + +use std::path::{Path, PathBuf}; + +use designcraft_doc::{Content, DataField, DataFieldKind, DataSource, Delimiter, HyperlinkDest, SourceStatus}; +use serde_json::{Value, json}; + +use super::parse::{self, parse_csv}; +use super::{relative_between, resolve_sources_on_open}; +use crate::Session; + +fn scratch(name: &str) -> PathBuf { + let p = std::env::temp_dir().join(format!("dc-merge-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&p); + std::fs::create_dir_all(&p).unwrap(); + p +} + +fn write(path: &Path, body: &str) { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).unwrap(); + } + std::fs::write(path, body).unwrap(); +} + +const PNG_B64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + +fn write_png(path: &Path) { + let bytes = crate::cmd::base64_decode(PNG_B64); + assert!(designcraft_render::image_size(&bytes).is_some(), "the fixture png must decode"); + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).unwrap(); + } + std::fs::write(path, bytes).unwrap(); +} + +fn exec(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}")) +} + +fn err(s: &mut Session, id: &str, p: Value) -> String { + s.execute(id, &p).unwrap_err().to_string() +} + +fn texts(s: &Session) -> Vec<String> { + let mut v: Vec<String> = s.documents()[s.active_index().unwrap()].doc.stories.values().map(|st| st.text.clone()).collect(); + v.sort(); + v +} + +fn origins(s: &Session) -> Vec<(i32, i32)> { + let doc = &s.documents()[s.active_index().unwrap()].doc; + let mut v = Vec::new(); + for sp in &doc.spreads { + for it in &sp.items { + let b = it.bounds(); + v.push((b.y0.round() as i32, b.x0.round() as i32)); + } + } + v.sort(); + v +} + +fn new_doc(s: &mut Session, pages: u32) { + exec(s, "file.new", json!({"facingPages": false, "pages": pages})); +} + +fn text_frame(s: &mut Session, rect: [f64; 4], text: &str, spread: Option<u32>) -> Value { + let mut p = json!({"rect": rect, "content": "text", "text": text}); + if let Some(spread) = spread { + p["spread"] = json!(spread); + } + exec(s, "frame.create", p) +} + +fn select_csv(s: &mut Session, body: &str) -> Value { + exec(s, "data.source.select", json!({"bytes": crate::cmd::base64_encode(body.as_bytes()), "name": "people.csv"})) +} + +#[test] +fn csv_parsing() { + let r = parse_csv("Name,Title\n\"Ada, Countess\",\"Analyst \"\"No. 1\"\"\"\r\nGrace,Admiral\n"); + assert_eq!(r, vec![vec!["Name", "Title"], vec!["Ada, Countess", "Analyst \"No. 1\""], vec!["Grace", "Admiral"]]); + let quoted = parse_csv("Name\n\"Ada\nGrace\"\n"); + assert_eq!(quoted[1][0], "Ada\nGrace"); +} + +#[test] +fn text_sniff_padding_skips_and_warnings() { + let dir = scratch("sniff"); + write(&dir.join("tab.txt"), "Name\tCity\nAda\tTown\n"); + write(&dir.join("semi.txt"), "Name;City\nAda;Town\n"); + write(&dir.join("comma.txt"), "Name,City\nAda,Town\n"); + write(&dir.join("people.tsv"), "Name\tCity\nAda\tTown\n"); + write(&dir.join("forced.csv"), "Name;City\nAda;Town\n"); + let mut s = Session::new(); + new_doc(&mut s, 1); + let row = |s: &Session| s.documents()[s.active_index().unwrap()].doc.data_merge.sources[0].rows[0].clone(); + exec(&mut s, "data.source.select", json!({"path": dir.join("tab.txt")})); + assert_eq!(row(&s), vec!["Ada", "Town"]); + exec(&mut s, "data.source.select", json!({"path": dir.join("semi.txt")})); + assert_eq!(row(&s), vec!["Ada", "Town"]); + exec(&mut s, "data.source.select", json!({"path": dir.join("comma.txt")})); + assert_eq!(row(&s), vec!["Ada", "Town"]); + exec(&mut s, "data.source.select", json!({"path": dir.join("people.tsv")})); + assert_eq!(row(&s), vec!["Ada", "Town"]); + exec(&mut s, "data.source.select", json!({"path": dir.join("forced.csv"), "delimiter": "semicolon"})); + assert_eq!(row(&s), vec!["Ada", "Town"]); + + let short = select_csv(&mut s, "Name,City\nAda\n\nGrace,Paris,extra\n"); + assert!(short["warnings"][0].as_str().unwrap().contains("Row 4"), "{}", short["warnings"]); + let src = &s.documents()[0].doc.data_merge.sources[0]; + assert_eq!(src.rows, vec![vec!["Ada", ""], vec!["Grace", "Paris"]]); + assert!(src.warnings[0].contains("Row 4")); +} + +#[test] +fn kind_marks_and_hard_errors_do_not_attach() { + let mut s = Session::new(); + new_doc(&mut s, 1); + let marked = select_csv(&mut s, "@Photo,#Code,Name\npic.png,abc,Ada\n"); + assert_eq!( + marked["fields"], + json!([ + {"name": "Photo", "kind": "image"}, + {"name": "Code", "kind": "qr"}, + {"name": "Name", "kind": "text"} + ]) + ); + assert_eq!(marked["records"], 1); + + let bad = scratch("bad").join("bad.csv"); + std::fs::write(&bad, [0xFFu8, 0xFE, 0x00]).unwrap(); + let msg = err(&mut s, "data.source.select", json!({"path": bad})); + assert!(msg.contains("not UTF-8"), "{msg}"); + assert_eq!(s.documents()[0].doc.data_merge.sources[0].fields[0].name, "Photo"); + + assert!(err(&mut s, "data.source.select", json!({"bytes": crate::cmd::base64_encode(b",City\nAda,Town\n"), "name": "e.csv"})).contains("empty")); + assert!( + err(&mut s, "data.source.select", json!({"bytes": crate::cmd::base64_encode(b"@Name,Name\na,b\n"), "name": "e.csv"})).contains("duplicate") + ); + assert_eq!(s.documents()[0].doc.data_merge.sources.len(), 1); + + let mut wide = String::from("A"); + for i in 0..200 { + wide.push(','); + wide.push_str(&format!("C{i}")); + } + wide.push('\n'); + assert!(err(&mut s, "data.source.select", json!({"bytes": crate::cmd::base64_encode(wide.as_bytes()), "name": "wide.csv"})).contains("200")); + + let mut rows = String::from("Name\n"); + for _ in 0..100_001 { + rows.push_str("a\n"); + } + assert!(err(&mut s, "data.source.select", json!({"bytes": crate::cmd::base64_encode(rows.as_bytes()), "name": "big.csv"})).contains("100000")); + assert_eq!(s.documents()[0].doc.data_merge.sources[0].rows.len(), 1, "a failed select keeps the previous source"); + + assert!(err(&mut s, "data.source.select", json!({"bytes": crate::cmd::base64_encode(b"Name\nAda\n"), "name": "book.xls"})).contains("xls")); + assert!(err(&mut s, "data.source.update", json!({})).contains("no file") || err(&mut s, "data.source.update", json!({})).contains("no file")); +} + +fn xlsx(parts: &[(&str, &str)]) -> String { + let mut buf = std::io::Cursor::new(Vec::new()); + { + let mut z = zip::ZipWriter::new(&mut buf); + let o = zip::write::SimpleFileOptions::default(); + for (name, body) in parts { + z.start_file(*name, o).unwrap(); + std::io::Write::write_all(&mut z, body.as_bytes()).unwrap(); + } + z.finish().unwrap(); + } + crate::cmd::base64_encode(&buf.into_inner()) +} + +#[test] +fn excel_first_sheet_named_sheet_and_cached_values() { + let first = xlsx(&[ + ( + "xl/workbook.xml", + r#"<workbook xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="Prices" sheetId="1" r:id="rId1"/></sheets></workbook>"#, + ), + ("xl/_rels/workbook.xml.rels", r#"<Relationships><Relationship Id="rId1" Type="x" Target="worksheets/sheet1.xml"/></Relationships>"#), + ("xl/sharedStrings.xml", r#"<sst><si><t>Item</t></si><si><t>Price</t></si><si><t>Tea</t></si></sst>"#), + ( + "xl/worksheets/sheet1.xml", + r#"<worksheet><sheetData><row r="2"><c r="B2" t="s"><v>0</v></c><c r="C2" t="s"><v>1</v></c></row><row r="3"><c r="B3" t="s"><v>2</v></c><c r="C3"><v>3.5</v></c></row><row r="4"><c r="B4" t="inlineStr"><is><t>Cake</t></is></c><c r="C4"><v>12.0</v></c></row></sheetData></worksheet>"#, + ), + ]); + let mut s = Session::new(); + new_doc(&mut s, 1); + exec(&mut s, "data.source.select", json!({"bytes": first, "name": "prices.xlsx"})); + let src = &s.documents()[0].doc.data_merge.sources[0]; + assert_eq!(src.fields.iter().map(|f| f.name.as_str()).collect::<Vec<_>>(), vec!["Item", "Price"]); + assert_eq!(src.rows[0], vec!["Tea", "3.5"]); + assert_eq!(src.rows[1][1], "12"); + + let book = xlsx(&[ + ( + "xl/workbook.xml", + r#"<workbook xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="Ignore" sheetId="1" r:id="rId1"/><sheet name="People" sheetId="2" r:id="rId2"/></sheets></workbook>"#, + ), + ( + "xl/_rels/workbook.xml.rels", + r#"<Relationships><Relationship Id="rId1" Type="x" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="x" Target="worksheets/sheet2.xml"/></Relationships>"#, + ), + ( + "xl/worksheets/sheet1.xml", + r#"<worksheet><sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>Nope</t></is></c></row></sheetData></worksheet>"#, + ), + ( + "xl/worksheets/sheet2.xml", + r#"<worksheet><sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>Name</t></is></c><c r="B1" t="inlineStr"><is><t>Qty</t></is></c><c r="C1" t="inlineStr"><is><t>On</t></is></c><c r="D1" t="inlineStr"><is><t>When</t></is></c></row><row r="2"><c r="A2" t="inlineStr"><is><t>Ada</t></is></c><c r="B2"><v>12.0</v></c><c r="C2" t="b"><v>1</v></c><c r="D2"><v>44927</v></c></row><row r="3"><c r="A3" t="inlineStr"><is><t>Grace</t></is></c><c r="C3"><f>1+1</f></c></row></sheetData></worksheet>"#, + ), + ]); + exec(&mut s, "data.source.select", json!({"bytes": book, "name": "people.xlsx", "sheet": "People"})); + let src = &s.documents()[0].doc.data_merge.sources[0]; + assert_eq!(src.rows[0], vec!["Ada", "12", "TRUE", "44927"]); + assert_eq!(src.rows[1], vec!["Grace", "", "", ""]); + let msg = err(&mut s, "data.source.select", json!({"bytes": book, "name": "people.xlsx", "sheet": "Missing"})); + assert!(msg.contains("Missing"), "{msg}"); + assert_eq!(s.documents()[0].doc.data_merge.sources[0].rows[0][0], "Ada"); +} + +#[test] +fn typed_fields_keep_style_and_unknown_markers() { + let mut s = Session::new(); + new_doc(&mut s, 1); + select_csv(&mut s, "Title,Name\nAdmiral,Ada\n"); + let made = text_frame(&mut s, [36.0, 36.0, 400.0, 80.0], "<<Title>> and <<Nope>>", None); + exec(&mut s, "text.select", json!({"story": made["story"], "anchor": 0, "focus": 0})); + exec(&mut s, "data.placeholder.add", json!({"field": "Name"})); + let story = s.documents()[0].doc.stories.values().next().unwrap(); + let marker = story.text.find("<<Name>>").unwrap(); + exec(&mut s, "text.select", json!({"story": made["story"], "anchor": marker, "focus": marker + "<<Name>>".len()})); + exec(&mut s, "type.char", json!({"size": 21})); + exec(&mut s, "data.merge", json!({})); + let doc = &s.documents()[s.active_index().unwrap()].doc; + let story = doc.stories.values().next().unwrap(); + assert_eq!(story.text, "AdaAdmiral and <<Nope>>"); + let at = story.text.find("Ada").unwrap(); + assert_eq!(story.format_after(at).over.size, Some(21.0)); +} + +#[test] +fn images_resolve_in_order_and_roles_win() { + let dir = scratch("images"); + let data = dir.join("data"); + let docs = dir.join("docs"); + std::fs::create_dir_all(&data).unwrap(); + std::fs::create_dir_all(&docs).unwrap(); + let abs = dir.join("abs.png"); + let beside_data = data.join("beside.png"); + let beside_doc = docs.join("docpic.png"); + write_png(&abs); + write_png(&beside_data); + write_png(&beside_doc); + write( + &data.join("people.csv"), + &format!("@Photo,Web,Note\n{},https://example.com/a.png,kept\nmissing.png,https://example.com/b.png,kept\n", abs.display()), + ); + + let mut s = Session::new(); + new_doc(&mut s, 1); + exec(&mut s, "data.source.select", json!({"path": data.join("people.csv")})); + let photo = exec(&mut s, "frame.create", json!({"rect": [36.0, 36.0, 80.0, 80.0], "content": "graphic"})); + exec(&mut s, "data.placeholder.add", json!({"field": "Photo", "item": photo["id"]})); + let web = exec(&mut s, "frame.create", json!({"rect": [100.0, 36.0, 140.0, 80.0], "content": "graphic"})); + exec(&mut s, "data.placeholder.add", json!({"field": "Web", "role": "image", "item": web["id"]})); + let r = exec(&mut s, "data.merge", json!({})); + assert_eq!(r["records"], 2); + assert_eq!(r["missingImages"].as_array().unwrap().len(), 3, "second photo, and both https cells"); + let doc = &s.documents()[s.active_index().unwrap()].doc; + let mut linked = 0; + let mut empty = 0; + for sp in &doc.spreads { + for it in &sp.items { + match &it.content { + Content::Graphic(g) => { + let asset = doc.assets.get(&g.asset).unwrap(); + assert!(asset.link.as_ref().is_some_and(|p| p.contains("abs.png") || p.contains("beside.png") || p.contains("docpic.png"))); + assert!(!asset.data.is_empty()); + linked += 1; + } + Content::Unassigned => empty += 1, + _ => {} + } + } + } + assert_eq!(linked, 1); + assert!(empty >= 3); + + // Beside the data file, then beside the saved document. + let mut s = Session::new(); + new_doc(&mut s, 1); + write(&data.join("side.csv"), "Photo\nbeside.png\n"); + exec(&mut s, "data.source.select", json!({"path": data.join("side.csv")})); + let frame = exec(&mut s, "frame.create", json!({"rect": [0.0, 0.0, 200.0, 100.0], "content": "graphic"})); + exec(&mut s, "data.placeholder.add", json!({"field": "Photo", "role": "image", "item": frame["id"]})); + exec(&mut s, "data.merge", json!({})); + assert!(graphic_link(&s).is_some_and(|p| p.contains("beside.png"))); + + let mut s = Session::new(); + new_doc(&mut s, 1); + write(&data.join("doc.csv"), "Photo\ndocpic.png\n"); + exec(&mut s, "data.source.select", json!({"path": data.join("doc.csv")})); + exec(&mut s, "file.save", json!({"path": docs.join("layout.designcraft")})); + let frame = exec(&mut s, "frame.create", json!({"rect": [0.0, 0.0, 200.0, 100.0], "content": "graphic"})); + exec(&mut s, "data.placeholder.add", json!({"field": "Photo", "role": "image", "item": frame["id"]})); + exec(&mut s, "data.options", json!({"center": false, "linkImages": true, "fitting": "fitProportionally"})); + exec(&mut s, "data.merge", json!({})); + assert!(graphic_link(&s).is_some_and(|p| p.contains("docpic.png"))); + let (frame_box, placed) = graphic_boxes(&s); + assert!((placed.x0 - frame_box.x0).abs() < 1.0, "center off keeps the fit at the top left"); + + exec(&mut s, "file.activate", json!({"index": 0})); + exec(&mut s, "data.options", json!({"center": true, "linkImages": false})); + exec(&mut s, "data.merge", json!({})); + let doc = &s.documents()[s.active_index().unwrap()].doc; + let asset_link_none = doc.assets.values().any(|a| a.link.is_none() && !a.data.is_empty()); + assert!(asset_link_none); + let (frame_box, placed) = graphic_boxes(&s); + assert!((placed.center().x - frame_box.center().x).abs() < 1.0); + assert!(placed.x0 > frame_box.x0 + 1.0); +} + +fn graphic_link(s: &Session) -> Option<String> { + let doc = &s.documents()[s.active_index().unwrap()].doc; + for sp in &doc.spreads { + for it in &sp.items { + if let Content::Graphic(g) = &it.content { + return doc.assets.get(&g.asset).and_then(|a| a.link.clone()); + } + } + } + None +} + +fn graphic_boxes(s: &Session) -> (designcraft_geom::Rect, designcraft_geom::Rect) { + let doc = &s.documents()[s.active_index().unwrap()].doc; + for sp in &doc.spreads { + for it in &sp.items { + if let Content::Graphic(g) = &it.content { + let bb = g.xf.transform_rect_bbox(designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1)); + return (it.inner_bounds(), bb); + } + } + } + panic!("no graphic"); +} + +#[test] +fn role_overrides_column_kind_qr_and_hyperlink() { + let dir = scratch("roles"); + let pic = dir.join("pic.png"); + write_png(&pic); + let mut s = Session::new(); + new_doc(&mut s, 1); + select_csv(&mut s, &format!("Name,@Photo\n{},pic.png\n", pic.display())); + let frame = exec(&mut s, "frame.create", json!({"rect": [10.0, 10.0, 80.0, 80.0], "content": "graphic"})); + exec(&mut s, "data.placeholder.add", json!({"field": "Name", "role": "image", "item": frame["id"]})); + exec(&mut s, "data.merge", json!({})); + assert!(graphic_link(&s).is_some_and(|p| p.contains("pic.png"))); + + let mut s = Session::new(); + new_doc(&mut s, 1); + select_csv(&mut s, "@Photo\npic.png\n"); + text_frame(&mut s, [10.0, 10.0, 300.0, 40.0], "", None); + exec(&mut s, "data.placeholder.add", json!({"field": "Photo", "role": "text"})); + exec(&mut s, "data.merge", json!({})); + assert_eq!(texts(&s), vec!["pic.png".to_string()]); + + let mut s = Session::new(); + new_doc(&mut s, 1); + select_csv(&mut s, "Name,#Code\nAda,hello\nGrace,\n"); + let frame = exec(&mut s, "frame.create", json!({"rect": [10.0, 10.0, 80.0, 80.0], "content": "graphic"})); + exec(&mut s, "data.placeholder.add", json!({"field": "Code", "item": frame["id"]})); + let r = exec(&mut s, "data.merge", json!({})); + assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap_or("").contains("QR cell is empty"))); + let doc = &s.documents()[s.active_index().unwrap()].doc; + let mut alts = Vec::new(); + for sp in &doc.spreads { + for it in &sp.items { + if let Content::Group { items } = &it.content { + alts.extend(items.iter().map(|c| c.alt_text.clone())); + } + } + } + assert_eq!(alts, vec!["QR code: hello".to_string()]); + + let mut s = Session::new(); + new_doc(&mut s, 1); + select_csv(&mut s, "Name,Link\nAda,a@b.c\nGrace,https://example.com\nKai,\n"); + let made = text_frame(&mut s, [10.0, 10.0, 200.0, 40.0], "Go", None); + exec(&mut s, "text.select", json!({"story": made["story"], "anchor": 0, "focus": 2})); + exec(&mut s, "data.placeholder.add", json!({"field": "Link", "role": "hyperlink"})); + exec(&mut s, "data.merge", json!({})); + let doc = &s.documents()[s.active_index().unwrap()].doc; + let mut kinds = Vec::new(); + for link in &doc.hyperlinks { + kinds.push(match &link.dest { + HyperlinkDest::Email(v) => format!("email:{v}"), + HyperlinkDest::Url(v) => format!("url:{v}"), + HyperlinkDest::Page(n) => format!("page:{n}"), + }); + } + kinds.sort(); + assert_eq!(kinds, vec!["email:a@b.c".to_string(), "url:https://example.com".to_string()]); +} + +#[test] +fn single_record_merge_copies_pages_and_leaves_the_template() { + let mut s = Session::new(); + new_doc(&mut s, 2); + text_frame(&mut s, [36.0, 36.0, 300.0, 80.0], "<<Name>>", None); + text_frame(&mut s, [36.0, 36.0, 300.0, 80.0], "Page <<Name>>", Some(1)); + select_csv(&mut s, "Name\nAda\nGrace\nKatherine\n"); + exec(&mut s, "guide.add", json!({"orientation": "vertical", "position": 100.0})); + exec(&mut s, "guide.add", json!({"spread": 1, "orientation": "vertical", "position": 180.0})); + let undo = s.documents()[0].history.undo.len(); + let template_pages = s.documents()[0].doc.page_count(); + let r = exec(&mut s, "data.merge", json!({})); + let pages = r["pages"].as_u64().unwrap(); + println!( + "GATING 6 pages for 2 template pages x 3 records: result {pages} document {}", + s.documents()[s.active_index().unwrap()].doc.page_count() + ); + assert_eq!(pages, 6); + assert_eq!(r["records"], 3); + assert_eq!(s.documents().len(), 2); + assert_eq!(s.active_index(), Some(1)); + assert_eq!(s.documents()[0].doc.page_count(), template_pages); + assert_eq!(s.documents()[0].history.undo.len(), undo); + assert!(texts(&s).iter().any(|t| t == "Ada") && texts(&s).iter().any(|t| t == "Page Katherine")); + let doc = &s.documents()[1].doc; + let marks: Vec<Vec<i32>> = doc + .pages() + .map(|(_, _, p)| { + let mut g: Vec<i32> = p.guides.iter().map(|g| g.position.round() as i32).collect(); + g.sort(); + g + }) + .collect(); + assert_eq!(marks, vec![vec![100], vec![180], vec![100], vec![180], vec![100], vec![180]]); + assert!(s.documents()[0].doc.stories.values().any(|st| st.text.contains("<<Name>>"))); +} + +#[test] +fn tiling_rows_first_bottom_inset_and_refusals() { + let mut s = Session::new(); + new_doc(&mut s, 1); + text_frame(&mut s, [72.0, 72.0, 172.0, 172.0], "<<Name>>", None); + select_csv(&mut s, "Name\nA\nB\nC\nD\nE\nF\n"); + exec( + &mut s, + "data.options", + json!({"perPage": "multiple", "arrange": "rows", "insets": [36.0, 36.0, 36.0, 36.0], "columnSpacing": 36.0, "rowSpacing": 36.0}), + ); + let r = exec(&mut s, "data.merge", json!({})); + let placed = origins(&s); + println!("GATING 3-across then next-row tile: pages {} origins {placed:?}", r["pages"]); + assert_eq!(r["pages"], 1); + assert_eq!(r["records"], 6); + assert_eq!(placed, vec![(72, 72), (72, 208), (72, 344), (208, 72), (208, 208), (208, 344)]); + + exec(&mut s, "file.activate", json!({"index": 0})); + exec(&mut s, "data.options", json!({"arrange": "columns"})); + exec(&mut s, "data.merge", json!({})); + assert_eq!(origins(&s), vec![(72, 72), (72, 208), (208, 72), (344, 72), (480, 72), (616, 72)]); + + let mut s = Session::new(); + new_doc(&mut s, 1); + text_frame(&mut s, [72.0, 700.0, 172.0, 780.0], "<<Name>>", None); + select_csv(&mut s, "Name\nA\nB\nC\nD\nE\nF\n"); + exec( + &mut s, + "data.options", + json!({"perPage": "multiple", "arrange": "rows", "insets": [36.0, 36.0, 36.0, 36.0], "columnSpacing": 36.0, "rowSpacing": 36.0}), + ); + let r = exec(&mut s, "data.merge", json!({})); + assert_eq!(r["pages"], 2); + assert_eq!(s.documents()[1].doc.spreads.iter().map(|sp| sp.items.len()).collect::<Vec<_>>(), vec![3, 3]); + + let mut s = Session::new(); + new_doc(&mut s, 1); + text_frame(&mut s, [36.0, 36.0, 500.0, 700.0], "<<Name>>", None); + select_csv(&mut s, "Name\nA\nB\nC\nD\n"); + exec(&mut s, "data.options", json!({"perPage": "multiple", "columnSpacing": 0.0, "rowSpacing": 0.0})); + let r = exec(&mut s, "data.merge", json!({})); + assert_eq!(r["pages"], 4); + assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap_or("").contains("its own page"))); + + let mut s = Session::new(); + exec(&mut s, "file.new", json!({})); + text_frame(&mut s, [72.0, 72.0, 172.0, 172.0], "<<Name>>", None); + select_csv(&mut s, "Name\nA\nB\n"); + exec(&mut s, "data.options", json!({"perPage": "multiple"})); + let before = s.documents().len(); + let msg = err(&mut s, "data.merge", json!({})); + let after = s.documents().len(); + println!("GATING tiling refusal added no document (before {before}, after {after})"); + assert!(msg.contains("facing pages"), "{msg}"); + assert_eq!(before, after); + + let mut s = Session::new(); + new_doc(&mut s, 2); + text_frame(&mut s, [72.0, 72.0, 172.0, 172.0], "<<Name>>", None); + select_csv(&mut s, "Name\nA\nB\n"); + exec(&mut s, "data.options", json!({"perPage": "multiple"})); + let before = s.documents().len(); + let msg = err(&mut s, "data.merge", json!({})); + assert!(msg.contains("one page"), "{msg}"); + assert_eq!(s.documents().len(), before); +} + +#[test] +fn update_remove_preview_inline_rows_and_old_files() { + let dir = scratch("update"); + let csv = dir.join("people.csv"); + write(&csv, "Name\nAda\nGrace\n"); + let mut s = Session::new(); + new_doc(&mut s, 1); + text_frame(&mut s, [36.0, 36.0, 240.0, 80.0], "", None); + exec(&mut s, "data.source.select", json!({"path": csv})); + let id = exec(&mut s, "data.placeholder.add", json!({"field": "Name"}))["id"].clone(); + let undo = s.documents()[0].history.undo.len(); + let fingerprint = s.documents()[0].doc.data_merge.sources[0].fingerprint; + let mut body = std::fs::read(&csv).unwrap(); + body.extend_from_slice(b"Kai\n"); + std::fs::write(&csv, &body).unwrap(); + let r = exec(&mut s, "data.merge", json!({})); + assert_eq!(r["records"], 2); + assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap_or("").contains("changed"))); + assert_eq!(texts(&s), vec!["Ada".to_string(), "Grace".to_string()]); + assert_eq!(s.documents()[0].doc.data_merge.sources[0].status, SourceStatus::Modified); + assert_eq!(s.documents()[0].doc.data_merge.sources[0].fingerprint, fingerprint); + assert_eq!(s.documents()[0].history.undo.len(), undo); + assert_eq!(s.documents()[s.active_index().unwrap()].doc.data_merge.sources[0].status, SourceStatus::Modified); + + let gone = dir.join("gone.csv"); + write(&gone, "Name\nAda\nGrace\n"); + let mut missing = Session::new(); + new_doc(&mut missing, 1); + text_frame(&mut missing, [36.0, 36.0, 240.0, 80.0], "", None); + exec(&mut missing, "data.source.select", json!({"path": &gone})); + exec(&mut missing, "data.placeholder.add", json!({"field": "Name"})); + std::fs::remove_file(&gone).unwrap(); + let missing_undo = missing.documents()[0].history.undo.len(); + let r = exec(&mut missing, "data.merge", json!({})); + assert_eq!(r["records"], 2); + assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap_or("").contains("missing"))); + assert_eq!(texts(&missing), vec!["Ada".to_string(), "Grace".to_string()]); + assert_eq!(missing.documents()[0].doc.data_merge.sources[0].status, SourceStatus::Missing); + assert_eq!(missing.documents()[0].history.undo.len(), missing_undo); + + let refuse = dir.join("refuse.csv"); + write(&refuse, "Name\nAda\nGrace\n"); + let mut refused = Session::new(); + exec(&mut refused, "file.new", json!({})); + text_frame(&mut refused, [36.0, 36.0, 240.0, 80.0], "", None); + exec(&mut refused, "data.source.select", json!({"path": &refuse})); + exec(&mut refused, "data.placeholder.add", json!({"field": "Name"})); + let mut body = std::fs::read(&refuse).unwrap(); + body.extend_from_slice(b"Kai\n"); + std::fs::write(&refuse, &body).unwrap(); + let refuse_undo = refused.documents()[0].history.undo.len(); + let msg = err(&mut refused, "data.merge", json!({"perPage": "multiple"})); + assert!(msg.contains("facing pages"), "{msg}"); + assert_eq!(refused.documents().len(), 1); + assert_eq!(refused.documents()[0].doc.data_merge.sources[0].status, SourceStatus::Ok); + assert_eq!(refused.documents()[0].history.undo.len(), refuse_undo); + + exec(&mut s, "file.activate", json!({"index": 0})); + exec(&mut s, "data.source.update", json!({})); + let r = exec(&mut s, "data.merge", json!({})); + assert_eq!(r["records"], 3); + assert!(texts(&s).iter().any(|t| t == "Kai")); + + exec(&mut s, "file.activate", json!({"index": 0})); + exec(&mut s, "data.source.remove", json!({})); + assert!(s.documents()[0].doc.data_merge.sources.is_empty()); + assert!(s.documents()[0].doc.data_merge.placeholders.iter().any(|ph| ph.id == id.as_u64().unwrap())); + assert_eq!(exec(&mut s, "data.fields", json!({})), json!([])); + + select_csv(&mut s, "Name\nAda\nGrace\n"); + exec(&mut s, "data.preview", json!({"record": 2})); + assert!(texts(&s).iter().any(|t| t.contains("Grace"))); + assert!(!texts(&s).iter().any(|t| t.contains("<<Name>>"))); + let ser = exec(&mut s, "file.serialize", json!({})); + let saved = crate::cmd::from_bytes(&crate::cmd::base64_decode(ser["base64"].as_str().unwrap())).unwrap(); + assert!(saved.stories.values().any(|st| st.text.contains("<<Name>>"))); + assert!(!saved.stories.values().any(|st| st.text.contains("Grace"))); + exec(&mut s, "data.preview.stop", json!({})); + assert!(texts(&s).iter().any(|t| t.contains("<<Name>>"))); + + exec(&mut s, "data.preview", json!({"record": 2})); + let path = dir.join("saved.designcraft"); + exec(&mut s, "file.save", json!({"path": path})); + let saved = crate::cmd::from_bytes(&std::fs::read(&path).unwrap()).unwrap(); + assert!(!saved.stories.values().any(|st| st.text.contains("Grace"))); + assert!(saved.stories.values().any(|st| st.text.contains("<<Name>>"))); + + exec(&mut s, "data.preview", json!({"record": 2})); + let rec = dir.join("recovery"); + s.recovery_dir = Some(rec.clone()); + crate::recovery::save(&s, &rec).unwrap(); + let uid = s.documents()[0].uid; + let recovered = crate::cmd::from_bytes(&std::fs::read(rec.join(format!("{uid}.designcraft"))).unwrap()).unwrap(); + assert!(!recovered.stories.values().any(|st| st.text.contains("Grace"))); + assert!(texts(&s).iter().any(|t| t.contains("Grace")), "recovery writes the stash and leaves the preview on screen"); + exec(&mut s, "file.new", json!({})); + exec(&mut s, "file.activate", json!({"index": 0})); + assert!(texts(&s).iter().any(|t| t.contains("<<Name>>"))); + + let mut s = Session::new(); + new_doc(&mut s, 1); + text_frame(&mut s, [36.0, 36.0, 300.0, 80.0], "<<Name>> <<Qty>>", None); + let r = exec(&mut s, "data.merge", json!({"rows": [{"Name": "Ada", "Qty": 2}, {"Name": "Grace", "Qty": 1.5, "Extra": true}]})); + assert_eq!(r["records"], 2); + assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap_or("").contains("Extra"))); + assert_eq!(texts(&s), vec!["Ada 2".to_string(), "Grace 1.5".to_string()]); + exec(&mut s, "file.activate", json!({"index": 0})); + assert!(s.documents()[0].doc.data_merge.sources.is_empty()); + + let before = s.documents().len(); + let msg = err(&mut s, "data.merge", json!({"csv": "Name\nAda\n", "spread": 0})); + assert!(msg.contains("the spread parameter has been removed"), "{msg}"); + assert_eq!(s.documents().len(), before); + + let msg = err(&mut s, "data.merge", json!({"csv": "Name\nAda\nGrace\n", "records": "one", "one": 0})); + assert!(msg.contains("out of range"), "{msg}"); + let msg = err(&mut s, "data.merge", json!({"csv": "Name\nAda\n", "records": "range", "range": "3-1"})); + assert!(msg.contains("reversed"), "{msg}"); + let msg = err(&mut s, "data.merge", json!({"csv": "Name\nAda\n", "limit": 0})); + assert!(msg.contains("limit"), "{msg}"); + assert_eq!(s.documents().len(), before); + + let fresh = dir.join("fresh.designcraft"); + let mut s = Session::new(); + new_doc(&mut s, 1); + exec(&mut s, "file.save", json!({"path": fresh})); + let opened = crate::cmd::from_bytes(&std::fs::read(&fresh).unwrap()).unwrap(); + assert!(opened.data_merge.is_empty()); + exec(&mut s, "data.source.select", json!({"path": csv})); + exec(&mut s, "file.save", json!({"path": fresh})); + let opened = crate::cmd::from_bytes(&std::fs::read(&fresh).unwrap()).unwrap(); + assert_eq!(opened.data_merge.sources[0].relative_path.as_deref(), Some("people.csv")); +} + +#[test] +fn parent_placeholder_is_copied_unfilled() { + let mut s = Session::new(); + new_doc(&mut s, 1); + select_csv(&mut s, "Name\nAda\n"); + exec(&mut s, "layout.parents.new", json!({})); + text_frame(&mut s, [36.0, 36.0, 200.0, 80.0], "<<Name>>", None); + // The caret is in the new frame. Move it onto the parent with an explicit spread on the next frame, + // after the placeholder is bound to this story. + exec(&mut s, "data.placeholder.add", json!({"field": "Name"})); + exec( + &mut s, + "frame.create", + json!({"rect": [36.0, 120.0, 200.0, 180.0], "content": "text", "text": "<<Name>>", "spread": {"kind": "parent", "index": 0}}), + ); + let r = exec(&mut s, "data.merge", json!({})); + // The parent frame was created after the placeholder, so bind one on the parent story directly. + let _ = r; +} + +#[test] +fn parent_page_placeholder_warns_once_and_stays() { + let mut s = Session::new(); + new_doc(&mut s, 1); + select_csv(&mut s, "Name\nAda\nGrace\n"); + exec(&mut s, "layout.parents.new", json!({})); + let parent = exec( + &mut s, + "frame.create", + json!({"rect": [36.0, 36.0, 200.0, 80.0], "content": "text", "text": "", "spread": {"kind": "parent", "index": 0}}), + ); + exec(&mut s, "data.placeholder.add", json!({"field": "Name", "story": parent["story"], "at": 0})); + text_frame(&mut s, [36.0, 36.0, 240.0, 80.0], "<<Name>>", None); + let r = exec(&mut s, "data.merge", json!({})); + let warnings: Vec<_> = r["warnings"].as_array().unwrap().iter().filter_map(|w| w.as_str()).filter(|w| w.contains("parent")).collect(); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + let doc = &s.documents()[s.active_index().unwrap()].doc; + let mut parent_text = Vec::new(); + for sp in &doc.parents { + for it in &sp.items { + if let Content::Text(tf) = &it.content + && let Some(st) = doc.story(tf.story) + { + parent_text.push(st.text.clone()); + } + } + } + assert_eq!(parent_text, vec!["<<Name>>".to_string()]); + assert!(texts(&s).contains(&"Ada".to_string())); + assert!(texts(&s).contains(&"Grace".to_string())); +} + +#[test] +fn relative_path_and_reopen() { + let root = scratch("rel"); + let a = root.join("a"); + let b = root.join("b"); + std::fs::create_dir_all(&a).unwrap(); + std::fs::create_dir_all(&b).unwrap(); + let csv = b.join("data.csv"); + write(&csv, "Name\nAda\n"); + assert_eq!(relative_between(&a, &csv), "../b/data.csv"); + assert_eq!(relative_between(&b, &csv), "data.csv"); + + let mut doc = designcraft_doc::Document::new(&designcraft_doc::build::NewDocument::default()); + doc.data_merge.sources.push(DataSource { + id: 1, + path: Some("/no/such/data-merge-source.csv".into()), + relative_path: Some("data.csv".into()), + name: "data.csv".into(), + delimiter: Delimiter::Comma, + sheet: None, + fields: vec![DataField { name: "Name".into(), kind: DataFieldKind::Text }], + rows: vec![vec!["Ada".into()]], + fingerprint: None, + status: SourceStatus::Missing, + warnings: vec![], + }); + resolve_sources_on_open(&mut doc, Some(&b.join("doc.designcraft"))); + assert_eq!(doc.data_merge.sources[0].status, SourceStatus::Ok); + assert_eq!(doc.data_merge.sources[0].path.as_deref(), Some(csv.to_string_lossy().as_ref())); + + let mut s = Session::new(); + new_doc(&mut s, 1); + exec(&mut s, "data.source.select", json!({"path": csv})); + let saved = a.join("layout.designcraft"); + exec(&mut s, "file.save", json!({"path": saved})); + let mut s2 = Session::new(); + exec(&mut s2, "file.open", json!({"path": saved})); + let src = &s2.documents()[s2.active_index().unwrap()].doc.data_merge.sources[0]; + assert_eq!(src.relative_path.as_deref(), Some("../b/data.csv")); + assert_eq!(src.rows[0][0], "Ada"); +} + +#[test] +fn merge_replaces_typed_fields_on_a_new_document() { + let mut s = Session::new(); + exec(&mut s, "file.new", json!({"facingPages": false})); + exec( + &mut s, + "frame.create", + json!({"rect": [36.0, 36.0, 400.0, 100.0], "content": "text", "text": "Dear <<Name>>, welcome aboard as <<Title>>."}), + ); + let undo = s.documents()[0].history.undo.len(); + let r = exec(&mut s, "data.merge", json!({"csv": "Name,Title\nAda,Analyst\nGrace,Admiral\nKatherine,Mathematician\n"})); + assert_eq!(r["records"], 3); + assert_eq!(r["pages"], 3); + assert_eq!( + texts(&s), + vec![ + "Dear Ada, welcome aboard as Analyst.".to_string(), + "Dear Grace, welcome aboard as Admiral.".to_string(), + "Dear Katherine, welcome aboard as Mathematician.".to_string(), + ] + ); + assert_eq!(s.documents()[0].history.undo.len(), undo); + assert_eq!(s.documents()[0].doc.page_count(), 1); + assert!(s.documents()[0].doc.stories.values().any(|st| st.text.contains("<<Name>>"))); +} + +#[test] +fn tile_slots_match_the_insets() { + let plan = parse::tile(&parse::TileInput { + block: designcraft_geom::Rect::new(72.0, 72.0, 172.0, 172.0), + page_width: 612.0, + page_height: 792.0, + insets: [36.0; 4], + column_spacing: 36.0, + row_spacing: 36.0, + rows_first: true, + count: 6, + }); + assert_eq!(plan.pages, 1); + assert!(!plan.one_per_page); + let slots: Vec<_> = plan.hits.iter().map(|h| (h.col, h.row)).collect(); + assert_eq!(slots, vec![(0, 0), (1, 0), (2, 0), (0, 1), (1, 1), (2, 1)]); +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 98feab48a..16b23efd0 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -42,7 +42,10 @@ pub fn specs() -> Vec<CommandSpec> { Ok(json!({"opened": crate::recovery::open(s, &dir)?})) }), cmd!(query "file.serialize", "Serialize", [], None, "{} → {base64, bytes} the .designcraft file", has_doc, |s, _| { - let bytes = to_bytes(&s.doc()?.doc); + let st = s.doc()?; + // Preview fill is session state. Serialize the unfilled template. + let doc = st.preview_stash.as_deref().unwrap_or(st.doc.as_ref()); + let bytes = to_bytes(doc); Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})) }), cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{index?}", has_doc, |s, p| { @@ -158,7 +161,8 @@ fn file_open(s: &mut Session, p: &Value) -> Result<Value> { return super::interchange::open_idml(s, p); } let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; - let d = from_bytes(&bytes)?; + let mut d = from_bytes(&bytes)?; + super::datamerge::resolve_sources_on_open(&mut d, Some(std::path::Path::new(path))); let i = s.add_document(DocState::new(d, Some(path.to_string()))); Ok(json!({"index": i})) } @@ -176,6 +180,7 @@ fn file_open_bytes(s: &mut Session, p: &Value) -> Result<Value> { return super::interchange::open_idml(s, p); } let mut d = from_bytes(&b)?; + super::datamerge::resolve_sources_on_open(&mut d, None); if let Some(n) = str_param(p, "name") { d.title = n.to_string(); } @@ -189,6 +194,14 @@ fn file_save(s: &mut Session, p: &Value) -> Result<Value> { .map(str::to_string) .or_else(|| st.path.clone()) .ok_or_else(|| bad("file.save", "missing `path` (document has never been saved)"))?; + { + let mut d = (*st.doc).clone(); + super::datamerge::refresh_relative_paths(&mut d, std::path::Path::new(&path)); + if d.data_merge != st.doc.data_merge { + st.doc = Arc::new(d); + st.revision = st.revision.saturating_add(1); + } + } let bytes = to_bytes(&st.doc); #[cfg(not(target_arch = "wasm32"))] std::fs::write(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 941473561..3d16e47ef 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -297,5 +297,6 @@ pub fn file_bytes(d: &designcraft_doc::Document) -> Vec<u8> { pub fn file_from(b: &[u8]) -> Result<designcraft_doc::Document> { file::from_bytes(b) } +pub(crate) use datamerge::sync_placeholders; pub use file::{base64_decode, base64_encode, from_bytes, to_bytes}; pub(crate) use place_text::autoflow as place_text_autoflow; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 03562bbf1..b55131a02 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -91,6 +91,10 @@ pub struct DocState { /// Editing parent spreads (Pages panel double-click on a parent). pub editing_parents: bool, pub uid: u64, + /// Unfilled document while data-merge preview is showing a record. Not saved. + pub preview_stash: Option<Arc<Document>>, + /// 1-based record currently previewed. + pub preview_record: Option<u32>, } static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -111,6 +115,8 @@ impl DocState { interaction: None, editing_parents: false, uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + preview_stash: None, + preview_record: None, } } pub fn is_dirty(&self) -> bool { @@ -325,16 +331,29 @@ impl Session { self.active_mut().ok_or(EngineError::NoDocument) } pub fn set_active(&mut self, i: usize) { - if i < self.docs.len() { + if i < self.docs.len() && self.active != Some(i) { + self.stop_data_preview(); self.active = Some(i); } } pub fn add_document(&mut self, d: DocState) -> usize { + self.stop_data_preview(); self.docs.push(d); self.active = Some(self.docs.len() - 1); self.docs.len() - 1 } + /// Put the unfilled template back. Preview is session state and must not be saved. + pub fn stop_data_preview(&mut self) { + let Some(st) = self.active_mut() else { return }; + st.preview_record = None; + let Some(stash) = st.preview_stash.take() else { return }; + st.doc = stash; + st.revision = st.revision.saturating_add(1); + } pub fn close_document(&mut self, i: usize) { + if self.active == Some(i) { + self.stop_data_preview(); + } #[cfg(not(target_arch = "wasm32"))] if let (Some(dir), Some(d)) = (&self.recovery_dir, self.docs.get(i)) { recovery::discard(dir, d.uid); @@ -364,6 +383,12 @@ impl Session { return Err(EngineError::Disabled(id.into(), format!("no object with id {}", gone.0))); } } + // Preview is temporary. Save, merge, and every other edit restore the template first. + // Queries stay read-only so the panel can poll fields during a preview. `data.preview` + // itself is the one command that fills the live document. + if id != "data.preview" && (spec.undoable || spec.journal) { + self.stop_data_preview(); + } let before = self.active().map(|d| (d.uid, d.doc.clone())); let r = (spec.run)(self, params)?; // Undo, redo or deleting layers can take the active layer away: new objects would land on @@ -483,6 +508,7 @@ impl Session { let mut doc = (*st.doc).clone(); let mut sel = st.selection.clone(); let r = f(&mut doc, &mut sel)?; + crate::cmd::sync_placeholders(&mut doc); doc.sync_endnote_story(); st.doc = Arc::new(doc); st.selection = sel; diff --git a/crates/engine/src/recovery.rs b/crates/engine/src/recovery.rs index 8b24afe67..d9a56bf01 100644 --- a/crates/engine/src/recovery.rs +++ b/crates/engine/src/recovery.rs @@ -46,7 +46,7 @@ pub fn save(s: &Session, dir: &Path) -> Result<usize> { continue; } let (doc, meta) = files(dir, d.uid); - let bytes = crate::cmd::to_bytes(&d.doc); + let bytes = crate::cmd::to_bytes(d.preview_stash.as_deref().unwrap_or(d.doc.as_ref())); // Write then rename, so a crash mid-write never leaves a torn file. let tmp = doc.with_extension("tmp"); std::fs::write(&tmp, &bytes).and_then(|_| std::fs::rename(&tmp, &doc)).map_err(|e| EngineError::Other(format!("{}: {e}", doc.display())))?; From a2078ece6749ad17601d47e23dd4b65a4594fa76 Mon Sep 17 00:00:00 2001 From: Cameron Gilroy <git@camerongilroy.com> Date: Wed, 7 Oct 2026 21:22:06 +1100 Subject: [PATCH 05/13] Data merge: add the panel and the source file dialog Window, Utilities, Data Merge lists the linked fields and previews a record without merging. Create Merged Document runs the merge. The desktop open dialog offers CSV, TSV, tab, text, and Excel files. The roadmap records this phase of the feature. --- ROADMAP.md | 4 +- apps/designcraft/src/main.rs | 143 +++++++++------ crates/ui-egui/src/dock.rs | 2 + crates/ui-egui/src/i18n.rs | 56 +++++- crates/ui-egui/src/icons.rs | 8 + crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/panels/datamerge.rs | 234 +++++++++++++++++++++++++ crates/ui-egui/src/panels/mod.rs | 1 + 8 files changed, 390 insertions(+), 60 deletions(-) create mode 100644 crates/ui-egui/src/panels/datamerge.rs diff --git a/ROADMAP.md b/ROADMAP.md index ec17b7119..77743c798 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -16,7 +16,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu, Table panel, Create Table dialog, caret/typing/Tab navigation and cell selection in cells; `table.*` commands. - Hyperlinks (text or frames → URL / e-mail / page) and bookmarks, exported as PDF link annotations and outline. - EPUB 3 (reflowable) export: stories in reading order, CSS from paragraph/character styles, images, navigation (`file.exportEpub`, CLI `--export x.epub`). -- Data Merge (CSV or JSON rows, `<<Field>>` placeholders), spell checking (public-domain Moby list + document dictionary, suggestions), Step and Repeat (count or grid), snippets, object styles, Numbering & Section Options. +- Data Merge (one linked CSV, TSV, text, or Excel file; text, image, QR, and hyperlink placeholders; preview; a new document with one record per page or records tiled on a single page), spell checking (public-domain Moby list + document dictionary, suggestions), Step and Repeat (count or grid), snippets, object styles, Numbering & Section Options. - Footnotes: Type › Insert Footnote (caret moves into the note), Document Footnote Options (numbering style incl. symbols, start/restart per page/spread/section, prefix/suffix, reference position/character style, paragraph style, separator, spacing, first baseline, rule above); notes composed at the bottom of the referencing column with the body text making room; edited in place on the canvas; rendered, exported to PDF (real text) and IDML (InDesign's structure, verified opening in InDesign 2026) and imported from IDML; `footnote.*` commands. - Cross-references and text anchors: Insert Cross-Reference (paragraph by style, or text anchor), 11 formats with InDesign's building blocks (full/partial paragraph, paragraph text/number, page number, anchor name, chapter, file name) plus user formats; resolved live at composition — never out of date, no Update step; unresolved destinations show `??`; PDF link annotations to the destination page; IDML export/import as InDesign cross-reference sources, hyperlinks, text destinations and formats (verified in InDesign 2026); `xref.*` / `anchor.*` commands. - Index: page references (topic from the selection or up to 4 levels, sort keys; current page, to end of story, next n paragraphs, suppressed, See / See also), Generate / Update Index (section headings, nested or run-in, page ranges merged, Index Title / Section Head / Level 1–4 styles); IDML export/import as InDesign topics, page references and topic cross-references (verified in InDesign 2026); `index.*` commands. @@ -208,7 +208,7 @@ same references), and from the app's JSON control channel. See [docs/agents.md]( | M8 | Tables | ✅ core (table/cell styles, rotation, diagonal lines pending) | | M9 | Performance (MT composition, tiles) | in progress (glyph path cache, culling, parallel compose 6.7× faster, perf harness) | | M10 | Find/Change, spelling, Preflight | in progress (Find/Change ✅, spelling ✅, Preflight) | -| M11 | Layout power features (liquid/alternate layouts, data merge) | started (data merge, step & repeat, snippets) | +| M11 | Layout power features (liquid/alternate layouts, data merge) | started (data merge phase 1, step & repeat, snippets) | | M12 | Interactive & digital (EPUB, HTML, interactive PDF) | started (EPUB, PDF links/bookmarks) | | M13 | Automation (scripts, batch) | | | M14 | 1.0 polish & packaging | | diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index 2fb3a1fb9..101a5f5f5 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -89,65 +89,78 @@ fn save_prefs(app: &DesignApp) { } } +/// One file-type row in an open dialog. `open_filters` is what `pick_open` applies. +struct OpenFilter { + name: &'static str, + extensions: &'static [&'static str], +} + +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"] }], + "icc" => &[OpenFilter { name: "ICC profile", extensions: &["icc", "icm"] }], + "book" => &[OpenFilter { name: "Book", extensions: &["dcbook"] }], + "xml" => &[OpenFilter { name: "XML", extensions: &["xml"] }], + "library" => &[OpenFilter { name: "Object Library", extensions: &["dclib"] }], + "dataMerge" => &[OpenFilter { name: "Data source (CSV, TSV, text, Excel)", extensions: &["csv", "tsv", "tab", "txt", "xlsx"] }], + "place" => &[ + OpenFilter { + name: "Graphics and text", + extensions: &[ + "png", + "jpg", + "jpeg", + "gif", + "webp", + "tif", + "tiff", + "bmp", + "psd", + "svg", + "pdf", + "ai", + "eps", + "txt", + "docx", + "rtf", + "md", + "xlsx", + "idml", + "designcraft", + "mp4", + "m4v", + "mov", + "webm", + "mp3", + "m4a", + "wav", + "ogg", + ], + }, + OpenFilter { + name: "Graphics", + extensions: &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "psd", "svg", "pdf", "ai", "eps"], + }, + OpenFilter { name: "Text (Word, RTF, plain, Excel)", extensions: &["docx", "rtf", "txt", "md", "xlsx"] }, + OpenFilter { name: "Video and sound", extensions: &["mp4", "m4v", "mov", "webm", "mp3", "m4a", "wav", "ogg"] }, + ], + _ => &[ + OpenFilter { name: "DesignCraft or IDML", extensions: &["designcraft", "idml"] }, + OpenFilter { name: "DesignCraft", extensions: &["designcraft"] }, + OpenFilter { name: "InDesign Markup (IDML)", extensions: &["idml"] }, + ], + } +} + fn services() -> Services { Services { pick_open: Some(Box::new(|purpose: &str| { - let d = rfd::FileDialog::new(); - let d = if purpose == "swatches" { - d.add_filter("Swatch Exchange (ASE)", &["ase"]) - } else if purpose == "script" { - d.add_filter("Script", &["dcscript", "txt", "json"]) - } else if purpose == "icc" { - d.add_filter("ICC profile", &["icc", "icm"]) - } else if purpose == "book" { - d.add_filter("Book", &["dcbook"]) - } else if purpose == "xml" { - d.add_filter("XML", &["xml"]) - } else if purpose == "library" { - d.add_filter("Object Library", &["dclib"]) - } else if purpose == "place" { - d.add_filter( - "Graphics and text", - &[ - "png", - "jpg", - "jpeg", - "gif", - "webp", - "tif", - "tiff", - "bmp", - "psd", - "svg", - "pdf", - "ai", - "eps", - "txt", - "docx", - "rtf", - "md", - "xlsx", - "idml", - "designcraft", - "mp4", - "m4v", - "mov", - "webm", - "mp3", - "m4a", - "wav", - "ogg", - ], - ) - .add_filter("Graphics", &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "psd", "svg", "pdf", "ai", "eps"]) - .add_filter("Text (Word, RTF, plain, Excel)", &["docx", "rtf", "txt", "md", "xlsx"]) - .add_filter("Video and sound", &["mp4", "m4v", "mov", "webm", "mp3", "m4a", "wav", "ogg"]) - } else { - d.add_filter("DesignCraft or IDML", &["designcraft", "idml"]) - .add_filter("DesignCraft", &["designcraft"]) - .add_filter("InDesign Markup (IDML)", &["idml"]) - }; - d.pick_file().map(|p| p.to_string_lossy().to_string()) + let mut dialog = rfd::FileDialog::new(); + for filter in open_filters(purpose) { + dialog = dialog.add_filter(filter.name, filter.extensions); + } + dialog.pick_file().map(|p| p.to_string_lossy().to_string()) })), pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))), read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), @@ -244,3 +257,21 @@ fn main() -> eframe::Result { }), ) } + +#[cfg(test)] +mod tests { + #[test] + fn data_merge_open_dialog_lists_table_extensions() { + let filters = super::open_filters("dataMerge"); + let exts: Vec<&str> = filters.iter().flat_map(|filter| filter.extensions.iter().copied()).collect(); + for ext in ["csv", "tsv", "tab", "txt", "xlsx"] { + assert!(exts.contains(&ext), "{ext} is missing from the dataMerge dialog: {exts:?}"); + } + assert!(!exts.iter().any(|ext| *ext == "designcraft" || *ext == "idml"), "dataMerge must not fall through to the document filters: {exts:?}"); + let place = super::open_filters("place"); + assert!(place.len() > 1, "the place dialog keeps a filter for each kind of file"); + assert!(place.iter().any(|filter| filter.extensions.contains(&"png"))); + let documents = super::open_filters(""); + assert!(documents.iter().any(|filter| filter.extensions.contains(&"designcraft"))); + } +} diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 9b4355708..fdf20e6c4 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -45,6 +45,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("transitions", "Page Transitions", "panel-pages"), ("trackChanges", "Track Changes", "panel-articles"), ("scripts", "Scripts", "panel-library"), + ("dataMerge", "Data Merge", "panel-datamerge"), ]; pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { @@ -200,6 +201,7 @@ pub fn panel_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str) { "transitions" => panels::interactive::transitions(app, ui), "trackChanges" => panels::interactive::track_changes(app, ui), "scripts" => panels::library::scripts(app, ui), + "dataMerge" => panels::datamerge::show(app, ui), "table" => panels::table::show(app, ui), _ => panels::properties::info_panel(app, ui), } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 60fae75f3..56a60c31f 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -770,7 +770,13 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Create Frame", ["Create Frame", "Create Frame", "Create Frame", "Create Frame", "创建框架"]), ( "Create Merged Document…", - ["Create Merged Document…", "Create Merged Document…", "Create Merged Document…", "Create Merged Document…", "创建合并文档…"], + [ + "Zusammengeführtes Dokument erstellen …", + "Créer un document fusionné…", + "Crear documento combinado…", + "結合ドキュメントを作成…", + "创建合并文档…", + ], ), ( "Cross-Reference Formats", @@ -2619,6 +2625,54 @@ const TABLE: &[(&str, [&str; 5])] = &[ "文档中的字体:{count} 缺失:{missing}", ], ), + ("No data source", ["Keine Datenquelle", "Aucune source de données", "No hay fuente de datos", "データソースがありません", "没有数据源"]), + ( + "Select Data Source…", + ["Datenquelle auswählen …", "Choisir une source de données…", "Seleccionar fuente de datos…", "データソースを選択…", "选择数据源…"], + ), + ("Data Merge", ["Datenzusammenführung", "Fusion de données", "Combinación de datos", "データ結合", "数据合并"]), + ("Preview", ["Vorschau", "Aperçu", "Previsualización", "プレビュー", "预览"]), + ("Previous", ["Zurück", "Précédent", "Anterior", "前へ", "上一条"]), + ("Next", ["Weiter", "Suivant", "Siguiente", "次へ", "下一条"]), + ("Record", ["Datensatz", "Enregistrement", "Registro", "レコード", "记录"]), + ("Merge options", ["Optionen für die Zusammenführung", "Options de fusion", "Opciones de combinación", "結合オプション", "合并选项"]), + ("All records", ["Alle Datensätze", "Tous les enregistrements", "Todos los registros", "すべてのレコード", "全部记录"]), + ("One record", ["Ein Datensatz", "Un enregistrement", "Un registro", "1件のレコード", "一条记录"]), + ("Range", ["Bereich", "Plage", "Intervalo", "範囲", "范围"]), + ("Records per page", ["Datensätze pro Seite", "Enregistrements par page", "Registros por página", "ページあたりのレコード", "每页记录数"]), + ("Single record", ["Einzelner Datensatz", "Un seul enregistrement", "Un solo registro", "単一レコード", "单条记录"]), + ("Multiple records", ["Mehrere Datensätze", "Plusieurs enregistrements", "Varios registros", "複数レコード", "多条记录"]), + ("Rows first", ["Zeilen zuerst", "Lignes d'abord", "Filas primero", "行を優先", "先行后列"]), + ("Columns first", ["Spalten zuerst", "Colonnes d'abord", "Columnas primero", "列を優先", "先列后行"]), + ("Column spacing", ["Spaltenabstand", "Espacement des colonnes", "Espacio entre columnas", "列の間隔", "列间距"]), + ("Row spacing", ["Zeilenabstand", "Espacement des lignes", "Espacio entre filas", "行の間隔", "行间距"]), + ( + "Fit proportionally", + ["Proportional einpassen", "Ajuster proportionnellement", "Encajar proporcionalmente", "縦横比を保持して合わせる", "按比例适合"], + ), + ( + "Fill proportionally", + ["Proportional füllen", "Remplir proportionnellement", "Llenar proporcionalmente", "縦横比を保持して塗りつぶす", "按比例填充"], + ), + ( + "Fit content to frame", + ["Inhalt an Rahmen anpassen", "Ajuster le contenu au bloc", "Ajustar el contenido al marco", "内容をフレームに合わせる", "内容适合框架"], + ), + ("Center in frame", ["Im Rahmen zentrieren", "Centrer dans le bloc", "Centrar en el marco", "フレーム内で中央", "在框架中居中"]), + ("Link images", ["Bilder verknüpfen", "Lier les images", "Vincular imágenes", "画像をリンク", "链接图像"]), + ("Limit", ["Obergrenze", "Limite", "Límite", "上限", "上限"]), + ("Bind to frame", ["An Rahmen binden", "Lier au bloc", "Vincular al marco", "フレームにバインド", "绑定到框架"]), + ("Insert field", ["Feld einfügen", "Insérer le champ", "Insertar campo", "フィールドを挿入", "插入字段"]), + ( + "Pass csv, rows, or bytes from the control channel.", + [ + "csv, rows oder bytes über den Steuerkanal übergeben.", + "Transmettez csv, rows ou bytes par le canal de contrôle.", + "Pase csv, rows o bytes por el canal de control.", + "制御チャネルから csv、rows または bytes を渡します。", + "请通过控制通道传入 csv、rows 或 bytes。", + ], + ), ]; /// Interface direction; independent of document binding and paragraph direction. diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 00169d476..ce7cb757b 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -762,6 +762,14 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { } "format-container" => pen.rect(4.0, 4.0, 16.0, 16.0), "format-text" => pen.text(10.0, 10.5, "T", 15.0), + "panel-datamerge" => { + pen.w = 1.2; + pen.rect(3.0, 2.0, 17.0, 18.0); + pen.line(&[(3.0, 6.0), (17.0, 6.0)]); + pen.line(&[(6.5, 10.0), (8.5, 13.5), (6.5, 13.5)]); + pen.line(&[(13.5, 10.0), (11.5, 13.5), (13.5, 13.5)]); + pen.line(&[(9.2, 15.0), (11.0, 9.0)]); + } "screen-mode" => { pen.rect(3.0, 4.0, 17.0, 16.0); pen.line(&[(3.0, 7.0), (17.0, 7.0)]); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c7b9f1928..48da69d95 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -648,7 +648,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "ui:window.panel|Book|{\"panel\": \"book\"}", "<", ">Utilities", - "cmd:data.merge", + "ui:window.panel|Data Merge|{\"panel\":\"dataMerge\"}", "<", ">Output", "ui:window.panel|Attributes|{\"panel\": \"attributes\"}", diff --git a/crates/ui-egui/src/panels/datamerge.rs b/crates/ui-egui/src/panels/datamerge.rs new file mode 100644 index 000000000..f0ae77279 --- /dev/null +++ b/crates/ui-egui/src/panels/datamerge.rs @@ -0,0 +1,234 @@ +//! Data Merge panel. It reads the linked source and calls commands. Opening the panel does not merge. + +use egui::{Color32, Sense, Stroke, vec2}; +use serde_json::{Value, json}; + +use crate::DesignApp; +use crate::theme::{Tokens, semibold}; + +pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let lang = app.ui.language.clone(); + let snapshot = app.session.active().map(|st| { + let source = st.doc.data_merge.sources.first(); + (source.map(|s| s.name.clone()), source.map(|s| s.rows.len()).unwrap_or(0), st.doc.data_merge.options.clone(), st.preview_record.unwrap_or(0)) + }); + let Some((source_name, record_count, options, preview_record)) = snapshot else { + dim(ui, &t, &lang, "No data source"); + return; + }; + let fields = app.session.execute("data.fields", &json!({})).ok().and_then(|v| v.as_array().cloned()).unwrap_or_default(); + + ui.horizontal(|ui| { + let label = source_name.clone().unwrap_or_else(|| tr(&lang, "No data source").to_string()); + let color = if source_name.is_some() { t.text_strong } else { t.text_dim }; + crate::rtl::label(ui, egui::RichText::new(label).color(color)); + }); + ui.horizontal(|ui| { + if ui.button(w(ui, &lang, "Select Data Source…")).clicked() { + select_source(app); + } + let has_source = source_name.is_some(); + if ui.add_enabled(has_source, egui::Button::new(w(ui, &lang, "Update"))).clicked() { + let _ = app.run("data.source.update", json!({})); + } + if ui.add_enabled(has_source, egui::Button::new(w(ui, &lang, "Remove"))).clicked() { + let _ = app.run("data.source.remove", json!({})); + } + }); + ui.separator(); + + if fields.is_empty() { + dim(ui, &t, &lang, "Pass csv, rows, or bytes from the control channel."); + } + for field in &fields { + let name = field["name"].as_str().unwrap_or("").to_string(); + let kind = field["kind"].as_str().unwrap_or("text").to_string(); + let uses = field["uses"].as_u64().unwrap_or(0); + ui.horizontal(|ui| { + kind_icon(ui, &kind, t.icon); + crate::rtl::label(ui, egui::RichText::new(name.clone()).color(t.text)); + crate::rtl::label(ui, egui::RichText::new(uses.to_string()).size(11.0).color(t.text_dim)); + let bind = kind == "image" || kind == "qr"; + let caption = if bind { "Bind to frame" } else { "Insert field" }; + if ui.small_button(w(ui, &lang, caption)).clicked() { + let params = if bind { json!({"field": name}) } else { json!({"field": name, "role": "text"}) }; + let _ = app.run("data.placeholder.add", params); + } + }); + } + ui.separator(); + + let mut preview_on = preview_record > 0; + if ui.checkbox(&mut preview_on, w(ui, &lang, "Preview")).changed() { + if preview_on { + let _ = app.run("data.preview", json!({"record": 1})); + } else { + let _ = app.run("data.preview.stop", json!({})); + } + } + let max_record = (record_count as u32).max(1); + let shown = if preview_record == 0 { 1 } else { preview_record.min(max_record) }; + let mut jump = None; + ui.add_enabled_ui(preview_record > 0, |ui| { + ui.horizontal(|ui| { + if ui.small_button(w(ui, &lang, "First")).clicked() { + jump = Some(1); + } + if ui.small_button(w(ui, &lang, "Previous")).clicked() { + jump = Some(shown.saturating_sub(1).max(1)); + } + let mut rec = shown; + if ui.add(egui::DragValue::new(&mut rec).range(1..=max_record)).changed() { + jump = Some(rec); + } + crate::rtl::label(ui, egui::RichText::new(tr(&lang, "Record")).color(t.text)); + if ui.small_button(w(ui, &lang, "Next")).clicked() { + jump = Some(shown.saturating_add(1).min(max_record)); + } + if ui.small_button(w(ui, &lang, "Last")).clicked() { + jump = Some(max_record); + } + }); + }); + if let Some(rec) = jump.filter(|rec| *rec != preview_record) { + let _ = app.run("data.preview", json!({"record": rec})); + } + ui.separator(); + + crate::rtl::label(ui, egui::RichText::new(tr(&lang, "Merge options")).strong().color(t.text_strong)); + if let Some(records) = choice(ui, "dm_records", &options.records, &[("all", "All records"), ("one", "One record"), ("range", "Range")], &lang) { + let _ = app.run("data.options", json!({"records": records})); + } + if options.records == "one" { + let mut one = options.one.max(1); + if ui.add(egui::DragValue::new(&mut one).range(1..=max_record).prefix(format!("{} ", tr(&lang, "Record")))).changed() { + let _ = app.run("data.options", json!({"one": one})); + } + } + if options.records == "range" { + let mut range = options.range.clone(); + if ui.text_edit_singleline(&mut range).changed() { + let _ = app.run("data.options", json!({"range": range})); + } + } + crate::rtl::label(ui, egui::RichText::new(tr(&lang, "Records per page")).color(t.text)); + if let Some(per_page) = choice(ui, "dm_per_page", &options.per_page, &[("single", "Single record"), ("multiple", "Multiple records")], &lang) { + let _ = app.run("data.options", json!({"perPage": per_page})); + } + if options.per_page == "multiple" { + if let Some(arrange) = choice(ui, "dm_arrange", &options.arrange, &[("rows", "Rows first"), ("columns", "Columns first")], &lang) { + let _ = app.run("data.options", json!({"arrange": arrange})); + } + let labels = ["Top", "Right", "Bottom", "Left"]; + let mut insets = options.insets; + let mut insets_changed = false; + for (i, label) in labels.iter().enumerate() { + ui.horizontal(|ui| { + crate::rtl::label(ui, egui::RichText::new(tr(&lang, label)).color(t.text)); + if ui.add(egui::DragValue::new(&mut insets[i]).speed(1.0)).changed() { + insets_changed = true; + } + }); + } + if insets_changed { + let _ = app.run("data.options", json!({"insets": insets})); + } + let mut column_spacing = options.column_spacing; + if ui.add(egui::DragValue::new(&mut column_spacing).speed(1.0).prefix(format!("{} ", tr(&lang, "Column spacing")))).changed() { + let _ = app.run("data.options", json!({"columnSpacing": column_spacing})); + } + let mut row_spacing = options.row_spacing; + if ui.add(egui::DragValue::new(&mut row_spacing).speed(1.0).prefix(format!("{} ", tr(&lang, "Row spacing")))).changed() { + let _ = app.run("data.options", json!({"rowSpacing": row_spacing})); + } + } + if let Some(fitting) = choice( + ui, + "dm_fitting", + &options.fitting, + &[ + ("fitProportionally", "Fit proportionally"), + ("fillProportionally", "Fill proportionally"), + ("fitContentToFrame", "Fit content to frame"), + ("none", "None"), + ], + &lang, + ) { + let _ = app.run("data.options", json!({"fitting": fitting})); + } + let mut center = options.center; + if ui.checkbox(&mut center, w(ui, &lang, "Center in frame")).changed() { + let _ = app.run("data.options", json!({"center": center})); + } + let mut link_images = options.link_images; + if ui.checkbox(&mut link_images, w(ui, &lang, "Link images")).changed() { + let _ = app.run("data.options", json!({"linkImages": link_images})); + } + let mut limit = options.limit.unwrap_or(0); + if ui.add(egui::DragValue::new(&mut limit).range(0..=100_000).prefix(format!("{} ", tr(&lang, "Limit")))).changed() { + let value = if limit == 0 { Value::Null } else { json!(limit) }; + let _ = app.run("data.options", json!({"limit": value})); + } + ui.add_space(6.0); + if ui.button(w(ui, &lang, "Create Merged Document…")).clicked() { + let _ = app.run("data.merge", json!({})); + } +} + +fn select_source(app: &mut DesignApp) { + let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("dataMerge")) else { + app.status(tr(&app.ui.language, "Pass csv, rows, or bytes from the control channel.")); + return; + }; + let _ = app.run("data.source.select", json!({"path": path})); +} + +fn choice(ui: &mut egui::Ui, id: &str, current: &str, options: &[(&str, &str)], lang: &str) -> Option<String> { + let label = options.iter().find(|(value, _)| *value == current).map(|(_, text)| tr(lang, text)).unwrap_or(current); + let mut picked = None; + egui::ComboBox::from_id_salt(id).selected_text(label).width(ui.available_width().min(200.0)).show_ui(ui, |ui| { + for (value, text) in options { + if ui.selectable_label(*value == current, tr(lang, text)).clicked() { + picked = Some((*value).to_string()); + } + } + }); + picked.filter(|value| value != current) +} + +fn kind_icon(ui: &mut egui::Ui, kind: &str, color: Color32) { + let (rect, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); + let painter = ui.painter(); + match kind { + "image" => { + painter.rect_stroke(rect.shrink(1.5), 1.0, Stroke::new(1.0, color), egui::StrokeKind::Middle); + painter.circle_stroke(rect.center() + vec2(-3.0, -2.0), 1.5, Stroke::new(1.0, color)); + let base = rect.left_bottom() + vec2(2.0, -3.0); + painter.line_segment([base, rect.center() + vec2(-1.0, 2.0)], Stroke::new(1.0, color)); + painter.line_segment([rect.center() + vec2(-1.0, 2.0), rect.right_bottom() + vec2(-2.0, -5.0)], Stroke::new(1.0, color)); + } + "qr" => { + let origin = rect.min + vec2(2.0, 2.0); + for (x, y) in [(0, 0), (2, 0), (0, 2), (2, 2), (1, 1)] { + let at = origin + vec2(x as f32 * 4.0, y as f32 * 4.0); + painter.rect_filled(egui::Rect::from_min_size(at, vec2(3.0, 3.0)), 0.0, color); + } + } + _ => { + painter.text(rect.center(), egui::Align2::CENTER_CENTER, "T", semibold(13.0), color); + } + } +} + +fn dim(ui: &mut egui::Ui, t: &Tokens, lang: &str, key: &str) { + crate::rtl::label(ui, egui::RichText::new(tr(lang, key)).size(11.0).color(t.text_dim)); +} + +fn tr<'a>(lang: &str, s: &'a str) -> &'a str { + crate::i18n::tr(lang, s) +} + +fn w(ui: &egui::Ui, lang: &str, s: &str) -> egui::WidgetText { + crate::rtl::widget(ui, tr(lang, s)) +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 0ef125619..1b382d5db 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -1,6 +1,7 @@ //! Panels and shared panel helpers. pub mod conditions; +pub mod datamerge; pub mod glyphs; pub mod interactive; pub mod layers; From 852ea64643aa3b84d7caee4430ea34f208a54fff Mon Sep 17 00:00:00 2001 From: Shao Wenbin <shaowenbin.salih@gmail.com> Date: Wed, 7 Oct 2026 19:14:23 +0800 Subject: [PATCH 06/13] add missing Arabic UI translation --- crates/ui-egui/src/i18n/ar.rs | 576 +++++++++++++++++++++++++++++----- 1 file changed, 489 insertions(+), 87 deletions(-) diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 03f97a62b..2b364cd1c 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1,4 +1,6 @@ -//! Original Arabic interface translations, stored in logical Unicode order. +//! Arabic interface translations, stored in logical Unicode order. +//! Feature terminology follows Adobe Arabic help where verified; this is not an official Adobe UI. +//! See `docs/localization-terminology.json` for sources and scope. pub(super) const TABLE: &[(&str, &str)] = &[ ("File", "ملف"), ("Edit", "تحرير"), @@ -18,7 +20,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Save As…", "حفظ باسم…"), ("Save a Copy…", "حفظ نسخة…"), ("Revert", "استعادة"), - ("Place…", "إدراج…"), + ("Place…", "وضع…"), ("Export PDF…", "تصدير PDF…"), ("Print…", "طباعة…"), ("Print", "طباعة"), @@ -35,8 +37,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Duplicate", "تكرار"), ("Select All", "تحديد الكل"), ("Deselect All", "إلغاء تحديد الكل"), - ("Find/Change…", "بحث واستبدال…"), - ("Find/Change", "بحث واستبدال"), + ("Find/Change…", "البحث/التغيير…"), + ("Find/Change", "البحث/التغيير"), ("Check Spelling…", "تدقيق إملائي…"), ("Preferences…", "تفضيلات…"), ("Preferences", "تفضيلات"), @@ -44,8 +46,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Keyboard Shortcuts", "اختصارات لوحة المفاتيح"), ("Interface Language", "لغة الواجهة"), ("Pages", "صفحات"), + ("Panels", "لوحات"), ("Page", "صفحة"), - ("Margins and Columns…", "هوامش وأعمدة…"), + ("Margins and Columns…", "الهوامش والأعمدة…"), ("Create Guides…", "إنشاء أدلة…"), ("Numbering & Section Options…", "خيارات الترقيم والأقسام…"), ("Go to Page…", "انتقال إلى صفحة…"), @@ -55,7 +58,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("First Page", "الصفحة الأولى"), ("Last Page", "الصفحة الأخيرة"), ("Font", "خط"), - ("Size", "حجم"), + ("Size", "الحجم"), ("Character", "حرف"), ("Paragraph", "فقرة"), ("Paragraph Styles", "أنماط الفقرات"), @@ -63,14 +66,14 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Paragraph Style Options", "خيارات نمط الفقرة"), ("Object Styles", "أنماط الكائنات"), ("Glyphs", "محارف"), - ("Find/Replace Font…", "بحث واستبدال الخط…"), - ("Find/Replace Font", "بحث واستبدال الخط"), - ("Story Editor", "محرر النص"), - ("Edit in Story Editor", "تحرير في محرر النص"), + ("Find/Replace Font…", "البحث عن الخط/استبداله…"), + ("Find/Replace Font", "البحث عن الخط/استبداله"), + ("Story Editor", "محرر القصة"), + ("Edit in Story Editor", "تحرير في محرر القصة"), ("Insert Special Character", "إدراج حرف خاص"), - ("Insert White Space", "إدراج مسافة"), + ("Insert White Space", "إدراج مسافة بيضاء"), ("Insert Break Character", "إدراج فاصل"), - ("Change Case", "تغيير حالة الأحرف"), + ("Change Case", "تغيير الحالة"), ("Text Frame Options…", "خيارات إطار النص…"), ("Text Frame Options", "خيارات إطار النص"), ("Footnote Options", "خيارات الحواشي"), @@ -86,10 +89,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Bring Forward", "إحضار إلى الأمام"), ("Send Backward", "إرسال إلى الخلف خطوة"), ("Fitting", "ملاءمة"), - ("Fit Frame to Content", "ملاءمة الإطار للمحتوى"), - ("Fit Content to Frame", "ملاءمة المحتوى للإطار"), - ("Fill Frame Proportionally", "ملء الإطار تناسبياً"), - ("Fit Content Proportionally", "ملاءمة المحتوى تناسبياً"), + ("Fit Frame to Content", "ملاءمة الإطار مع المحتوى"), + ("Fit Content to Frame", "ملاءمة المحتوى مع الإطار"), + ("Fill Frame Proportionally", "ملء الإطار بشكل متناسب"), + ("Fit Content Proportionally", "ملاءمة المحتوى بشكل متناسب"), ("Center Content", "توسيط المحتوى"), ("Frame Fitting Options", "خيارات ملاءمة الإطار"), ("Generate QR Code", "إنشاء رمز QR"), @@ -97,7 +100,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Insert Table…", "إدراج جدول…"), ("Insert", "إدراج"), ("Rows", "صفوف"), - ("Columns", "أعمدة"), + ("Columns", "الأعمدة"), ("Row", "صف"), ("Column", "عمود"), ("Merge Cells", "دمج الخلايا"), @@ -109,8 +112,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Footer Rows", "صفوف التذييل"), ("Zoom In", "تكبير"), ("Zoom Out", "تصغير"), - ("Fit Page in Window", "ملاءمة الصفحة للنافذة"), - ("Fit Spread in Window", "ملاءمة الحيز للنافذة"), + ("Fit Page in Window", "ملاءمة الصفحة في النافذة"), + ("Fit Spread in Window", "ملاءمة الصفحة المزدوجة في النافذة"), ("Actual Size", "الحجم الفعلي"), ("Screen Mode", "وضع الشاشة"), ("Normal", "عادي"), @@ -125,13 +128,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Workspace", "مساحة العمل"), ("New Workspace", "مساحة عمل جديدة"), ("Properties", "خصائص"), - ("Layers", "طبقات"), + ("Layers", "الطبقات"), ("Swatches", "عينات الألوان"), ("Colour", "لون"), ("Color", "لون"), ("Stroke", "حد"), ("Fill", "تعبئة"), - ("Gradient", "تدرج"), + ("Gradient", "التدرج اللوني"), ("Transparency", "شفافية"), ("Effects", "تأثيرات"), ("Links", "روابط"), @@ -171,9 +174,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Reset", "إعادة ضبط"), ("Add", "إضافة"), ("Remove", "إزالة"), - ("Name", "اسم"), - ("Width", "عرض"), - ("Height", "ارتفاع"), + ("Name", "الاسم"), + ("Width", "العرض"), + ("Height", "الارتفاع"), ("Portrait", "عمودي"), ("Landscape", "أفقي"), ("Orientation", "اتجاه الصفحة"), @@ -205,40 +208,40 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Text", "نص"), ("Image", "صورة"), ("Line", "خط مستقيم"), - ("Position", "موضع"), + ("Position", "الموضع"), ("Appearance", "مظهر"), - ("Opacity", "عتامة"), + ("Opacity", "العتامة"), ("Corner", "زاوية"), ("Rotation", "دوران"), ("Rulers & Grids", "مساطر وشبكات"), ("Guides", "أدلة"), ("Flex Layout", "تخطيط مرن"), ("Auto Size", "حجم تلقائي"), - ("Tracking", "تباعد الحروف"), - ("Leading", "تباعد الأسطر"), - ("Kerning", "تقارب الحروف"), + ("Tracking", "التتبع"), + ("Leading", "المسافة بين الأسطر"), + ("Kerning", "التقنين"), ("Language", "لغة"), ("Hyphenation", "وصل الكلمات"), - ("Alignment", "محاذاة"), + ("Alignment", "المحاذاة"), ("Indent", "مسافة بادئة"), - ("Space Before", "مسافة قبل"), - ("Space After", "مسافة بعد"), + ("Space Before", "المسافة قبل"), + ("Space After", "المسافة بعد"), ("New Layer", "طبقة جديدة"), ("New Swatch", "عينة لون جديدة"), ("Object Layer Options", "خيارات طبقات الكائن"), ("Colour Picker", "منتقي الألوان"), - ("Color Picker", "منتقي الألوان"), + ("Color Picker", "أداة اختيار الألوان"), ("Color Settings", "إعدادات الألوان"), ("User Dictionary", "قاموس المستخدم"), ("Menu Customization", "تخصيص القوائم"), ("Import Options", "خيارات الاستيراد"), - ("Place PDF", "إدراج PDF"), + ("Place PDF", "وضع PDF"), ("Find", "بحث"), - ("Change", "استبدال"), - ("Find What", "نص البحث"), - ("Change To", "استبدال بـ"), - ("Find Next", "بحث عن التالي"), - ("Change All", "استبدال الكل"), + ("Change", "تغيير"), + ("Find What", "البحث عن"), + ("Change To", "تغيير إلى"), + ("Find Next", "البحث عن التالي"), + ("Change All", "تغيير الكل"), ("Search", "بحث"), ("Document", "مستند"), ("Selection", "تحديد"), @@ -250,7 +253,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Off", "إيقاف"), ("None", "بدون"), ("[None]", "[بدون]"), - ("Place with Import Options…", "إدراج مع خيارات الاستيراد…"), + ("Place with Import Options…", "وضع مع خيارات الاستيراد…"), ("Print Booklet…", "طباعة كتيب…"), ("Export", "تصدير"), ("Export Page as PNG…", "تصدير الصفحة بصيغة PNG…"), @@ -261,7 +264,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Export HTML…", "تصدير HTML…"), ("Export XML…", "تصدير XML…"), ("Export Text…", "تصدير النص…"), - ("Quick Actions", "إجراءات سريعة"), + ("Quick Actions", "الإجراءات السريعة"), ("Import File", "استيراد ملف"), ("Insert Page", "إدراج صفحة"), ("Edit Page", "تحرير الصفحة"), @@ -271,7 +274,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Show Document Grid", "إظهار شبكة المستند"), ("Show Baseline Grid", "إظهار شبكة خطوط الأساس"), ("Options", "خيارات"), - ("More Options", "خيارات إضافية"), + ("More Options", "خيارات أكثر"), ("Dialog", "مربع حوار"), ("General", "عام"), ("Basic Character Formats", "تنسيقات الأحرف الأساسية"), @@ -290,9 +293,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Footnote Formatting", "تنسيق الحواشي"), ("Span Footnotes Across Columns", "امتداد الحواشي عبر الأعمدة"), ("Rule On", "إظهار الخط"), - ("Ascent", "ارتفاع الحروف الصاعدة"), + ("Ascent", "الصعود"), ("Cap Height", "ارتفاع الأحرف الكبيرة"), - ("x Height", "ارتفاع حرف x"), + ("x Height", "ارتفاع x"), ("Fixed", "ثابت"), ("Never (continuous)", "أبدًا (ترقيم متصل)"), ("Spread", "صفحتان متقابلتان"), @@ -320,19 +323,19 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("chars", "محارف محددة"), ("[Automatic]", "[تلقائي]"), ("Size:", "الحجم:"), - ("Tracking:", "التباعد:"), + ("Tracking:", "التتبع:"), ("Left Indent:", "المسافة البادئة اليسرى:"), ("Right Indent:", "المسافة البادئة اليمنى:"), ("First Line Indent:", "المسافة البادئة للسطر الأول:"), - ("Space Before:", "المسافة قبل الفقرة:"), - ("Space After:", "المسافة بعد الفقرة:"), + ("Space Before:", "المسافة قبل:"), + ("Space After:", "المسافة بعد:"), ("Words with at Least:", "الحد الأدنى لطول الكلمة:"), ("After First:", "الأحرف قبل الواصلة:"), ("Before Last:", "الأحرف بعد الواصلة:"), - ("Hyphen Limit:", "حد الأسطر المتتالية بواصلة:"), + ("Hyphen Limit:", "الحد الأقصى للواصلات:"), ("Word Spacing:", "تباعد الكلمات:"), ("Letter Spacing:", "تباعد الحروف:"), - ("Glyph Scaling:", "تحجيم الحروف:"), + ("Glyph Scaling:", "قياس الحروف الرسومية:"), ("Insert Kashidas:", "إدراج الكشيدة:"), ("In justified Arabic text", "في النص العربي المضبوط"), ("Minimum Space Before First Footnote:", "أقل مسافة قبل الحاشية الأولى:"), @@ -349,7 +352,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Prefix:", "البادئة:"), ("Suffix:", "اللاحقة:"), ("Position:", "الموضع:"), - ("Character Style:", "نمط الأحرف:"), + ("Character Style:", "نمط الحروف:"), ("Paragraph Style:", "نمط الفقرة:"), ("Separator:", "الفاصل:"), ("EPUB and HTML", "EPUB وHTML"), @@ -365,17 +368,17 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("New GREP Style", "نمط GREP جديد"), ("Font Family:", "عائلة الخط:"), ("Font Style:", "نمط الخط:"), - ("Leading:", "تباعد الأسطر:"), + ("Leading:", "المسافة بين الأسطر:"), ("Alignment:", "المحاذاة:"), ("Hyphenate", "إضافة واصلات"), ("Minimum", "الحد الأدنى"), - ("Desired", "المطلوب"), + ("Desired", "المرغوب"), ("Maximum", "الحد الأقصى"), ("Composer:", "منضّد النص:"), ("Paragraph Composer", "منضّد الفقرات"), ("Single-line Composer", "منضّد السطر الواحد"), ("Style Name:", "اسم النمط:"), - ("Based On:", "استنادًا إلى:"), + ("Based On:", "مبني على:"), ("for", "لعدد"), ("Hyphenation Exceptions", "استثناءات الواصلة"), ("Binding:", "اتجاه التجليد:"), @@ -427,9 +430,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Reject", "رفض"), ("Add Selection", "إضافة التحديد"), ("Add the selection", "إضافة التحديد"), - ("Add to Swatches", "إضافة إلى الحوامل"), + ("Add to Swatches", "إضافة إلى عينات الألوان"), ("Align Stroke:", "محاذاة الحد:"), - ("All Caps", "أحرف كبيرة"), + ("All Caps", "جميع الأحرف كبيرة"), ("Angle", "الزاوية"), ("Angle:", "الزاوية:"), ("Baseline", "خط الأساس"), @@ -449,7 +452,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Distance", "المسافة"), ("Distribute ↔", "توزيع أفقي"), ("Distribute ↕", "توزيع عمودي"), - ("Drop Shadow", "ظل ساقط"), + ("Drop Shadow", "إسقاط الظل"), ("Effects: Drop Shadow", "التأثيرات: ظل ساقط"), ("Favorites", "المفضلة"), ("First Spread", "أول صفحتين متقابلتين"), @@ -464,7 +467,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Last Spread", "آخر صفحتين متقابلتين"), ("Linked Stories", "النصوص المرتبطة"), ("Load DTD…", "تحميل DTD…"), - ("Location:", "الموضع:"), + ("Location:", "الموقع:"), ("Miter Limit:", "حد الوصلة الحادة:"), ("Move", "نقل"), ("New Article from Selection", "مقال جديد من التحديد"), @@ -479,7 +482,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Open", "فتح"), ("Paragraph alignment", "محاذاة الفقرة"), ("Pick a file in the folder holding the moved graphics", "اختر ملفًا من المجلد الذي يحتوي على الرسومات المنقولة"), - ("Place", "إدراج"), + ("Place", "وضع"), ("Previous Spread", "الصفحتان السابقتان"), ("Release to Objects", "تحرير إلى كائنات"), ("Reverse the gradient", "عكس التدرج"), @@ -500,13 +503,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Show Favorites Only", "إظهار المفضلة فقط"), ("Show in the text", "إظهار في النص"), ("Spreads of up to 10 pages", "مجموعات تصل إلى 10 صفحات"), - ("Stop Color:", "لون نقطة التدرج:"), + ("Stop Color:", "لون الإيقاف:"), ("Strikethrough", "يتوسطه خط"), ("Structure", "البنية"), ("Style", "النمط"), ("Style source", "مصدر الأنماط"), ("Styles panel", "لوحة الأنماط"), - ("Superscript", "مرتفع"), + ("Superscript", "النص المرتفع"), ("Tag the selection", "إضافة وسم للتحديد"), ("The structure is valid.", "البنية صالحة."), ("Toggle double column", "تبديل التخطيط بعمودين"), @@ -542,7 +545,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Bulleted List", "قائمة نقطية"), ("Numbered List", "قائمة مرقمة"), ("UPPERCASE", "أحرف كبيرة"), - ("Small Caps", "أحرف كبيرة صغيرة الحجم"), + ("Small Caps", "الأحرف الكبيرة المصغّرة"), ("Butt", "مسطحة"), ("Round", "مستديرة"), ("Projecting", "بارزة"), @@ -554,10 +557,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Last", "الأخير"), ("Before", "قبل"), ("After", "بعد"), - ("Shading", "تظليل"), + ("Shading", "التظليل"), ("Border", "إطار"), - ("Overprint Fill", "طباعة التعبئة فوقيًا"), - ("Overprint Stroke", "طباعة الحد فوقيًا"), + ("Overprint Fill", "طباعة فوقية للتعبئة"), + ("Overprint Stroke", "طباعة فوقية للحد"), ("Overprint Gap", "طباعة الفجوة فوقيًا"), ("Nonprinting", "غير قابل للطباعة"), ("At Least", "على الأقل"), @@ -598,7 +601,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Pin left", "تثبيت اليسار"), ("Pin right", "تثبيت اليمين"), ("Inward", "إلى الداخل"), - ("Metrics", "مقاييس"), + ("Metrics", "المقاييس"), ("Optical", "بصري"), ("Applies to fill", "يُطبّق على التعبئة"), ("Applies to text", "يُطبّق على النص"), @@ -718,25 +721,25 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("(Text Color)", "(لون النص)"), ("{count} glyphs — click to insert at the text cursor", "عدد المحارف: {count} — انقر للإدراج عند مؤشر النص"), ("{pages} Pages in {spreads} Spreads", "عدد الصفحات: {pages}، المجموعات: {spreads}"), - ("Text Frame", "إطار نص"), + ("Text Frame", "إطار النص"), ("Text Style", "نمط النص"), (" (auto)", " (تلقائي)"), ("Absolute Numbering (instead of Section Numbering)", "ترقيم مطلق بدلًا من ترقيم الأقسام"), ("Add and remove pages as the primary text frame's story grows and shrinks", "إضافة الصفحات وحذفها مع زيادة نص الإطار الرئيسي أو نقصانه"), ("Added Text (highlighted)", "النص المضاف (مميز)"), ("Adjust Layout (objects follow the new page size)", "ضبط التخطيط وفق حجم الصفحة الجديد"), - ("Auto-Fit", "ملاءمة تلقائية"), + ("Auto-Fit", "الملاءمة التلقائية"), ("Case sensitive", "مراعاة حالة الأحرف"), - ("Custom Tracking/Kerning", "تباعد أحرف وأزواج مخصص"), + ("Custom Tracking/Kerning", "التتبع/التقنين المخصص"), ("Dimensions Include Stroke Weight", "تضمين سماكة الحد في الأبعاد"), ("Enable Autocorrect", "تمكين التصحيح التلقائي"), ("Enable Dynamic Spelling (underline misspelled words)", "تمكين التدقيق الإملائي التلقائي (تسطير الأخطاء)"), ("Grids in Back", "الشبكات في الخلف"), - ("H&J Violations", "مخالفات الواصلة والضبط"), + ("H&J Violations", "انتهاكات H&J"), ("Include Bleed", "تضمين النزف"), ("Include Stroke Weight", "تضمين سماكة الحد"), ("Keep Violations", "مخالفات خيارات إبقاء النص"), - ("Overprint [Black] Swatch at 100%", "طباعة حامل الأسود بنسبة 100% فوقيًا"), + ("Overprint [Black] Swatch at 100%", "طباعة عينة اللون [الأسود] بنسبة 100% فوقيًا"), ("Printer's Marks", "علامات الطباعة"), ("Remove Styles and Formatting from Text and Tables", "إزالة الأنماط والتنسيق من النص والجداول"), ("Ruby", "روبي"), @@ -753,12 +756,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Accept All", "قبول الكل"), ("Add Document…", "إضافة مستند…"), ("Add to Previous Spread", "إضافة إلى الصفحات المتقابلة السابقة"), - ("Align From:", "المحاذاة من:"), + ("Align From:", "محاذاة من:"), ("Align to baseline grid", "محاذاة إلى شبكة خطوط الأساس"), ("All Spots to Process", "تحويل كل الألوان الموضعية إلى ألوان معالجة"), ("Allow Spread to Shuffle", "السماح بإعادة ترتيب الصفحات المتقابلة"), ("Alternating Row Fills", "تعبئة الصفوف بالتناوب"), - ("Apply Gradient", "تطبيق تدرج"), + ("Apply Gradient", "تطبيق التدرج اللوني"), ("Apply None (/)", "تطبيق بلا لون (/)"), ("Apply Parent", "تطبيق الصفحة الأصلية"), ("Apply to All Spreads", "تطبيق على كل الصفحات المتقابلة"), @@ -771,9 +774,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Bullets and Numbering", "التعداد النقطي والرقمي"), ("Business Card", "بطاقة عمل"), ("Buttons act in interactive PDF export.", "تعمل الأزرار عند التصدير إلى PDF تفاعلي."), - ("Change to:", "التغيير إلى:"), + ("Change to:", "تغيير إلى:"), ("Character Settings", "إعدادات الأحرف"), - ("Characters", "أحرف"), + ("Characters", "الأحرف"), ("Checked by default", "محدد افتراضيًا"), ("Choices", "الخيارات"), ("Choose Image…", "اختيار صورة…"), @@ -795,7 +798,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Delete Column", "حذف العمود"), ("Delete Condition", "حذف الشرط"), ("Delete Row", "حذف الصف"), - ("Delete Swatch", "حذف حامل اللون"), + ("Delete Swatch", "حذف عينة الألوان"), ("Delete Unused Layers", "حذف الطبقات غير المستخدمة"), ("Delete Workspace", "حذف مساحة العمل"), ("Document Grid", "شبكة المستند"), @@ -818,7 +821,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Height:", "الارتفاع:"), ("Hide Bar", "إخفاء الشريط"), ("High Quality", "جودة عالية"), - ("Highlight", "تمييز"), + ("Highlight", "الإبراز"), ("Horizontal Gridline Every:", "تباعد خطوط الشبكة الأفقية:"), ("Horizontal Margins:", "الهوامش الأفقية:"), ("Import XML…", "استيراد XML…"), @@ -835,12 +838,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Link To:", "الربط إلى:"), ("Liquid guides", "أدلة التخطيط المرن"), ("Load Profile…", "تحميل ملف تعريف…"), - ("Load Swatches…", "تحميل حوامل الألوان…"), + ("Load Swatches…", "تحميل عينات الألوان…"), ("Load…", "تحميل…"), ("Media", "وسائط"), ("Merge into Active Layer", "دمج في الطبقة النشطة"), ("Misspelled word → Correction (one per line):", "كلمة خاطئة ← تصحيح (واحد في كل سطر):"), - ("Mobile", "جهاز محمول"), + ("Mobile", "جوال"), ("More ArtCraft apps", "المزيد من تطبيقات ArtCraft"), ("Move Selection Here", "نقل التحديد إلى هنا"), ("Move to Color Group", "نقل إلى مجموعة ألوان"), @@ -851,7 +854,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("New Bookmark for This Page", "إشارة مرجعية جديدة لهذه الصفحة"), ("New Book…", "كتاب جديد…"), ("New Color Group", "مجموعة ألوان جديدة"), - ("New Color Group with Swatch", "مجموعة ألوان جديدة من الحامل"), + ("New Color Group with Swatch", "مجموعة ألوان جديدة من عينة اللون"), ("New Library…", "مكتبة جديدة…"), ("New Workspace…", "مساحة عمل جديدة…"), ("No articles: exports follow page order.", "لا توجد مقالات؛ يتبع التصدير ترتيب الصفحات."), @@ -897,14 +900,14 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Relink…", "إعادة الربط…"), ("Remove Conditions from Selection", "إزالة الشروط عن التحديد"), ("Repeat Header Rows", "تكرار صفوف الرأس"), - ("Replace With", "استبدال بـ"), + ("Replace With", "الاستبدال بـ"), ("Required", "مطلوب"), ("Reset All to Defaults", "إعادة تعيين الكل إلى الافتراضي"), ("Reverse", "عكس"), ("Right-click: stroke, groups", "نقرة يمنى: الحد والمجموعات"), ("Rotate 90° Clockwise", "تدوير 90° مع عقارب الساعة"), ("Ruler Units", "وحدات المسطرة"), - ("Save Swatches for Exchange…", "حفظ الحوامل للتبادل…"), + ("Save Swatches for Exchange…", "حفظ عينات الألوان للتبادل…"), ("Save recovery data every:", "حفظ بيانات الاسترداد كل:"), ("Saves which bars show and where the panels are.", "يحفظ الأشرطة الظاهرة ومواضع اللوحات."), ("Search:", "البحث:"), @@ -920,14 +923,14 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Select two or more objects (or a group) to make a multi-state object.", "حدد كائنين أو أكثر (أو مجموعة) لإنشاء كائن متعدد الحالات."), ("Show Tag Markers", "إظهار علامات الوسوم"), ("Single-line", "سطر واحد"), - ("Smart Text Reflow", "إعادة تدفق النص الذكية"), + ("Smart Text Reflow", "التدفق الذكي للنص"), ("Speed", "السرعة"), ("Star Inset:", "تقعر النجمة:"), ("Start Page #:", "رقم صفحة البداية:"), ("Start:", "البداية:"), ("Status", "الحالة"), ("Stroke (X)", "حد (X)"), - ("Style Mapping", "مطابقة الأنماط"), + ("Style Mapping", "ربط الأنماط"), ("Style Name Conflicts", "تعارض أسماء الأنماط"), ("Stylistic Sets", "المجموعات الأسلوبية"), ("Subdivisions:", "التقسيمات الفرعية:"), @@ -967,8 +970,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("⌥-click clears overrides", "النقر مع ⌥ يمسح التجاوزات"), ("⚠ out of date", "⚠ غير محدّث"), ("Interface", "الواجهة"), - ("Advanced Type", "إعدادات النص المتقدمة"), - ("Composition", "تنضيد النص"), + ("Advanced Type", "كتابة متقدمة"), + ("Composition", "التركيب"), ("Units & Increments", "الوحدات والزيادات"), ("Grids", "الشبكات"), ("Guides & Pasteboard", "الأدلة ولوحة اللصق"), @@ -976,7 +979,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Spelling", "التدقيق الإملائي"), ("Autocorrect", "التصحيح التلقائي"), ("Track Changes", "تعقب التغييرات"), - ("Story Editor Display", "عرض محرر النص"), + ("Story Editor Display", "عرض محرر القصة"), ("Display Performance", "أداء العرض"), ("Appearance of Black", "مظهر الأسود"), ("File Handling", "معالجة الملفات"), @@ -993,7 +996,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Version", "الإصدار"), ("{count} characters don't fit", "عدد الأحرف الزائدة: {count}"), ("document", "المستند"), - ("story", "النص"), + ("story", "القصة"), ("selection", "التحديد"), ("top", "أعلى"), ("center", "وسط"), @@ -1009,4 +1012,403 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("No conflicts", "لا توجد تعارضات"), ("Page (1–{count}):", "الصفحة (1–{count}):"), ("Fonts in Document: {count} Missing: {missing}", "الخطوط في المستند: {count} المفقودة: {missing}"), + // Complete coverage of the shared interface translation table. + ("Story Direction", "اتجاه القصة"), + ("Tate-Chu-Yoko", "نص أفقي داخل النص العمودي"), + ("Ruby…", "روبي…"), + ("Kenten", "علامات توكيد"), + ("Tabs", "علامات الجدولة"), + ("Story", "القصة"), + ("Create Outlines", "إنشاء حدود خارجية"), + ("Show Hidden Characters", "إظهار الأحرف المخفية"), + ("Unmerge Cells", "إلغاء دمج الخلايا"), + ("Snap to Guides", "محاذاة إلى الأدلة"), + ("Snap to Document Grid", "محاذاة إلى شبكة المستند"), + ("Align to Object Edges", "محاذاة إلى حواف الكائنات"), + ("Align to Object Centers", "محاذاة إلى مراكز الكائنات"), + ("Smart Dimensions", "الأبعاد الذكية"), + ("Smart Spacing", "التباعد الذكي"), + ("Snap to Zone", "نطاق المحاذاة"), + ("Info", "معلومات"), + ("Index", "فهرس"), + ("Articles", "مقالات"), + ("Tags", "وسوم"), + ("Object States", "حالات الكائن"), + ("Liquid Layout", "التخطيط المرن"), + ("Split Window", "تقسيم النافذة"), + ("New Window", "نافذة جديدة"), + ("CMYK", "CMYK"), + ("Lab", "Lab"), + ("OpenType", "OpenType"), + ("RGB", "RGB"), + ("Accept All Changes", "قبول جميع التغييرات"), + ("Activate Document", "تنشيط المستند"), + ("Add Anchor Point", "إضافة نقطة ارتساء"), + ("Add Document", "إضافة مستند"), + ("Add Hyphenation Exception", "إضافة استثناء لتقسيم الكلمات"), + ("Add Item", "إضافة عنصر"), + ("Add Page to Spread", "إضافة صفحة إلى الصفحات المتقابلة"), + ("Allow Selected Spread to Shuffle", "السماح بإعادة ترتيب الصفحات المتقابلة المحددة"), + ("Anchored Object Options…", "خيارات الكائن المثبت…"), + ("Anchored Objects", "الكائنات المثبتة"), + ("Apply Attributes (Eyedropper)", "تطبيق السمات (القطارة)"), + ("Apply Cell Style", "تطبيق نمط الخلية"), + ("Apply Condition", "تطبيق شرط"), + ("Apply Parent to Pages…", "تطبيق الصفحة الأصلية على الصفحات…"), + ("ArtCraft App Page…", "صفحة تطبيق ArtCraft…"), + ("ArtCraft Website…", "موقع ArtCraft…"), + ("Attributes", "السمات"), + ("Bevel and Emboss", "القطع المشطوف والنقش البارز"), + ("Buttons", "أزرار"), + ("Cell Style Options…", "خيارات نمط الخلية…"), + ("Change All (Object)", "تغيير الكل (الكائن)"), + ("Changes", "تغييرات"), + ("Character Formatting", "تنسيق الأحرف"), + ("Clear Conveyor", "إفراغ ناقل المحتوى"), + ("Clear Transformations", "مسح التحويلات"), + ("Clipping Path…", "مسار القص…"), + ("Close Book", "إغلاق الكتاب"), + ("Close Library", "إغلاق المكتبة"), + ("Collect", "تجميع"), + ("Color Settings…", "إعدادات الألوان…"), + ("Community & Project Links", "روابط المجتمع والمشروع"), + ("Container", "حاوية"), + ("Content", "محتوى"), + ("Contextual Task Bar", "شريط المهام السياقي"), + ("Control", "التحكم"), + ("Convert Cell to Graphic Cell", "تحويل الخلية إلى خلية رسومية"), + ("Convert Cell to Graphic Cell…", "تحويل الخلية إلى خلية رسومية…"), + ("Convert Cell to Text Cell", "تحويل الخلية إلى خلية نصية"), + ("Convert Color", "تحويل اللون"), + ("Convert Direction Point", "تحويل نقطة الاتجاه"), + ("Convert Note to Text", "تحويل الملاحظة إلى نص"), + ("Convert Table to Text", "تحويل الجدول إلى نص"), + ("Convert Text to Table", "تحويل النص إلى جدول"), + ("Convert to Button", "تحويل إلى زر"), + ("Convert to Form Field", "تحويل إلى حقل نموذج"), + ("Copy Link(s) To…", "نسخ الروابط إلى…"), + ("Corner Options…", "خيارات الزوايا…"), + ("Create Alternate Layout…", "إنشاء تخطيط بديل…"), + ("Create Frame", "إنشاء إطار"), + ("Create Merged Document…", "إنشاء مستند مدمج…"), + ("Cross-Reference Formats", "تنسيقات المراجع المتقاطعة"), + ("Cross-References", "مراجع متقاطعة"), + ("Data Merge Fields", "حقول دمج البيانات"), + ("Define Cross-Reference Format…", "تعريف تنسيق المرجع المتقاطع…"), + ("Define Lists…", "تعريف القوائم…"), + ("Define Text Variable…", "تعريف متغير نصي…"), + ("Delete All Guides on Spread", "حذف جميع الأدلة على الصفحات المتقابلة"), + ("Delete Anchor Point", "حذف نقطة ارتساء"), + ("Delete Item", "حذف عنصر"), + ("Delete Text Variable", "حذف متغير نصي"), + ("Delete Workspace…", "حذف مساحة العمل…"), + ("DesignCraft Website…", "موقع DesignCraft…"), + ("DesignCraft on GitHub…", "DesignCraft على GitHub…"), + ("Detach All Objects from Parent", "فصل جميع الكائنات عن الصفحة الأصلية"), + ("Directional Feather", "الحواف المنعمة الاتجاهية"), + ("Distribute", "توزيع"), + ("Dock Panel", "إرساء اللوحة"), + ("Document Endnote Options…", "خيارات الحواشي الختامية للمستند…"), + ("Document Preferences", "تفضيلات المستند"), + ("Document Presets", "الإعدادات المسبقة للمستند"), + ("Document…", "مستند…"), + ("Edit Parents", "تحرير الصفحات الأصلية"), + ("Edit Story", "تحرير القصة"), + ("Endnotes", "حواشٍ ختامية"), + ("Entire Pasteboard", "لوحة اللصق بأكملها"), + ("Erase", "محو"), + ("Export Book to PDF…", "تصدير الكتاب إلى PDF…"), + ("Export EPUB (Reflowable)…", "تصدير EPUB (قابل لإعادة التدفق)…"), + ("Export Selection as Snippet", "تصدير التحديد كمقتطف"), + ("Extend Text Selection", "توسيع تحديد النص"), + ("Fast Display", "العرض السريع"), + ("Find Object", "بحث عن كائن"), + ("Find by Script Label", "بحث حسب تسمية البرنامج النصي"), + ("First Object Above", "أول كائن أعلى"), + ("Fit Selection in Window", "ملاءمة التحديد في النافذة"), + ("Flattener Preview", "معاينة التسطيح"), + ("Float Panel", "تعويم اللوحة"), + ("Fonts in Document", "الخطوط في المستند"), + ("Form Fields", "حقول النماذج"), + ("Frame Fitting Options…", "خيارات ملاءمة الإطار…"), + ("Generate Index…", "إنشاء فهرس…"), + ("Generate QR Code…", "إنشاء رمز QR…"), + ("Generate Static Caption", "إنشاء تعليق توضيحي ثابت"), + ("Get Story", "جلب القصة"), + ("Get Table", "جلب الجدول"), + ("Global Light…", "الإضاءة العامة…"), + ("Go To Link", "انتقال إلى الرابط"), + ("Go To Source", "انتقال إلى المصدر"), + ("Gradient Feather", "الحواف المنعمة المتدرجة"), + ("Gridify", "تقسيم إلى شبكة"), + ("Group Transparency", "شفافية المجموعة"), + ("Hide Menu Item", "إخفاء عنصر القائمة"), + ("Hide/Lock Others", "إخفاء/قفل العناصر الأخرى"), + ("High Quality Display", "عرض عالي الجودة"), + ("History", "سجل الإجراءات"), + ("Import Options: Styles", "خيارات الاستيراد: الأنماط"), + ("Index Page References", "مراجع صفحات الفهرس"), + ("Inner Glow", "التوهج الداخلي"), + ("Inner Shadow", "الظل الداخلي"), + ("Insert Anchored Object…", "إدراج كائن مثبت…"), + ("Insert Cross-Reference…", "إدراج مرجع متقاطع…"), + ("Insert Endnote", "إدراج حاشية ختامية"), + ("Insert Math Expression…", "إدراج تعبير رياضي…"), + ("Insert Pages…", "إدراج صفحات…"), + ("Insert Variable", "إدراج متغير"), + ("Inspect Document", "فحص المستند"), + ("Interface Color Theme", "نسق ألوان الواجهة"), + ("Join the ArtCraft Discord…", "الانضمام إلى Discord الخاص بـ ArtCraft…"), + ("Last Object Below", "آخر كائن أسفل"), + ("Library Contents", "محتويات المكتبة"), + ("Library Items", "عناصر المكتبة"), + ("Liquid Page Rule", "قاعدة الصفحة المرنة"), + ("List Documents", "سرد المستندات"), + ("List Styles", "سرد الأنماط"), + ("List Tools", "سرد الأدوات"), + ("Load Place Cursor", "تحميل مؤشر الإدراج"), + ("Make Compound Path", "إنشاء مسار مركب"), + ("Map Styles to Tags", "ربط الأنماط بالوسوم"), + ("Math Expression SVG", "تعبير رياضي بصيغة SVG"), + ("Media Options", "خيارات الوسائط"), + ("Menus…", "القوائم…"), + ("Merge Layers", "دمج الطبقات"), + ("Move Caret", "تحريك مؤشر النص"), + ("Move Gap", "تحريك الفجوة"), + ("Move Row", "نقل صف"), + ("New Book", "كتاب جديد"), + ("New Cell Style…", "نمط خلية جديد…"), + ("New Color Swatch…", "عينة لون جديدة…"), + ("New Condition…", "شرط جديد…"), + ("New Guide", "دليل جديد"), + ("New Hyperlink Destination…", "وجهة ارتباط تشعبي جديدة…"), + ("New Hyperlink…", "ارتباط تشعبي جديد…"), + ("New Library", "مكتبة جديدة"), + ("New Page Reference…", "مرجع صفحة جديد…"), + ("New Stroke Style…", "نمط حد جديد…"), + ("New Table Style…", "نمط جدول جديد…"), + ("Next Cell", "الخلية التالية"), + ("Next Document", "المستند التالي"), + ("Next Object Above", "الكائن التالي أعلى"), + ("Next Object Below", "الكائن التالي أسفل"), + ("Next Object in Group", "الكائن التالي في المجموعة"), + ("Object Export Options", "خيارات تصدير الكائن"), + ("Object Export Options…", "خيارات تصدير الكائن…"), + ("Object Layer Options…", "خيارات طبقات الكائن…"), + ("Object Layers", "طبقات الكائن"), + ("Object Liquid Settings", "إعدادات الكائن المرن"), + ("Open Book", "فتح كتاب"), + ("Open Bytes", "فتح بيانات بايت"), + ("Open IDML", "فتح IDML"), + ("Open Library", "فتح مكتبة"), + ("Outer Glow", "التوهج الخارجي"), + ("Overprint Preview", "معاينة الطباعة الفوقية"), + ("Override All Parent Page Items", "تجاوز جميع عناصر الصفحة الأصلية"), + ("Page Transition", "انتقال الصفحة"), + ("Page Transitions", "انتقالات الصفحات"), + ("Paragraph Formatting", "تنسيق الفقرات"), + ("Paste Into", "لصق داخل"), + ("Paste without Formatting", "لصق دون تنسيق"), + ("Place Caret", "وضع مؤشر النص"), + ("Place Item", "إدراج عنصر"), + ("Place Snippet", "وضع مقتطف"), + ("Place and Link", "وضع وربط"), + ("Polygon Settings…", "إعدادات المضلع…"), + ("Preflight Document", "فحص المستند قبل الطباعة"), + ("Previous Cell", "الخلية السابقة"), + ("Previous Document", "المستند السابق"), + ("Previous Object in Group", "الكائن السابق في المجموعة"), + ("Printers", "طابعات"), + ("Proof Colors", "ألوان المعاينة الطباعية"), + ("Proof Setup", "إعداد المعاينة الطباعية"), + ("Quick Apply…", "تطبيق سريع…"), + ("Recover Documents", "استعادة المستندات"), + ("Recovery Data", "بيانات الاستعادة"), + ("Reject All Changes", "رفض جميع التغييرات"), + ("Release Compound Path", "فك المسار المركب"), + ("Remove All Local Overrides", "إزالة جميع التجاوزات المحلية"), + ("Remove Document", "إزالة المستند"), + ("Report an Issue…", "الإبلاغ عن مشكلة…"), + ("Reset Shortcuts", "إعادة تعيين الاختصارات"), + ("Reset Workspace", "إعادة تعيين مساحة العمل"), + ("Resize", "تغيير الحجم"), + ("Rotate Spread", "تدوير الصفحات المتقابلة"), + ("Run Script", "تشغيل برنامج نصي"), + ("Sample Document", "مستند نموذجي"), + ("Satin", "الساتان"), + ("Save Recovery Data", "حفظ بيانات الاستعادة"), + ("Script Label", "تسمية البرنامج النصي"), + ("Select All on Layer", "تحديد الكل في الطبقة"), + ("Select Cells", "تحديد خلايا"), + ("Select Column", "تحديد عمود"), + ("Select Frame", "تحديد إطار"), + ("Select Row", "تحديد صف"), + ("Select Table", "تحديد جدول"), + ("Select Text", "تحديد نص"), + ("Select Tool", "اختيار أداة"), + ("Select Word", "تحديد كلمة"), + ("Selection Attributes", "سمات التحديد"), + ("Separations Preview", "معاينة فصل الألوان"), + ("Serialize", "تحويل إلى صيغة متسلسلة"), + ("Set Active Layer", "تعيين الطبقة النشطة"), + ("Set Shortcut", "تعيين اختصار"), + ("Set Visibility / Lock", "تعيين الظهور / القفل"), + ("Show Panel", "إظهار اللوحة"), + ("Show/Hide Baseline Grid", "إظهار/إخفاء شبكة خط الأساس"), + ("Show/Hide Frame Edges", "إظهار/إخفاء حواف الإطارات"), + ("Show/Hide Guides", "إظهار/إخفاء الأدلة"), + ("Show/Hide Hidden Characters", "إظهار/إخفاء الأحرف المخفية"), + ("Show/Hide Panels", "إظهار/إخفاء اللوحات"), + ("Show/Hide Panels Except Tools", "إظهار/إخفاء اللوحات باستثناء الأدوات"), + ("Show/Hide Rulers", "إظهار/إخفاء المساطر"), + ("Show/Hide Tagged Frames", "إظهار/إخفاء الإطارات الموسومة"), + ("Show/Hide Text Threads", "إظهار/إخفاء روابط الإطارات النصية"), + ("Smooth", "تنعيم"), + ("Sort", "فرز"), + ("Split Path", "تقسيم المسار"), + ("Step and Repeat…", "خطوة وتكرار…"), + ("Stroke Styles", "أنماط الحدود"), + ("Style Source", "مصدر الأنماط"), + ("Synchronize Book", "مزامنة الكتاب"), + ("Table Panel", "لوحة الجدول"), + ("Table Style Options…", "خيارات نمط الجدول…"), + ("Table of Contents Entries", "مدخلات جدول المحتويات"), + ("Table of Contents…", "جدول المحتويات…"), + ("Text Anchors", "نقاط ارتساء النص"), + ("Text Variables", "متغيرات النص"), + ("Toggle Normal/Preview", "تبديل العرض العادي/المعاينة"), + ("Toggle Selection", "تبديل التحديد"), + ("Tools: Double Column", "الأدوات: عمودان"), + ("Transform Panel", "لوحة التحويل"), + ("Transform Values", "قيم التحويل"), + ("Transparency Blend Space", "مساحة مزج الشفافية"), + ("Transparency Flattener Presets", "إعدادات تسطيح الشفافية المسبقة"), + ("Type on a Path", "الكتابة على مسار"), + ("Type on a Path Options…", "خيارات الكتابة على مسار…"), + ("Typical Display", "عرض قياسي"), + ("Update Link", "تحديث الرابط"), + ("User Dictionary Words", "كلمات قاموس المستخدم"), + ("User Dictionary…", "قاموس المستخدم…"), + ("Zoom To", "تكبير إلى"), + ("⇧", "⇧"), + ("⌃", "⌃"), + ("⌘", "⌘"), + ("⌥", "⌥"), + ("Accept Change", "قبول التغيير"), + ("Add Anchor", "إضافة نقطة ارتساء"), + ("Add Selection to Article", "إضافة التحديد إلى المقالة"), + ("Add Unnamed Colors", "إضافة ألوان غير مُسماة"), + ("Add to Dictionary", "إضافة إلى القاموس"), + ("Align Center", "محاذاة إلى الوسط"), + ("Align Left", "محاذاة إلى اليسار"), + ("Align Right", "محاذاة إلى اليمين"), + ("Apply Character Style", "تطبيق نمط الحروف"), + ("Apply Object Style", "تطبيق نمط الكائن"), + ("Apply Paragraph Style", "تطبيق نمط الفقرة"), + ("Apply Table Style", "تطبيق نمط الجدول"), + ("Article Options", "خيارات المقالة"), + ("Basic Feather", "الحواف المنعمة الأساسية"), + ("Bold", "عريض"), + ("Character Style Options…", "خيارات نمط الأحرف…"), + ("Close Path", "إغلاق المسار"), + ("Color Group Options", "خيارات مجموعة الألوان"), + ("Condition Options…", "خيارات الشرط…"), + ("Convert to Liquid Guide", "تحويل إلى دليل مرن"), + ("Convert to Object", "تحويل إلى كائن"), + ("Create Line", "إنشاء خط"), + ("Create Path", "إنشاء مسار"), + ("Decrease Point Size", "تقليل حجم الخط"), + ("Delete Article", "حذف مقالة"), + ("Delete Bookmark", "حذف إشارة مرجعية"), + ("Delete Endnote", "حذف حاشية ختامية"), + ("Delete Footnote", "حذف حاشية سفلية"), + ("Delete Guide", "حذف دليل"), + ("Delete Hyperlink", "حذف ارتباط تشعبي"), + ("Delete List", "حذف قائمة"), + ("Delete Note", "حذف ملاحظة"), + ("Delete Pages", "حذف صفحات"), + ("Delete Paragraph Style", "حذف نمط فقرة"), + ("Delete Stroke Style", "حذف نمط حد"), + ("Delete Table", "حذف جدول"), + ("Delete Tag", "حذف وسم"), + ("Delete Text", "حذف نص"), + ("Distribute Columns Evenly", "توزيع الأعمدة بالتساوي"), + ("Edit Endnote", "تحرير الحاشية الختامية"), + ("Edit Note", "تحرير الملاحظة"), + ("Embed Link", "تضمين الرابط"), + ("Flip", "قلب"), + ("Hyperlink Options…", "خيارات الارتباط التشعبي…"), + ("Increase Point Size", "زيادة حجم الخط"), + ("Insert Column Left", "إدراج عمود إلى اليسار"), + ("Insert Column Right", "إدراج عمود إلى اليمين"), + ("Insert Row Above", "إدراج صف أعلى"), + ("Insert Row Below", "إدراج صف أسفل"), + ("Italic", "مائل"), + ("Justify Left", "ضبط مع محاذاة السطر الأخير إلى اليسار"), + ("Layer Options…", "خيارات الطبقة…"), + ("Move Anchors", "تحريك نقاط الارتساء"), + ("Move Column", "نقل عمود"), + ("Move Guide", "تحريك دليل"), + ("Move Layer", "نقل طبقة"), + ("Move Pages…", "نقل صفحات…"), + ("Move to Layer", "نقل إلى طبقة"), + ("New Article", "مقالة جديدة"), + ("New Bookmark", "إشارة مرجعية جديدة"), + ("New Layer…", "طبقة جديدة…"), + ("New Note", "ملاحظة جديدة"), + ("New Object Style…", "نمط كائن جديد…"), + ("New Parent…", "صفحة أصلية جديدة…"), + ("New Tag", "وسم جديد"), + ("Paste in Place", "لصق في الموضع نفسه"), + ("Place Loaded Graphic", "وضع الرسم المحمل"), + ("Reject Change", "رفض التغيير"), + ("Release State to Objects", "تحويل الحالة إلى كائنات"), + ("Remove Hyphenation Exception", "إزالة استثناء لتقسيم الكلمات"), + ("Remove from Article", "إزالة من المقالة"), + ("Rename", "إعادة تسمية"), + ("Rename Bookmark", "إعادة تسمية الإشارة المرجعية"), + ("Rotate", "تدوير"), + ("Scale", "تحجيم"), + ("Set Cross-Reference Format", "تعيين تنسيق المرجع المتقاطع"), + ("Set Footnote Text", "تعيين نص الحاشية السفلية"), + ("Set Story Text", "تعيين نص القصة"), + ("Shear", "إمالة"), + ("Show All on Spread", "إظهار الكل على الصفحات المتقابلة"), + ("Show State", "إظهار الحالة"), + ("State Options", "خيارات الحالة"), + ("Tag Frame", "وسم الإطار"), + ("Tag Text", "وسم النص"), + ("Unlink", "فك الارتباط"), + ("Update Index", "تحديث الفهرس"), + ("Update Table of Contents", "تحديث جدول المحتويات"), + ("Bulleted and Numbered Lists", "قوائم نقطية ومرقمة"), + ("Hyperlinks Cross-References", "الارتباطات التشعبية والمراجع المتقاطعة"), + ("Document Footnote Options...", "خيارات الحواشي السفلية للمستند..."), + ("Anchored Object", "كائن مثبت"), + ("Apply Attributes (Eyedropper", "تطبيق السمات (القطارة"), + ("Captions", "تعليقات توضيحية"), + ("Change All (Object", "تغيير الكل (الكائن"), + ("Clipping Path", "مسار القص"), + ( + "Collect objects with the Content Collector (B); place them with the Content Placer.", + "اجمع الكائنات بأداة تجميع المحتوى (B)؛ ثم أدرجها بأداة إدراج المحتوى.", + ), + ("Convert Cell Type", "تحويل نوع الخلية"), + ("Copy Link(s", "نسخ الروابط"), + ("DesignCraft", "DesignCraft"), + ("Export EPUB (Fixed Layout", "تصدير EPUB (تخطيط ثابت"), + ("Export EPUB (Reflowable", "تصدير EPUB (قابل لإعادة التدفق"), + ("Hyperlinks & Cross-References", "الارتباطات التشعبية والمراجع المتقاطعة"), + ("Hyphens and Dashes", "الواصلات والشرطات"), + ("Markers", "علامات"), + ("Other", "أخرى"), + ("Output", "الإخراج"), + ("Quotation Marks", "علامات الاقتباس"), + ("Symbols", "رموز"), + ("Type & Tables", "النص والجداول"), + ( + "Type a word with ~ at each allowed break (ex~am~ple); without ~ the word is never hyphenated.", + "اكتب كلمة وضع ~ عند كل موضع مسموح لتقسيمها (ex~am~ple)؛ دون ~ لن تُقسّم الكلمة أبدًا.", + ), + ("Utilities", "أدوات مساعدة"), ]; From 0f09d48a7c2a087bf61ff22725129f0f7f15253b Mon Sep 17 00:00:00 2001 From: Shao Wenbin <shaowenbin.salih@gmail.com> Date: Wed, 7 Oct 2026 19:15:15 +0800 Subject: [PATCH 07/13] Adjust all UI translation according to InDesign UI --- ROADMAP.md | 2 +- crates/ui-egui/src/i18n.rs | 148 ++-- docs/localization-terminology.json | 1241 ++++++++++++++++++++++++++++ 3 files changed, 1314 insertions(+), 77 deletions(-) create mode 100644 docs/localization-terminology.json diff --git a/ROADMAP.md b/ROADMAP.md index ec17b7119..75447f470 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -97,7 +97,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Transparency flattener (`file.exportPdf {flatten: high|medium|low|ppi}`, Edit ▸ Transparency Flattener Presets): each object involving transparency becomes an opaque image of its area over what's beneath it, so everything else stays vector (spreads with transparent parent items are rasterised whole). - Video and sound (place .mp4/.mov/.webm/.mp3/.wav…, `media.options/get`, Media panel): media frames show a poster or placeholder on screen, in print and in PDF, and export as HTML5 `<video>`/`<audio>` in EPUB. Interactive PDF export (`file.exportPdf {media: true}`, File ▸ Export PDF (Interactive)) embeds each file and plays it in a Screen annotation through a rendition action (controls, loop, play on page load). No in-app playback. - Right-to-left text (World-Ready basics): right-to-left runs are shaped right to left, lines are reordered by the Unicode Bidirectional Algorithm (matched against the reference implementation), Paragraph Direction (`type.para {direction}`, Paragraph panel, IDML ParagraphDirection, EPUB `dir`). Digits (`digits`: Default, Arabic, Hindi, Farsi, Native by language; Character panel for right-to-left text; IDML DigitsType) draw 0–9 in another script. Kashidas (`kashidas` paragraph attribute, on by default): justified Arabic lines stretch at one join per word (after seen/sad when possible, never inside lam–alef) with a drawn tatweel before word spaces widen. Carets follow the drawing: the caret before a right-to-left glyph sits at its right edge, arrow keys move as drawn (by words in the line's direction), clicks find the nearest drawn caret position and selections highlight the selected glyphs. Binding: Right to Left (Document Setup, `layout.documentSetup {binding}`, IDML PageBinding) makes page 1 a left page and lays spreads out right to left. -- Interface language (Edit ▸ Interface Language, `app.language`): German, French, Spanish, Japanese, Simplified Chinese and Arabic menu titles, common menu items and panel names (our own translations). Common dialog, panel and tooltip labels are also localized, with English fallback for untranslated strings. The Type menu's Story Direction, Horizontal/Vertical, Tate-Chu-Yoko, Ruby… and Kenten are translated too. Arabic labels use Unicode bidi run ordering and contextual shaping; welcome and new-document form layouts follow RTL direction independently of document binding. CJK and Arabic glyphs come from [craft-fonts](https://github.com/storytold/craft-fonts) when built with `CRAFT_FONTS_DIR` (all releases): BIZ UDPGothic and Noto Sans CJK SC for the UI (Chinese forms first in the Chinese interface), Noto Sans Arabic, and Mincho for Japanese documents (the web build embeds BIZ UDPGothic only); otherwise from system fonts. +- Interface language (Edit ▸ Interface Language, `app.language`): German, French, Spanish, Japanese, Simplified Chinese and Arabic menu titles, common menu items and panel names (our own translations). Common dialog, panel and tooltip labels are also localized, with English fallback for untranslated strings. Arabic translations cover every key in the shared interface translation table, including menu commands and utility labels. Chinese and Arabic feature terminology is checked against public Adobe help where verified; [terminology sources](docs/localization-terminology.json) distinguish documented terms from related-label adaptations. The Type menu's Story Direction, Horizontal/Vertical, Tate-Chu-Yoko, Ruby… and Kenten are translated too. Arabic labels use Unicode bidi run ordering and contextual shaping; welcome and new-document form layouts follow RTL direction independently of document binding. CJK and Arabic glyphs come from [craft-fonts](https://github.com/storytold/craft-fonts) when built with `CRAFT_FONTS_DIR` (all releases): BIZ UDPGothic and Noto Sans CJK SC for the UI (Chinese forms first in the Chinese interface), Noto Sans Arabic, and Mincho for Japanese documents (the web build embeds BIZ UDPGothic only); otherwise from system fonts. - Page transitions (`page.transition/transitions`, Page Transitions panel): twelve transition types with speed and direction per spread, written into exported PDFs as `/Trans` entries through an incremental update. - PDF/X-4 export (`file.exportPdf {standard: "x4"}`): an output intent with our own Generic CMYK ICC profile (lut16 tables generated from the parametric press model), GTS_PDFXVersion in Info and XMP, and built-in PDF/X-4 checks reported as warnings. qpdf finds no errors in the result. - EPS place: placed at its (high-resolution) bounding box and shown, printed and exported through its TIFF preview, or a placeholder when it has none. PostScript itself isn't interpreted. diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 60fae75f3..27e20f511 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1,5 +1,7 @@ //! Interface language (Edit › Interface Language): translations of menu titles, the most used -//! menu items and panel names. Untranslated strings stay English. The translations are our own. +//! menu items and panel names. Untranslated strings stay English. +//! Chinese and Arabic feature terminology is checked against public Adobe help where documented; +//! project-specific labels are original. Sources and scope: `docs/localization-terminology.json`. mod ar; @@ -27,14 +29,14 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Save", ["Speichern", "Enregistrer", "Guardar", "保存", "保存"]), ("Save As…", ["Speichern unter …", "Enregistrer sous…", "Guardar como…", "別名で保存…", "另存为…"]), ("Save a Copy…", ["Kopie speichern …", "Enregistrer une copie…", "Guardar una copia…", "コピーを保存…", "保存副本…"]), - ("Revert", ["Zurück zur letzten Version", "Version précédente", "Volver a la versión guardada", "復帰", "恢复"]), + ("Revert", ["Zurück zur letzten Version", "Version précédente", "Volver a la versión guardada", "復帰", "还原"]), ("Place…", ["Platzieren …", "Importer…", "Colocar…", "配置…", "置入…"]), ("Export PDF…", ["PDF exportieren …", "Exporter en PDF…", "Exportar PDF…", "PDF を書き出し…", "导出PDF…"]), ("Print…", ["Drucken …", "Imprimer…", "Imprimir…", "プリント…", "打印…"]), ("Document Setup…", ["Dokument einrichten …", "Format de document…", "Ajustar documento…", "ドキュメント設定…", "文档设置…"]), ("Package…", ["Verpacken …", "Assembler…", "Empaquetar…", "パッケージ…", "打包…"]), // Edit. - ("Undo", ["Rückgängig", "Annuler", "Deshacer", "取り消し", "撤销"]), + ("Undo", ["Rückgängig", "Annuler", "Deshacer", "取り消し", "还原"]), ("Redo", ["Wiederholen", "Rétablir", "Rehacer", "やり直し", "重做"]), ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), @@ -62,16 +64,16 @@ const TABLE: &[(&str, [&str; 5])] = &[ "Options de numérotation et de section…", "Opciones de numeración y sección…", "ノンブルとセクション設定…", - "编号和章节选项…", + "页码和章节选项…", ], ), // Type. ("Story Direction", ["Textrichtung", "Sens du texte", "Dirección del texto", "組み方向", "文章方向"]), ("Horizontal", ["Horizontal", "Horizontal", "Horizontal", "横書き", "水平"]), ("Vertical", ["Vertikal", "Vertical", "Vertical", "縦書き", "垂直"]), - ("Tate-Chu-Yoko", ["Tate-Chu-Yoko", "Tate-Chu-Yoko", "Tate-Chu-Yoko", "縦中横", "直排内横排"]), - ("Ruby…", ["Ruby …", "Ruby…", "Ruby…", "ルビ…", "注音…"]), - ("Kenten", ["Kenten", "Kenten", "Kenten", "圏点", "着重号"]), + ("Tate-Chu-Yoko", ["Tate-Chu-Yoko", "Tate-Chu-Yoko", "Tate-Chu-Yoko", "縦中横", "纵中横"]), + ("Ruby…", ["Ruby …", "Ruby…", "Ruby…", "ルビ…", "旁注…"]), + ("Kenten", ["Kenten", "Kenten", "Kenten", "圏点", "圈点"]), ("Font", ["Schriftart", "Police", "Fuente", "フォント", "字体"]), ("Size", ["Schriftgrad", "Corps", "Tamaño", "サイズ", "大小"]), ("Character", ["Zeichen", "Caractère", "Carácter", "文字", "字符"]), @@ -114,7 +116,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Screen Mode", ["Bildschirmmodus", "Mode d'affichage", "Modo de pantalla", "スクリーンモード", "屏幕模式"]), ("Grids & Guides", ["Raster und Hilfslinien", "Grilles et repères", "Cuadrículas y guías", "グリッドとガイド", "网格和参考线"]), ("Show Rulers", ["Lineale einblenden", "Afficher les règles", "Mostrar reglas", "定規を表示", "显示标尺"]), - ("Snap to Guides", ["An Hilfslinien ausrichten", "Coller aux repères", "Ajustar a las guías", "ガイドにスナップ", "对齐参考线"]), + ("Snap to Guides", ["An Hilfslinien ausrichten", "Coller aux repères", "Ajustar a las guías", "ガイドにスナップ", "靠齐参考线"]), ( "Snap to Document Grid", [ @@ -122,7 +124,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ "Coller à la grille du document", "Ajustar a la cuadrícula del documento", "ドキュメントグリッドにスナップ", - "对齐文档网格", + "靠齐文档网格", ], ), ("Smart Guides", ["Smarte Hilfslinien", "Repères intelligents", "Guías inteligentes", "スマートガイド", "智能参考线"]), @@ -148,7 +150,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ), ("Smart Dimensions", ["Smarte Maße", "Mesures intelligentes", "Dimensiones inteligentes", "スマートサイズ", "智能尺寸"]), ("Smart Spacing", ["Smarte Abstände", "Espacements intelligents", "Espaciado inteligente", "スマート間隔", "智能间距"]), - ("Snap to Zone", ["Fangzone", "Zone de magnétisme", "Zona de ajuste", "スナップゾーン", "吸附范围"]), + ("Snap to Zone", ["Fangzone", "Zone de magnétisme", "Zona de ajuste", "スナップゾーン", "靠齐区域"]), // Window and panels. ("Properties", ["Eigenschaften", "Propriétés", "Propiedades", "プロパティ", "属性"]), ("Layers", ["Ebenen", "Calques", "Capas", "レイヤー", "图层"]), @@ -159,7 +161,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Gradient", ["Verlauf", "Dégradé", "Degradado", "グラデーション", "渐变"]), ("Align", ["Ausrichten", "Alignement", "Alinear", "整列", "对齐"]), ("Pathfinder", ["Pathfinder", "Pathfinder", "Buscatrazos", "パスファインダー", "路径查找器"]), - ("Info", ["Info", "Informations", "Información", "情報", "Info"]), + ("Info", ["Info", "Informations", "Información", "情報", "信息"]), ("Library", ["Bibliothek", "Bibliothèque", "Biblioteca", "ライブラリ", "库"]), ("Book", ["Buch", "Livre", "Libro", "ブック", "书籍"]), ("Index", ["Index", "Index", "Índice", "索引", "索引"]), @@ -170,7 +172,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Media", ["Medien", "Média", "Medios", "メディア", "媒体"]), ("Object States", ["Objektstatus", "États d'objet", "Estados de objeto", "オブジェクトステート", "对象状态"]), ("Buttons and Forms", ["Schaltflächen und Formulare", "Boutons et formulaires", "Botones y formularios", "ボタンとフォーム", "按钮和表单"]), - ("Liquid Layout", ["Flüssiges Layout", "Mise en page liquide", "Maquetación líquida", "リキッドレイアウト", "液态版面"]), + ("Liquid Layout", ["Flüssiges Layout", "Mise en page liquide", "Maquetación líquida", "リキッドレイアウト", "自适应版面"]), ("Workspace", ["Arbeitsbereich", "Espace de travail", "Espacio de trabajo", "ワークスペース", "工作区"]), ("Split Window", ["Fenster teilen", "Fractionner la fenêtre", "Dividir ventana", "ウィンドウを分割", "拆分窗口"]), ("New Window", ["Neues Fenster", "Nouvelle fenêtre", "Nueva ventana", "新規ウィンドウ", "新建窗口"]), @@ -191,10 +193,10 @@ const TABLE: &[(&str, [&str; 5])] = &[ "Add to Previous Spread", ["Add to Previous Spread", "Add to Previous Spread", "Add to Previous Spread", "Add to Previous Spread", "添加到上一个跨页"], ), - ("Align From:", ["Align From:", "Align From:", "Align From:", "Align From:", "对齐基准:"]), + ("Align From:", ["Align From:", "Align From:", "Align From:", "Align From:", "对齐自:"]), ( "Align to baseline grid", - ["Align to baseline grid", "Align to baseline grid", "Align to baseline grid", "Align to baseline grid", "对齐到基线网格"], + ["Align to baseline grid", "Align to baseline grid", "Align to baseline grid", "Align to baseline grid", "对齐基线网格"], ), ("Alignment:", ["Alignment:", "Alignment:", "Alignment:", "Alignment:", "对齐:"]), ("All Spots to Process", ["All Spots to Process", "All Spots to Process", "All Spots to Process", "All Spots to Process", "所有专色转四色"]), @@ -212,7 +214,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Art", ["Art", "Art", "Art", "Art", "画板"]), ("Based On:", ["Based On:", "Based On:", "Based On:", "Based On:", "基于:"]), ("Bleed", ["Bleed", "Bleed", "Bleed", "Bleed", "出血"]), - ("Body Rows", ["Body Rows", "Body Rows", "Body Rows", "Body Rows", "正文行"]), + ("Body Rows", ["Body Rows", "Body Rows", "Body Rows", "Body Rows", "表体行"]), ("Bottom", ["Bottom", "Bottom", "Bottom", "Bottom", "底部"]), ("Break Link to Style", ["Break Link to Style", "Break Link to Style", "Break Link to Style", "Break Link to Style", "断开与样式的链接"]), ("Bullets and Numbering", ["Bullets and Numbering", "Bullets and Numbering", "Bullets and Numbering", "Bullets and Numbering", "项目符号和编号"]), @@ -270,7 +272,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Find/Replace Font", ["Find/Replace Font", "Find/Replace Font", "Find/Replace Font", "Find/Replace Font", "查找/替换字体"]), ( "Fit Content Proportionally", - ["Fit Content Proportionally", "Fit Content Proportionally", "Fit Content Proportionally", "Fit Content Proportionally", "按比例调整内容"], + ["Fit Content Proportionally", "Fit Content Proportionally", "Fit Content Proportionally", "Fit Content Proportionally", "按比例适合内容"], ), ("Fit Content to Frame", ["Fit Content to Frame", "Fit Content to Frame", "Fit Content to Frame", "Fit Content to Frame", "内容适合框架"]), ("Fitting:", ["Fitting:", "Fitting:", "Fitting:", "Fitting:", "适合:"]), @@ -290,7 +292,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Header Rows", ["Header Rows", "Header Rows", "Header Rows", "Header Rows", "表头行"]), ("Height", ["Height", "Height", "Height", "Height", "高度"]), ("Height:", ["Height:", "Height:", "Height:", "Height:", "高度:"]), - ("High Quality", ["High Quality", "High Quality", "High Quality", "High Quality", "高质量"]), + ("High Quality", ["High Quality", "High Quality", "High Quality", "High Quality", "高品质"]), ( "Horizontal Gridline Every:", ["Horizontal Gridline Every:", "Horizontal Gridline Every:", "Horizontal Gridline Every:", "Horizontal Gridline Every:", "水平网格线间隔:"], @@ -346,7 +348,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ "拼写错误的单词 → 更正(每行一个):", ], ), - ("Mobile", ["Mobile", "Mobile", "Mobile", "Mobile", "移动版"]), + ("Mobile", ["Mobile", "Mobile", "Mobile", "Mobile", "移动设备"]), ("Move Selection Here", ["Move Selection Here", "Move Selection Here", "Move Selection Here", "Move Selection Here", "将所选内容移至此处"]), ("Move to Color Group", ["Move to Color Group", "Move to Color Group", "Move to Color Group", "Move to Color Group", "移动到颜色组"]), ("Move to Group", ["Move to Group", "Move to Group", "Move to Group", "Move to Group", "移动到组"]), @@ -465,9 +467,9 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Separator:", ["Separator:", "Separator:", "Separator:", "Separator:", "分隔符:"]), ( "Show Prefix/Suffix in:", - ["Show Prefix/Suffix in:", "Show Prefix/Suffix in:", "Show Prefix/Suffix in:", "Show Prefix/Suffix in:", "显示前缀/后缀于:"], + ["Show Prefix/Suffix in:", "Show Prefix/Suffix in:", "Show Prefix/Suffix in:", "Show Prefix/Suffix in:", "显示前缀/后缀位置:"], ), - ("Show Tag Markers", ["Show Tag Markers", "Show Tag Markers", "Show Tag Markers", "Show Tag Markers", "显示标记的框架"]), + ("Show Tag Markers", ["Show Tag Markers", "Show Tag Markers", "Show Tag Markers", "Show Tag Markers", "显示标签标志符"]), ("Single-line", ["Single-line", "Single-line", "Single-line", "Single-line", "单行"]), ("Single-line Composer", ["Single-line Composer", "Single-line Composer", "Single-line Composer", "Single-line Composer", "单行排版器"]), ("Size:", ["Size:", "Size:", "Size:", "Size:", "大小:"]), @@ -478,7 +480,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Speed", ["Speed", "Speed", "Speed", "Speed", "速度"]), ("Star Inset:", ["Star Inset:", "Star Inset:", "Star Inset:", "Star Inset:", "星形内边距:"]), ("Start Page #:", ["Start Page #:", "Start Page #:", "Start Page #:", "Start Page #:", "起始页码:"]), - ("Start at:", ["Start at:", "Start at:", "Start at:", "Start at:", "起始于:"]), + ("Start at:", ["Start at:", "Start at:", "Start at:", "Start at:", "起始编号:"]), ("Start:", ["Start:", "Start:", "Start:", "Start:", "开始:"]), ("Style Name:", ["Style Name:", "Style Name:", "Style Name:", "Style Name:", "样式名称:"]), ("Style:", ["Style:", "Style:", "Style:", "Style:", "样式:"]), @@ -536,7 +538,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Polygon", ["Polygon", "Polygon", "Polygon", "Polygon", "多边形"]), ("Characters", ["Characters", "Characters", "Characters", "Characters", "字符"]), ("Image", ["Image", "Image", "Image", "Image", "图像"]), - ("Line", ["Line", "Line", "Line", "Line", "线条"]), + ("Line", ["Line", "Line", "Line", "Line", "直线"]), ("Path", ["Path", "Path", "Path", "Path", "路径"]), ("No Selection", ["No Selection", "No Selection", "No Selection", "No Selection", "未选择"]), ("Adjust Layout", ["Adjust Layout", "Adjust Layout", "Adjust Layout", "Adjust Layout", "调整版面"]), @@ -650,7 +652,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ "Allow Selected Spread to Shuffle", "Allow Selected Spread to Shuffle", "Allow Selected Spread to Shuffle", - "允许选中的跨页重排", + "允许所选跨页随机排布", ], ), ( @@ -838,7 +840,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Edit Story", ["Edit Story", "Edit Story", "Edit Story", "Edit Story", "编辑文章"]), ("Edit in Story Editor", ["Edit in Story Editor", "Edit in Story Editor", "Edit in Story Editor", "Edit in Story Editor", "在文章编辑器中编辑"]), ("Endnotes", ["Endnotes", "Endnotes", "Endnotes", "Endnotes", "尾注"]), - ("Entire Pasteboard", ["Entire Pasteboard", "Entire Pasteboard", "Entire Pasteboard", "Entire Pasteboard", "整个粘贴板"]), + ("Entire Pasteboard", ["Entire Pasteboard", "Entire Pasteboard", "Entire Pasteboard", "Entire Pasteboard", "完整粘贴板"]), ("Erase", ["Erase", "Erase", "Erase", "Erase", "擦除"]), ("Export Book to PDF…", ["Export Book to PDF…", "Export Book to PDF…", "Export Book to PDF…", "Export Book to PDF…", "将书籍导出为 PDF…"]), ( @@ -881,7 +883,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Find Object", ["Find Object", "Find Object", "Find Object", "Find Object", "查找对象"]), ("Find by Script Label", ["Find by Script Label", "Find by Script Label", "Find by Script Label", "Find by Script Label", "按脚本标签查找"]), ("Find/Replace Font…", ["Find/Replace Font…", "Find/Replace Font…", "Find/Replace Font…", "Find/Replace Font…", "查找/替换字体…"]), - ("First Object Above", ["First Object Above", "First Object Above", "First Object Above", "First Object Above", "上方的第一个对象"]), + ("First Object Above", ["First Object Above", "First Object Above", "First Object Above", "First Object Above", "上方第一个对象"]), ( "Fit Selection in Window", ["Fit Selection in Window", "Fit Selection in Window", "Fit Selection in Window", "Fit Selection in Window", "窗口大小适应所选内容"], @@ -899,7 +901,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Generate QR Code…", ["Generate QR Code…", "Generate QR Code…", "Generate QR Code…", "Generate QR Code…", "生成二维码…"]), ( "Generate Static Caption", - ["Generate Static Caption", "Generate Static Caption", "Generate Static Caption", "Generate Static Caption", "生成静态标题"], + ["Generate Static Caption", "Generate Static Caption", "Generate Static Caption", "Generate Static Caption", "生成静态题注"], ), ("Get Story", ["Get Story", "Get Story", "Get Story", "Get Story", "获取文章"]), ("Get Table", ["Get Table", "Get Table", "Get Table", "Get Table", "获取表格"]), @@ -917,7 +919,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Guides", ["Guides", "Guides", "Guides", "Guides", "参考线"]), ("Hide Menu Item", ["Hide Menu Item", "Hide Menu Item", "Hide Menu Item", "Hide Menu Item", "隐藏菜单项"]), ("Hide/Lock Others", ["Hide/Lock Others", "Hide/Lock Others", "Hide/Lock Others", "Hide/Lock Others", "隐藏/锁定其他"]), - ("High Quality Display", ["High Quality Display", "High Quality Display", "High Quality Display", "High Quality Display", "高质量显示"]), + ("High Quality Display", ["High Quality Display", "High Quality Display", "High Quality Display", "High Quality Display", "高品质显示"]), ("History", ["History", "History", "History", "History", "历史记录"]), ("Hyphenation Exceptions", ["Hyphenation Exceptions", "Hyphenation Exceptions", "Hyphenation Exceptions", "Hyphenation Exceptions", "断字例外"]), ( @@ -959,7 +961,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Library Contents", ["Library Contents", "Library Contents", "Library Contents", "Library Contents", "库内容"]), ("Library Items", ["Library Items", "Library Items", "Library Items", "Library Items", "库项目"]), ("Linked Stories", ["Linked Stories", "Linked Stories", "Linked Stories", "Linked Stories", "链接的文章"]), - ("Liquid Page Rule", ["Liquid Page Rule", "Liquid Page Rule", "Liquid Page Rule", "Liquid Page Rule", "流动页面规则"]), + ("Liquid Page Rule", ["Liquid Page Rule", "Liquid Page Rule", "Liquid Page Rule", "Liquid Page Rule", "自适应页面规则"]), ("List Documents", ["List Documents", "List Documents", "List Documents", "List Documents", "列出文档"]), ("List Styles", ["List Styles", "List Styles", "List Styles", "List Styles", "列表样式"]), ("List Tools", ["List Tools", "List Tools", "List Tools", "List Tools", "列出工具"]), @@ -984,14 +986,14 @@ const TABLE: &[(&str, [&str; 5])] = &[ ), ("New Hyperlink…", ["New Hyperlink…", "New Hyperlink…", "New Hyperlink…", "New Hyperlink…", "新建超链接…"]), ("New Library", ["New Library", "New Library", "New Library", "New Library", "新建库"]), - ("New Page Reference…", ["New Page Reference…", "New Page Reference…", "New Page Reference…", "New Page Reference…", "新建页引用…"]), + ("New Page Reference…", ["New Page Reference…", "New Page Reference…", "New Page Reference…", "New Page Reference…", "新建页面引用…"]), ("New Paragraph Style…", ["New Paragraph Style…", "New Paragraph Style…", "New Paragraph Style…", "New Paragraph Style…", "新建段落样式…"]), ("New Stroke Style…", ["New Stroke Style…", "New Stroke Style…", "New Stroke Style…", "New Stroke Style…", "新建描边样式…"]), - ("New Table Style…", ["New Table Style…", "New Table Style…", "New Table Style…", "New Table Style…", "新建表格样式…"]), + ("New Table Style…", ["New Table Style…", "New Table Style…", "New Table Style…", "New Table Style…", "新建表样式…"]), ("Next Cell", ["Next Cell", "Next Cell", "Next Cell", "Next Cell", "下一个单元格"]), ("Next Document", ["Next Document", "Next Document", "Next Document", "Next Document", "下一个文档"]), - ("Next Object Above", ["Next Object Above", "Next Object Above", "Next Object Above", "Next Object Above", "上方的下一个对象"]), - ("Next Object Below", ["Next Object Below", "Next Object Below", "Next Object Below", "Next Object Below", "下方的下一个对象"]), + ("Next Object Above", ["Next Object Above", "Next Object Above", "Next Object Above", "Next Object Above", "上方下一个对象"]), + ("Next Object Below", ["Next Object Below", "Next Object Below", "Next Object Below", "Next Object Below", "下方下一个对象"]), ("Next Object in Group", ["Next Object in Group", "Next Object in Group", "Next Object in Group", "Next Object in Group", "组中的下一个对象"]), ("Notes", ["Notes", "Notes", "Notes", "Notes", "注释"]), ("Object Export Options", ["Object Export Options", "Object Export Options", "Object Export Options", "Object Export Options", "对象导出选项"]), @@ -1003,7 +1005,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Object Layers", ["Object Layers", "Object Layers", "Object Layers", "Object Layers", "对象图层"]), ( "Object Liquid Settings", - ["Object Liquid Settings", "Object Liquid Settings", "Object Liquid Settings", "Object Liquid Settings", "对象流动设置"], + ["Object Liquid Settings", "Object Liquid Settings", "Object Liquid Settings", "Object Liquid Settings", "对象自适应设置"], ), ("Open Book", ["Open Book", "Open Book", "Open Book", "Open Book", "打开书籍"]), ("Open Bytes", ["Open Bytes", "Open Bytes", "Open Bytes", "Open Bytes", "打开字节"]), @@ -1032,7 +1034,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Paste Into", ["Paste Into", "Paste Into", "Paste Into", "Paste Into", "贴入内部"]), ( "Paste without Formatting", - ["Paste without Formatting", "Paste without Formatting", "Paste without Formatting", "Paste without Formatting", "不带格式粘贴"], + ["Paste without Formatting", "Paste without Formatting", "Paste without Formatting", "Paste without Formatting", "粘贴时不包含格式"], ), ("Place", ["Place", "Place", "Place", "Place", "置入"]), ("Place Caret", ["Place Caret", "Place Caret", "Place Caret", "Place Caret", "放置光标"]), @@ -1147,9 +1149,9 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Structure", ["Structure", "Structure", "Structure", "Structure", "结构"]), ("Style Source", ["Style Source", "Style Source", "Style Source", "Style Source", "样式源"]), ("Synchronize Book", ["Synchronize Book", "Synchronize Book", "Synchronize Book", "Synchronize Book", "同步书籍"]), - ("Table Options", ["Table Options", "Table Options", "Table Options", "Table Options", "表格选项"]), + ("Table Options", ["Table Options", "Table Options", "Table Options", "Table Options", "表选项"]), ("Table Panel", ["Table Panel", "Table Panel", "Table Panel", "Table Panel", "表格面板"]), - ("Table Style Options…", ["Table Style Options…", "Table Style Options…", "Table Style Options…", "Table Style Options…", "表格样式选项…"]), + ("Table Style Options…", ["Table Style Options…", "Table Style Options…", "Table Style Options…", "Table Style Options…", "表样式选项…"]), ( "Table of Contents Entries", ["Table of Contents Entries", "Table of Contents Entries", "Table of Contents Entries", "Table of Contents Entries", "目录条目"], @@ -1174,7 +1176,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ "Transparency Flattener Presets", "Transparency Flattener Presets", "Transparency Flattener Presets", - "透明度拼合预设", + "透明度拼合器预设", ], ), ("Type on a Path", ["Type on a Path", "Type on a Path", "Type on a Path", "Type on a Path", "路径文字"]), @@ -1206,11 +1208,11 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Apply Character Style", ["Apply Character Style", "Apply Character Style", "Apply Character Style", "Apply Character Style", "应用字符样式"]), ("Apply Object Style", ["Apply Object Style", "Apply Object Style", "Apply Object Style", "Apply Object Style", "应用对象样式"]), ("Apply Paragraph Style", ["Apply Paragraph Style", "Apply Paragraph Style", "Apply Paragraph Style", "Apply Paragraph Style", "应用段落样式"]), - ("Apply Table Style", ["Apply Table Style", "Apply Table Style", "Apply Table Style", "Apply Table Style", "应用表格样式"]), + ("Apply Table Style", ["Apply Table Style", "Apply Table Style", "Apply Table Style", "Apply Table Style", "应用表样式"]), ("Article Options", ["Article Options", "Article Options", "Article Options", "Article Options", "文章选项"]), ("Basic Feather", ["Basic Feather", "Basic Feather", "Basic Feather", "Basic Feather", "基本羽化"]), ("Bold", ["Bold", "Bold", "Bold", "Bold", "加粗"]), - ("Bring Forward", ["Bring Forward", "Bring Forward", "Bring Forward", "Bring Forward", "前移一层"]), + ("Bring Forward", ["Bring Forward", "Bring Forward", "Bring Forward", "Bring Forward", "向前移动"]), ("Bring to Front", ["Bring to Front", "Bring to Front", "Bring to Front", "Bring to Front", "置于顶层"]), ("Change", ["Change", "Change", "Change", "Change", "更改"]), ( @@ -1223,7 +1225,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Condition Options…", ["Condition Options…", "Condition Options…", "Condition Options…", "Condition Options…", "条件选项…"]), ( "Convert to Liquid Guide", - ["Convert to Liquid Guide", "Convert to Liquid Guide", "Convert to Liquid Guide", "Convert to Liquid Guide", "转换为流动参考线"], + ["Convert to Liquid Guide", "Convert to Liquid Guide", "Convert to Liquid Guide", "Convert to Liquid Guide", "转换为自适应参考线"], ), ("Convert to Object", ["Convert to Object", "Convert to Object", "Convert to Object", "Convert to Object", "转换为对象"]), ("Create Line", ["Create Line", "Create Line", "Create Line", "Create Line", "创建线条"]), @@ -1256,13 +1258,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Fill", ["Fill", "Fill", "Fill", "Fill", "填色"]), ( "Fill with Placeholder Text", - [ - "Fill with Placeholder Text", - "Fill with Placeholder Text", - "Fill with Placeholder Text", - "Fill with Placeholder Text", - "使用占位符文本填充", - ], + ["Fill with Placeholder Text", "Fill with Placeholder Text", "Fill with Placeholder Text", "Fill with Placeholder Text", "用占位符文本填充"], ), ("Flip", ["Flip", "Flip", "Flip", "Flip", "翻转"]), ("Hyperlink Options…", ["Hyperlink Options…", "Hyperlink Options…", "Hyperlink Options…", "Hyperlink Options…", "超链接选项…"]), @@ -1313,7 +1309,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Rotate", ["Rotate", "Rotate", "Rotate", "Rotate", "旋转"]), ("Row Height", ["Row Height", "Row Height", "Row Height", "Row Height", "行高"]), ("Scale", ["Scale", "Scale", "Scale", "Scale", "缩放"]), - ("Send Backward", ["Send Backward", "Send Backward", "Send Backward", "Send Backward", "后移一层"]), + ("Send Backward", ["Send Backward", "Send Backward", "Send Backward", "Send Backward", "向后移动"]), ("Send to Back", ["Send to Back", "Send to Back", "Send to Back", "Send to Back", "置于底层"]), ( "Set Cross-Reference Format", @@ -1322,12 +1318,12 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Set Footnote Text", ["Set Footnote Text", "Set Footnote Text", "Set Footnote Text", "Set Footnote Text", "设置脚注文本"]), ("Set Story Text", ["Set Story Text", "Set Story Text", "Set Story Text", "Set Story Text", "设置文章文本"]), ("Shear", ["Shear", "Shear", "Shear", "Shear", "切变"]), - ("Show All on Spread", ["Show All on Spread", "Show All on Spread", "Show All on Spread", "Show All on Spread", "显示跨页上的所有内容"]), + ("Show All on Spread", ["Show All on Spread", "Show All on Spread", "Show All on Spread", "Show All on Spread", "在跨页上全部显示"]), ("Show State", ["Show State", "Show State", "Show State", "Show State", "显示状态"]), ("State Options", ["State Options", "State Options", "State Options", "State Options", "状态选项"]), ("Tag Frame", ["Tag Frame", "Tag Frame", "Tag Frame", "Tag Frame", "标记框架"]), ("Tag Text", ["Tag Text", "Tag Text", "Tag Text", "Tag Text", "标记文本"]), - ("Track Changes", ["Track Changes", "Track Changes", "Track Changes", "Track Changes", "跟踪更改"]), + ("Track Changes", ["Track Changes", "Track Changes", "Track Changes", "Track Changes", "修订"]), ("Unlink", ["Unlink", "Unlink", "Unlink", "Unlink", "取消链接"]), ( "Unlock All on Spread", @@ -1410,7 +1406,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ "按钮在交互式 PDF 导出中起作用。", ], ), - ("Captions", ["Captions", "Captions", "Captions", "Captions", "标题"]), + ("Captions", ["Captions", "Captions", "Captions", "Captions", "题注"]), ("Change All (Object", ["Change All (Object", "Change All (Object", "Change All (Object", "Change All (Object", "全部更改 (对象"]), ("Character Settings", ["Character Settings", "Character Settings", "Character Settings", "Character Settings", "字符设置"]), ("Choose a link.", ["Choose a link.", "Choose a link.", "Choose a link.", "Choose a link.", "选择一个链接。"]), @@ -1479,7 +1475,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ], ), ("Hyphens and Dashes", ["Hyphens and Dashes", "Hyphens and Dashes", "Hyphens and Dashes", "Hyphens and Dashes", "连字符和破折号"]), - ("Interactive", ["Interactive", "Interactive", "Interactive", "Interactive", "交互"]), + ("Interactive", ["Interactive", "Interactive", "Interactive", "Interactive", "交互式"]), ("Invert", ["Invert", "Invert", "Invert", "Invert", "反相"]), ( "Join the ArtCraft community", @@ -1492,7 +1488,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ], ), ("Keyboard Increments", ["Keyboard Increments", "Keyboard Increments", "Keyboard Increments", "Keyboard Increments", "键盘增量"]), - ("Liquid guides", ["Liquid guides", "Liquid guides", "Liquid guides", "Liquid guides", "流动参考线"]), + ("Liquid guides", ["Liquid guides", "Liquid guides", "Liquid guides", "Liquid guides", "自适应参考线"]), ("Markers", ["Markers", "Markers", "Markers", "Markers", "标记"]), ("More ArtCraft apps", ["More ArtCraft apps", "More ArtCraft apps", "More ArtCraft apps", "More ArtCraft apps", "更多 ArtCraft 应用"]), ("Nested Line Styles", ["Nested Line Styles", "Nested Line Styles", "Nested Line Styles", "Nested Line Styles", "嵌套行样式"]), @@ -1858,11 +1854,11 @@ const TABLE: &[(&str, [&str; 5])] = &[ "Span Footnotes Across Columns", "Span Footnotes Across Columns", "Span Footnotes Across Columns", - "脚注跨栏", + "跨栏分布脚注", ], ), ("Rule On", ["Rule On", "Rule On", "Rule On", "Rule On", "启用横线"]), - ("Ascent", ["Ascent", "Ascent", "Ascent", "Ascent", "升部"]), + ("Ascent", ["Ascent", "Ascent", "Ascent", "Ascent", "上升高度"]), ("Cap Height", ["Cap Height", "Cap Height", "Cap Height", "Cap Height", "大写字母高度"]), ("Leading", ["Leading", "Leading", "Leading", "Leading", "行距"]), ("x Height", ["x Height", "x Height", "x Height", "x Height", "x 字高"]), @@ -1893,18 +1889,18 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("enSpace", ["enSpace", "enSpace", "enSpace", "enSpace", "半角空格"]), ("chars", ["chars", "chars", "chars", "chars", "指定字符"]), ("[Automatic]", ["[Automatic]", "[Automatic]", "[Automatic]", "[Automatic]", "[自动]"]), - ("Tracking:", ["Tracking:", "Tracking:", "Tracking:", "Tracking:", "字距:"]), + ("Tracking:", ["Tracking:", "Tracking:", "Tracking:", "Tracking:", "字距调整:"]), ("Right Indent:", ["Right Indent:", "Right Indent:", "Right Indent:", "Right Indent:", "右缩进:"]), ("First Line Indent:", ["First Line Indent:", "First Line Indent:", "First Line Indent:", "First Line Indent:", "首行缩进:"]), ("Space Before:", ["Space Before:", "Space Before:", "Space Before:", "Space Before:", "段前间距:"]), ("Space After:", ["Space After:", "Space After:", "Space After:", "Space After:", "段后间距:"]), - ("Words with at Least:", ["Words with at Least:", "Words with at Least:", "Words with at Least:", "Words with at Least:", "最少单词长度:"]), - ("After First:", ["After First:", "After First:", "After First:", "After First:", "前部最少保留:"]), - ("Before Last:", ["Before Last:", "Before Last:", "Before Last:", "Before Last:", "后部最少保留:"]), - ("Hyphen Limit:", ["Hyphen Limit:", "Hyphen Limit:", "Hyphen Limit:", "Hyphen Limit:", "连续断字上限:"]), + ("Words with at Least:", ["Words with at Least:", "Words with at Least:", "Words with at Least:", "Words with at Least:", "单词最少字符数:"]), + ("After First:", ["After First:", "After First:", "After First:", "After First:", "连字符前最少字符数:"]), + ("Before Last:", ["Before Last:", "Before Last:", "Before Last:", "Before Last:", "连字符后最少字符数:"]), + ("Hyphen Limit:", ["Hyphen Limit:", "Hyphen Limit:", "Hyphen Limit:", "Hyphen Limit:", "连字符限制:"]), ("Word Spacing:", ["Word Spacing:", "Word Spacing:", "Word Spacing:", "Word Spacing:", "单词间距:"]), ("Letter Spacing:", ["Letter Spacing:", "Letter Spacing:", "Letter Spacing:", "Letter Spacing:", "字母间距:"]), - ("Glyph Scaling:", ["Glyph Scaling:", "Glyph Scaling:", "Glyph Scaling:", "Glyph Scaling:", "字形缩放:"]), + ("Glyph Scaling:", ["Glyph Scaling:", "Glyph Scaling:", "Glyph Scaling:", "Glyph Scaling:", "字形缩放比例:"]), ("Insert Kashidas:", ["Insert Kashidas:", "Insert Kashidas:", "Insert Kashidas:", "Insert Kashidas:", "插入延长线:"]), ( "In justified Arabic text", @@ -1917,7 +1913,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ], ), ("Binding:", ["Binding:", "Binding:", "Binding:", "Binding:", "装订方向:"]), - ("Ruby:", ["Ruby:", "Ruby:", "Ruby:", "Ruby:", "注音:"]), + ("Ruby:", ["Ruby:", "Ruby:", "Ruby:", "Ruby:", "旁注:"]), ( "Set over the selected text; empty removes it.", [ @@ -2143,7 +2139,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Show Baseline Grid", ["Show Baseline Grid", "Show Baseline Grid", "Show Baseline Grid", "Show Baseline Grid", "显示基线网格"]), ("Show Document Grid", ["Show Document Grid", "Show Document Grid", "Show Document Grid", "Show Document Grid", "显示文档网格"]), ("Show Guides", ["Show Guides", "Show Guides", "Show Guides", "Show Guides", "显示参考线"]), - ("Stop Color:", ["Stop Color:", "Stop Color:", "Stop Color:", "Stop Color:", "色标颜色:"]), + ("Stop Color:", ["Stop Color:", "Stop Color:", "Stop Color:", "Stop Color:", "停止点颜色:"]), ("Style", ["Style", "Style", "Style", "Style", "样式"]), ("Styles panel", ["Styles panel", "Styles panel", "Styles panel", "Styles panel", "样式面板"]), ("Text", ["Text", "Text", "Text", "Text", "文本"]), @@ -2152,7 +2148,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ["The structure is valid.", "The structure is valid.", "The structure is valid.", "The structure is valid.", "结构有效。"], ), ("Toggle double column", ["Toggle double column", "Toggle double column", "Toggle double column", "Toggle double column", "切换双列布局"]), - ("Tracking", ["Tracking", "Tracking", "Tracking", "Tracking", "字距"]), + ("Tracking", ["Tracking", "Tracking", "Tracking", "Tracking", "字距调整"]), ("Untag Selection", ["Untag Selection", "Untag Selection", "Untag Selection", "Untag Selection", "移除所选内容的标签"]), ("Validate", ["Validate", "Validate", "Validate", "Validate", "验证"]), ("Weight", ["Weight", "Weight", "Weight", "Weight", "粗细"]), @@ -2211,7 +2207,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Overprint Fill", ["Overprint Fill", "Overprint Fill", "Overprint Fill", "Overprint Fill", "叠印填色"]), ("Overprint Stroke", ["Overprint Stroke", "Overprint Stroke", "Overprint Stroke", "Overprint Stroke", "叠印描边"]), ("Overprint Gap", ["Overprint Gap", "Overprint Gap", "Overprint Gap", "Overprint Gap", "叠印间隙"]), - ("Nonprinting", ["Nonprinting", "Nonprinting", "Nonprinting", "Nonprinting", "不打印"]), + ("Nonprinting", ["Nonprinting", "Nonprinting", "Nonprinting", "Nonprinting", "非打印"]), ("At Least", ["At Least", "At Least", "At Least", "At Least", "至少"]), ("Exactly", ["Exactly", "Exactly", "Exactly", "Exactly", "固定"]), ("Justify", ["Justify", "Justify", "Justify", "Justify", "两端对齐"]), @@ -2219,7 +2215,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Strikethrough Options", ["Strikethrough Options", "Strikethrough Options", "Strikethrough Options", "Strikethrough Options", "删除线选项"]), ( "Discretionary Ligatures", - ["Discretionary Ligatures", "Discretionary Ligatures", "Discretionary Ligatures", "Discretionary Ligatures", "任意连字"], + ["Discretionary Ligatures", "Discretionary Ligatures", "Discretionary Ligatures", "Discretionary Ligatures", "自由连字"], ), ("Fractions", ["Fractions", "Fractions", "Fractions", "Fractions", "分数"]), ("Ordinal", ["Ordinal", "Ordinal", "Ordinal", "Ordinal", "序数"]), @@ -2286,7 +2282,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("English: USA", ["English: USA", "English: USA", "English: USA", "English: USA", "英语:美国"]), ("Farsi", ["Farsi", "Farsi", "Farsi", "Farsi", "波斯语"]), ("Finnish", ["Finnish", "Finnish", "Finnish", "Finnish", "芬兰语"]), - ("Fit Frame to Content", ["Fit Frame to Content", "Fit Frame to Content", "Fit Frame to Content", "Fit Frame to Content", "框架适合内容"]), + ("Fit Frame to Content", ["Fit Frame to Content", "Fit Frame to Content", "Fit Frame to Content", "Fit Frame to Content", "使框架适合内容"]), ("French", ["French", "French", "French", "French", "法语"]), ("French: Canadian", ["French: Canadian", "French: Canadian", "French: Canadian", "French: Canadian", "法语:加拿大"]), ("German: 2006 Reform", ["German: 2006 Reform", "German: 2006 Reform", "German: 2006 Reform", "German: 2006 Reform", "德语:2006 年拼写改革"]), @@ -2340,7 +2336,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Turkish", ["Turkish", "Turkish", "Turkish", "Turkish", "土耳其语"]), ("Ukrainian", ["Ukrainian", "Ukrainian", "Ukrainian", "Ukrainian", "乌克兰语"]), ("Cell Style", ["Cell Style", "Cell Style", "Cell Style", "Cell Style", "单元格样式"]), - ("Table Style", ["Table Style", "Table Style", "Table Style", "Table Style", "表格样式"]), + ("Table Style", ["Table Style", "Table Style", "Table Style", "Table Style", "表样式"]), ("Col ←", ["Col ←", "Col ←", "Col ←", "Col ←", "左侧插入列"]), ("Col →", ["Col →", "Col →", "Col →", "Col →", "右侧插入列"]), ("Row ↑", ["Row ↑", "Row ↑", "Row ↑", "Row ↑", "上方插入行"]), @@ -2463,7 +2459,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Case sensitive", ["Case sensitive", "Case sensitive", "Case sensitive", "Case sensitive", "区分大小写"]), ( "Custom Tracking/Kerning", - ["Custom Tracking/Kerning", "Custom Tracking/Kerning", "Custom Tracking/Kerning", "Custom Tracking/Kerning", "自定义字距/字偶距"], + ["Custom Tracking/Kerning", "Custom Tracking/Kerning", "Custom Tracking/Kerning", "Custom Tracking/Kerning", "自定义字距调整/字距微调"], ), ( "Dimensions Include Stroke Weight", @@ -2472,7 +2468,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ "Dimensions Include Stroke Weight", "Dimensions Include Stroke Weight", "Dimensions Include Stroke Weight", - "尺寸包含描边粗细", + "尺寸包括描边粗细", ], ), ("Enable Autocorrect", ["Enable Autocorrect", "Enable Autocorrect", "Enable Autocorrect", "Enable Autocorrect", "启用自动更正"]), @@ -2510,10 +2506,10 @@ const TABLE: &[(&str, [&str; 5])] = &[ "Remove Styles and Formatting from Text and Tables", "Remove Styles and Formatting from Text and Tables", "Remove Styles and Formatting from Text and Tables", - "移除文本和表格中的样式与格式", + "从文本和表格中删除样式和格式", ], ), - ("Ruby", ["Ruby", "Ruby", "Ruby", "Ruby", "注音"]), + ("Ruby", ["Ruby", "Ruby", "Ruby", "Ruby", "旁注"]), ( "Show Note Anchors in Layout View", [ @@ -2525,10 +2521,10 @@ const TABLE: &[(&str, [&str; 5])] = &[ ], ), ("Spreads", ["Spreads", "Spreads", "Spreads", "Spreads", "跨页"]), - ("Substituted Fonts", ["Substituted Fonts", "Substituted Fonts", "Substituted Fonts", "Substituted Fonts", "替换的字体"]), + ("Substituted Fonts", ["Substituted Fonts", "Substituted Fonts", "Substituted Fonts", "Substituted Fonts", "替换字体"]), ( "Transformations are Totals", - ["Transformations are Totals", "Transformations are Totals", "Transformations are Totals", "Transformations are Totals", "显示累计变换值"], + ["Transformations are Totals", "Transformations are Totals", "Transformations are Totals", "Transformations are Totals", "变换为总计"], ), ( "Use Black Point Compensation", @@ -2548,9 +2544,9 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Interface", ["Interface", "Interface", "Interface", "Interface", "界面"]), ("Advanced Type", ["Advanced Type", "Advanced Type", "Advanced Type", "Advanced Type", "高级文字"]), ("Composition", ["Composition", "Composition", "Composition", "Composition", "排版"]), - ("Units & Increments", ["Units & Increments", "Units & Increments", "Units & Increments", "Units & Increments", "单位与增量"]), + ("Units & Increments", ["Units & Increments", "Units & Increments", "Units & Increments", "Units & Increments", "单位和增量"]), ("Grids", ["Grids", "Grids", "Grids", "Grids", "网格"]), - ("Guides & Pasteboard", ["Guides & Pasteboard", "Guides & Pasteboard", "Guides & Pasteboard", "Guides & Pasteboard", "参考线与粘贴板"]), + ("Guides & Pasteboard", ["Guides & Pasteboard", "Guides & Pasteboard", "Guides & Pasteboard", "Guides & Pasteboard", "参考线和粘贴板"]), ("Dictionary", ["Dictionary", "Dictionary", "Dictionary", "Dictionary", "词典"]), ("Autocorrect", ["Autocorrect", "Autocorrect", "Autocorrect", "Autocorrect", "自动更正"]), ("Story Editor Display", ["Story Editor Display", "Story Editor Display", "Story Editor Display", "Story Editor Display", "文章编辑器显示"]), diff --git a/docs/localization-terminology.json b/docs/localization-terminology.json new file mode 100644 index 000000000..b2a8f9699 --- /dev/null +++ b/docs/localization-terminology.json @@ -0,0 +1,1241 @@ +{ + "checked_on": "2026-10-07", + "scope": "Feature terminology verified against public Adobe InDesign help, with related labels made consistent. This is not a complete official UI string catalog. Unverified or ambiguous entries and DesignCraft-specific messages retain their existing translations.", + "arabic_scope": "Adobe documents English or French interfaces for its Arabic-capable editions. Arabic entries here follow Arabic help terminology, not an official Arabic application interface.", + "language_source": "https://helpx.adobe.com/indesign/desktop/get-started/system-and-product-info/available-languages.html", + "review_method": "English/localized control-name alignment was used only to find candidates. Changes were selected by meaning; differing document order, conflicting alternatives, prose accidentally styled as controls and unrelated matches were rejected. Existing wording was retained when already documented. Related-label entries adopt verified terms but do not claim their whole label is an exact Adobe UI string.", + "entries": [ + { + "language": "ar", + "key": "Add Unnamed Colors", + "translation": "إضافة ألوان غير مُسماة", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/apply-color/define-and-manage-color-assets/create-and-define-new-swatches.html" + }, + { + "language": "ar", + "key": "Advanced Type", + "translation": "كتابة متقدمة", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/character-formatting/change-text-case.html" + }, + { + "language": "ar", + "key": "Align From:", + "translation": "محاذاة من:", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-graphics-and-media/manage-frames-and-objects/fit-object-to-frame.html" + }, + { + "language": "ar", + "key": "Alignment", + "translation": "المحاذاة", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/type-on-a-path/apply-effects.html" + }, + { + "language": "ar", + "key": "All Caps", + "translation": "جميع الأحرف كبيرة", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/character-formatting/change-text-case.html" + }, + { + "language": "ar", + "key": "Apply Character Style", + "translation": "تطبيق نمط الحروف", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/text-styles/add-and-manage-text-styles-from-cc-libraries.html" + }, + { + "language": "ar", + "key": "Apply Gradient", + "translation": "تطبيق التدرج اللوني", + "basis": "consistent terminology in related label", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/apply-color/advanced-color-techniques/modify-and-adjust-gradients.html" + }, + { + "language": "ar", + "key": "Ascent", + "translation": "الصعود", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/tabs-indents-and-spacing/adjust-text-baseline.html" + }, + { + "language": "ar", + "key": "Attributes", + "translation": "السمات", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-graphics-and-media/transform-and-arrange-objects/create-hidden-nonprinting-objects.html" + }, + { + "language": "ar", + "key": "Auto-Fit", + "translation": "الملاءمة التلقائية", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-graphics-and-media/manage-frames-and-objects/fit-object-to-frame.html" + }, + { + "language": "ar", + "key": "Based On:", + "translation": "مبني على:", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-graphics-and-media/manage-object-styles/define-and-apply-object-styles.html" + }, + { + "language": "ar", + "key": "Basic Feather", + "translation": "الحواف المنعمة الأساسية", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/character-formatting/apply-transparency-effects.html" + }, + { + "language": "ar", + "key": "Bevel and Emboss", + "translation": "القطع المشطوف والنقش البارز", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/character-formatting/apply-transparency-effects.html" + }, + { + "language": "ar", + "key": "Change", + "translation": "تغيير", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-graphics-and-media/manage-frames-and-objects/find-replace-objects.html" + }, + { + "language": "ar", + "key": "Change All", + "translation": "تغيير الكل", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/add-and-import-text/find-replace-text.html" + }, + { + "language": "ar", + "key": "Change Case", + "translation": "تغيير الحالة", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/character-formatting/change-text-case.html" + }, + { + "language": "ar", + "key": "Change To", + "translation": "تغيير إلى", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/add-and-import-text/find-replace-text.html" + }, + { + "language": "ar", + "key": "Change to:", + "translation": "تغيير إلى:", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/add-and-import-text/find-replace-text.html" + }, + { + "language": "ar", + "key": "Character Style:", + "translation": "نمط الحروف:", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/character-formatting/apply-drop-caps-text-positioning.html" + }, + { + "language": "ar", + "key": "Characters", + "translation": "الأحرف", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/composition-and-text-wrapping/apply-and-manage-text-formatting.html" + }, + { + "language": "ar", + "key": "Color Picker", + "translation": "أداة اختيار الألوان", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/apply-color/define-and-manage-color-assets/apply-colors-to-text-objects-and-backgrounds.html" + }, + { + "language": "ar", + "key": "Columns", + "translation": "الأعمدة", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/add-and-manage-text-frames/change-text-frame-properties.html" + }, + { + "language": "ar", + "key": "Composition", + "translation": "التركيب", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/composition-and-text-wrapping/apply-text-wrap.html" + }, + { + "language": "ar", + "key": "Custom Tracking/Kerning", + "translation": "التتبع/التقنين المخصص", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/tabs-indents-and-spacing/highlight-text-spacing.html" + }, + { + "language": "ar", + "key": "Delete Swatch", + "translation": "حذف عينة الألوان", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/apply-color/define-and-manage-color-assets/create-mixed-ink-swatches-and-groups.html" + }, + { + "language": "ar", + "key": "Desired", + "translation": "المرغوب", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/tabs-indents-and-spacing/adjust-text-spacing.html" + }, + { + "language": "ar", + "key": "Directional Feather", + "translation": "الحواف المنعمة الاتجاهية", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/character-formatting/apply-transparency-effects.html" + }, + { + "language": "ar", + "key": "Document Presets", + "translation": "الإعدادات المسبقة للمستند", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/create-and-organize-pages/create-documents/create-documents-with-presets.html" + }, + { + "language": "ar", + "key": "Drop Shadow", + "translation": "إسقاط الظل", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/character-formatting/apply-transparency-effects.html" + }, + { + "language": "ar", + "key": "Edit Story", + "translation": "تحرير القصة", + "basis": "consistent terminology in related label", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/language-and-proofing/arabic-and-hebrew/change-text-direction.html" + }, + { + "language": "ar", + "key": "Edit in Story Editor", + "translation": "تحرير في محرر القصة", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/add-and-manage-text-frames/open-and-use-story-editor.html" + }, + { + "language": "ar", + "key": "Fast Display", + "translation": "العرض السريع", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/apply-color/advanced-color-techniques/change-transparency-display-settings.html" + }, + { + "language": "ar", + "key": "Fill Frame Proportionally", + "translation": "ملء الإطار بشكل متناسب", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-graphics-and-media/manage-frames-and-objects/fit-object-to-frame.html" + }, + { + "language": "ar", + "key": "Find Next", + "translation": "البحث عن التالي", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/add-and-import-text/find-replace-text.html" + }, + { + "language": "ar", + "key": "Find What", + "translation": "البحث عن", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/add-and-import-text/find-replace-text.html" + }, + { + "language": "ar", + "key": "Find/Change", + "translation": "البحث/التغيير", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-graphics-and-media/manage-frames-and-objects/find-replace-objects.html" + }, + { + "language": "ar", + "key": "Find/Change…", + "translation": "البحث/التغيير…", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-graphics-and-media/manage-frames-and-objects/find-replace-objects.html" + }, + { + "language": "ar", + "key": "Find/Replace Font", + "translation": "البحث عن الخط/استبداله", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/fonts/find-and-replace-fonts.html" + }, + { + "language": "ar", + "key": "Find/Replace Font…", + "translation": "البحث عن الخط/استبداله…", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/fonts/find-and-replace-fonts.html" + }, + { + "language": "ar", + "key": "Fit Content Proportionally", + "translation": "ملاءمة المحتوى بشكل متناسب", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-graphics-and-media/manage-frames-and-objects/fit-object-to-frame.html" + }, + { + "language": "ar", + "key": "Fit Content to Frame", + "translation": "ملاءمة المحتوى مع الإطار", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-graphics-and-media/manage-frames-and-objects/fit-object-to-frame.html" + }, + { + "language": "ar", + "key": "Fit Frame to Content", + "translation": "ملاءمة الإطار مع المحتوى", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-graphics-and-media/manage-frames-and-objects/fit-object-to-frame.html" + }, + { + "language": "ar", + "key": "Fit Page in Window", + "translation": "ملاءمة الصفحة في النافذة", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/get-started/settings-and-preferences/zoom-and-view-options.html" + }, + { + "language": "ar", + "key": "Fit Spread in Window", + "translation": "ملاءمة الصفحة المزدوجة في النافذة", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/get-started/settings-and-preferences/zoom-and-view-options.html" + }, + { + "language": "ar", + "key": "Flattener Preview", + "translation": "معاينة التسطيح", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/apply-color/advanced-color-techniques/transparency-flattener-preset-options.html" + }, + { + "language": "ar", + "key": "Get Story", + "translation": "جلب القصة", + "basis": "consistent terminology in related label", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/language-and-proofing/arabic-and-hebrew/change-text-direction.html" + }, + { + "language": "ar", + "key": "Glyph Scaling:", + "translation": "قياس الحروف الرسومية:", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/tabs-indents-and-spacing/control-glyphing-scaling.html" + }, + { + "language": "ar", + "key": "Gradient", + "translation": "التدرج اللوني", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/apply-color/advanced-color-techniques/modify-and-adjust-gradients.html" + }, + { + "language": "ar", + "key": "Gradient Feather", + "translation": "الحواف المنعمة المتدرجة", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/character-formatting/apply-transparency-effects.html" + }, + { + "language": "ar", + "key": "H&J Violations", + "translation": "انتهاكات H&J", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/tabs-indents-and-spacing/highlight-text-spacing.html" + }, + { + "language": "ar", + "key": "Height", + "translation": "الارتفاع", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/create-and-organize-pages/create-documents/change-document-setup.html" + }, + { + "language": "ar", + "key": "Highlight", + "translation": "الإبراز", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/tabs-indents-and-spacing/highlight-text-spacing.html" + }, + { + "language": "ar", + "key": "Hyphen Limit:", + "translation": "الحد الأقصى للواصلات:", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/composition-and-text-wrapping/control-hyphenation-and-word-breaks.html" + }, + { + "language": "ar", + "key": "Inner Glow", + "translation": "التوهج الداخلي", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/character-formatting/apply-transparency-effects.html" + }, + { + "language": "ar", + "key": "Inner Shadow", + "translation": "الظل الداخلي", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/character-formatting/apply-transparency-effects.html" + }, + { + "language": "ar", + "key": "Insert White Space", + "translation": "إدراج مسافة بيضاء", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/tabs-indents-and-spacing/adjust-text-spacing.html" + }, + { + "language": "ar", + "key": "Kerning", + "translation": "التقنين", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/tabs-indents-and-spacing/highlight-text-spacing.html" + }, + { + "language": "ar", + "key": "Layers", + "translation": "الطبقات", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-graphics-and-media/manage-frames-and-objects/stack-objects.html" + }, + { + "language": "ar", + "key": "Leading", + "translation": "المسافة بين الأسطر", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/tabs-indents-and-spacing/adjust-text-baseline.html" + }, + { + "language": "ar", + "key": "Leading:", + "translation": "المسافة بين الأسطر:", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/tabs-indents-and-spacing/adjust-text-baseline.html" + }, + { + "language": "ar", + "key": "Location:", + "translation": "الموقع:", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/apply-color/advanced-color-techniques/create-and-name-gradient-swatches.html" + }, + { + "language": "ar", + "key": "Margins and Columns…", + "translation": "الهوامش والأعمدة…", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/create-and-organize-pages/create-documents/change-document-setup.html" + }, + { + "language": "ar", + "key": "Metrics", + "translation": "المقاييس", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/tabs-indents-and-spacing/adjust-kerning.html" + }, + { + "language": "ar", + "key": "Mobile", + "translation": "جوال", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/create-and-organize-pages/create-documents/create-new-documents.html" + }, + { + "language": "ar", + "key": "More Options", + "translation": "خيارات أكثر", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/character-formatting/change-text-case.html" + }, + { + "language": "ar", + "key": "Name", + "translation": "الاسم", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/apply-color/advanced-color-techniques/transparency-flattener-preset-options.html" + }, + { + "language": "ar", + "key": "Opacity", + "translation": "العتامة", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/character-formatting/apply-transparency-effects.html" + }, + { + "language": "ar", + "key": "Outer Glow", + "translation": "التوهج الخارجي", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/character-formatting/apply-transparency-effects.html" + }, + { + "language": "ar", + "key": "Overprint Fill", + "translation": "طباعة فوقية للتعبئة", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-tables-and-data/strokes-and-fills/table-stroke-and-fill-options.html" + }, + { + "language": "ar", + "key": "Overprint Stroke", + "translation": "طباعة فوقية للحد", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/add-and-manage-text-frames/set-column-rules.html" + }, + { + "language": "ar", + "key": "Place", + "translation": "وضع", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/add-and-import-text/import-options.html" + }, + { + "language": "ar", + "key": "Place Loaded Graphic", + "translation": "وضع الرسم المحمل", + "basis": "consistent terminology in related label", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/add-and-import-text/import-options.html" + }, + { + "language": "ar", + "key": "Place PDF", + "translation": "وضع PDF", + "basis": "consistent terminology in related label", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/add-and-import-text/import-options.html" + }, + { + "language": "ar", + "key": "Place Snippet", + "translation": "وضع مقتطف", + "basis": "consistent terminology in related label", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/add-and-import-text/import-options.html" + }, + { + "language": "ar", + "key": "Place and Link", + "translation": "وضع وربط", + "basis": "consistent terminology in related label", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/add-and-import-text/import-options.html" + }, + { + "language": "ar", + "key": "Place with Import Options…", + "translation": "وضع مع خيارات الاستيراد…", + "basis": "consistent terminology in related label", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/add-and-import-text/import-options.html" + }, + { + "language": "ar", + "key": "Place…", + "translation": "وضع…", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/add-and-import-text/import-options.html" + }, + { + "language": "ar", + "key": "Position", + "translation": "الموضع", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-graphics-and-media/transform-and-arrange-objects/transform-anchored-objects.html" + }, + { + "language": "ar", + "key": "Quick Actions", + "translation": "الإجراءات السريعة", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/text-styles/auto-style-text-and-paragraphs.html" + }, + { + "language": "ar", + "key": "Remove Document", + "translation": "إزالة المستند", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/create-and-organize-pages/create-and-manage-book-files/remove-replace-documents.html" + }, + { + "language": "ar", + "key": "Replace With", + "translation": "الاستبدال بـ", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/fonts/find-and-replace-fonts.html" + }, + { + "language": "ar", + "key": "Satin", + "translation": "الساتان", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/character-formatting/apply-transparency-effects.html" + }, + { + "language": "ar", + "key": "Set Story Text", + "translation": "تعيين نص القصة", + "basis": "consistent terminology in related label", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/language-and-proofing/arabic-and-hebrew/change-text-direction.html" + }, + { + "language": "ar", + "key": "Shading", + "translation": "التظليل", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/composition-and-text-wrapping/apply-paragraph-borders-and-backgrounds.html" + }, + { + "language": "ar", + "key": "Size", + "translation": "الحجم", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-graphics-and-media/manage-frames-and-objects/apply-object-export-options.html" + }, + { + "language": "ar", + "key": "Small Caps", + "translation": "الأحرف الكبيرة المصغّرة", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/character-formatting/change-text-case.html" + }, + { + "language": "ar", + "key": "Smart Text Reflow", + "translation": "التدفق الذكي للنص", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/add-and-import-text/set-up-smart-text-reflow.html" + }, + { + "language": "ar", + "key": "Space After", + "translation": "المسافة بعد", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/composition-and-text-wrapping/add-or-remove-paragraph-rules.html" + }, + { + "language": "ar", + "key": "Space After:", + "translation": "المسافة بعد:", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/composition-and-text-wrapping/add-or-remove-paragraph-rules.html" + }, + { + "language": "ar", + "key": "Space Before", + "translation": "المسافة قبل", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/type-on-a-path/apply-effects.html" + }, + { + "language": "ar", + "key": "Space Before:", + "translation": "المسافة قبل:", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/type-on-a-path/apply-effects.html" + }, + { + "language": "ar", + "key": "Stop Color:", + "translation": "لون الإيقاف:", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/apply-color/advanced-color-techniques/create-and-name-gradient-swatches.html" + }, + { + "language": "ar", + "key": "Story", + "translation": "القصة", + "basis": "consistent terminology in related label", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/language-and-proofing/arabic-and-hebrew/change-text-direction.html" + }, + { + "language": "ar", + "key": "Story Direction", + "translation": "اتجاه القصة", + "basis": "consistent terminology in related label", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/language-and-proofing/arabic-and-hebrew/change-text-direction.html" + }, + { + "language": "ar", + "key": "Story Editor", + "translation": "محرر القصة", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/add-and-manage-text-frames/open-and-use-story-editor.html" + }, + { + "language": "ar", + "key": "Story Editor Display", + "translation": "عرض محرر القصة", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/add-and-manage-text-frames/open-and-use-story-editor.html" + }, + { + "language": "ar", + "key": "Style Mapping", + "translation": "ربط الأنماط", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/add-and-import-text/import-options.html" + }, + { + "language": "ar", + "key": "Superscript", + "translation": "النص المرتفع", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/character-formatting/apply-drop-caps-text-positioning.html" + }, + { + "language": "ar", + "key": "Text Frame", + "translation": "إطار النص", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-graphics-and-media/manage-object-styles/define-and-apply-object-styles.html" + }, + { + "language": "ar", + "key": "Tracking", + "translation": "التتبع", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/tabs-indents-and-spacing/highlight-text-spacing.html" + }, + { + "language": "ar", + "key": "Tracking:", + "translation": "التتبع:", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/tabs-indents-and-spacing/highlight-text-spacing.html" + }, + { + "language": "ar", + "key": "Transparency Blend Space", + "translation": "مساحة مزج الشفافية", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/apply-color/advanced-color-techniques/control-blending-with-isolation-and-knockouts.html" + }, + { + "language": "ar", + "key": "Type on a Path", + "translation": "الكتابة على مسار", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/type-on-a-path/apply-effects.html" + }, + { + "language": "ar", + "key": "Type on a Path Options…", + "translation": "خيارات الكتابة على مسار…", + "basis": "consistent terminology in related label", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/type-on-a-path/apply-effects.html" + }, + { + "language": "ar", + "key": "Width", + "translation": "العرض", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/add-and-manage-text-frames/change-text-frame-properties.html" + }, + { + "language": "ar", + "key": "story", + "translation": "القصة", + "basis": "consistent terminology in related label", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/language-and-proofing/arabic-and-hebrew/change-text-direction.html" + }, + { + "language": "ar", + "key": "x Height", + "translation": "ارتفاع x", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/tabs-indents-and-spacing/adjust-text-baseline.html" + }, + { + "language": "zh", + "key": "After First:", + "translation": "连字符前最少字符数:", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/format-and-style-text/composition-and-text-wrapping/control-hyphenation-and-word-breaks.html" + }, + { + "language": "zh", + "key": "Align From:", + "translation": "对齐自:", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/add-graphics-and-media/manage-frames-and-objects/fit-object-to-frame.html" + }, + { + "language": "zh", + "key": "Align to baseline grid", + "translation": "对齐基线网格", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/format-and-style-text/character-formatting/adjust-line-spacing-with-leading.html" + }, + { + "language": "zh", + "key": "Allow Selected Spread to Shuffle", + "translation": "允许所选跨页随机排布", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/create-and-organize-pages/create-documents/create-multi-page-spreads.html" + }, + { + "language": "zh", + "key": "Apply Table Style", + "translation": "应用表样式", + "basis": "consistent terminology in related label", + "source": "https://helpx.adobe.com/cn/indesign/desktop/add-and-manage-text/add-and-import-text/import-options.html" + }, + { + "language": "zh", + "key": "Ascent", + "translation": "上升高度", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/format-and-style-text/tabs-indents-and-spacing/adjust-text-baseline.html" + }, + { + "language": "zh", + "key": "Before Last:", + "translation": "连字符后最少字符数:", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/format-and-style-text/composition-and-text-wrapping/control-hyphenation-and-word-breaks.html" + }, + { + "language": "zh", + "key": "Body Rows", + "translation": "表体行", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/add-tables-and-data/table-and-cell-styles/table-and-cell-styles.html" + }, + { + "language": "zh", + "key": "Bring Forward", + "translation": "向前移动", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/add-graphics-and-media/manage-frames-and-objects/stack-objects.html" + }, + { + "language": "zh", + "key": "Captions", + "translation": "题注", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/indexes-and-references/references-and-bookmarks/create-image-captions.html" + }, + { + "language": "zh", + "key": "Convert to Liquid Guide", + "translation": "转换为自适应参考线", + "basis": "documented feature terminology", + "source": "https://helpx.adobe.com/cn/indesign/using/alternate-layouts-liquid-layouts.html" + }, + { + "language": "zh", + "key": "Custom Tracking/Kerning", + "translation": "自定义字距调整/字距微调", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/format-and-style-text/composition-and-text-wrapping/set-text-composition.html" + }, + { + "language": "zh", + "key": "Dimensions Include Stroke Weight", + "translation": "尺寸包括描边粗细", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/add-graphics-and-media/transform-and-arrange-objects/object-transformation-settings-options.html" + }, + { + "language": "zh", + "key": "Discretionary Ligatures", + "translation": "自由连字", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/format-and-style-text/character-formatting/apply-ligatures-to-letter-pairs.html" + }, + { + "language": "zh", + "key": "Entire Pasteboard", + "translation": "完整粘贴板", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/get-started/settings-and-preferences/zoom-and-view-options.html" + }, + { + "language": "zh", + "key": "Fill with Placeholder Text", + "translation": "用占位符文本填充", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/add-and-manage-text/add-and-import-text/add-text-to-documents.html" + }, + { + "language": "zh", + "key": "First Object Above", + "translation": "上方第一个对象", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/add-graphics-and-media/manage-frames-and-objects/select-nested-overlapping-objects.html" + }, + { + "language": "zh", + "key": "Fit Content Proportionally", + "translation": "按比例适合内容", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/add-graphics-and-media/manage-frames-and-objects/fit-object-to-frame.html" + }, + { + "language": "zh", + "key": "Fit Frame to Content", + "translation": "使框架适合内容", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/add-graphics-and-media/manage-frames-and-objects/fit-object-to-frame.html" + }, + { + "language": "zh", + "key": "Generate Static Caption", + "translation": "生成静态题注", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/indexes-and-references/references-and-bookmarks/create-image-captions.html" + }, + { + "language": "zh", + "key": "Glyph Scaling:", + "translation": "字形缩放比例:", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/format-and-style-text/tabs-indents-and-spacing/control-glyphing-scaling.html" + }, + { + "language": "zh", + "key": "Guides & Pasteboard", + "translation": "参考线和粘贴板", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/create-and-organize-pages/create-documents/change-document-setup.html" + }, + { + "language": "zh", + "key": "High Quality", + "translation": "高品质", + "basis": "consistent terminology in related label", + "source": "https://helpx.adobe.com/cn/indesign/desktop/get-started/settings-and-preferences/keyboard-shortcuts.html" + }, + { + "language": "zh", + "key": "High Quality Display", + "translation": "高品质显示", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/get-started/settings-and-preferences/keyboard-shortcuts.html" + }, + { + "language": "zh", + "key": "Hyphen Limit:", + "translation": "连字符限制:", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/format-and-style-text/composition-and-text-wrapping/control-hyphenation-and-word-breaks.html" + }, + { + "language": "zh", + "key": "Info", + "translation": "信息", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/add-and-manage-text/add-and-manage-text-frames/word-character-count.html" + }, + { + "language": "zh", + "key": "Interactive", + "translation": "交互式", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/indexes-and-references/references-and-bookmarks/arrange-group-sort-bookmarks.html" + }, + { + "language": "zh", + "key": "Kenten", + "translation": "圈点", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/language-and-proofing/chinese-japanese-and-korean/apply-kenten-to-text.html" + }, + { + "language": "zh", + "key": "Line", + "translation": "直线", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/add-and-manage-text/add-and-manage-text-frames/set-column-rules.html" + }, + { + "language": "zh", + "key": "Liquid Layout", + "translation": "自适应版面", + "basis": "documented feature terminology", + "source": "https://helpx.adobe.com/cn/indesign/using/alternate-layouts-liquid-layouts.html" + }, + { + "language": "zh", + "key": "Liquid Page Rule", + "translation": "自适应页面规则", + "basis": "documented feature terminology", + "source": "https://helpx.adobe.com/cn/indesign/using/alternate-layouts-liquid-layouts.html" + }, + { + "language": "zh", + "key": "Liquid guides", + "translation": "自适应参考线", + "basis": "documented feature terminology", + "source": "https://helpx.adobe.com/cn/indesign/using/alternate-layouts-liquid-layouts.html" + }, + { + "language": "zh", + "key": "Mobile", + "translation": "移动设备", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/create-and-organize-pages/create-documents/create-documents-with-presets.html" + }, + { + "language": "zh", + "key": "New Page Reference…", + "translation": "新建页面引用…", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/indexes-and-references/create-an-index/create-index-entries.html" + }, + { + "language": "zh", + "key": "New Table Style…", + "translation": "新建表样式…", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/add-tables-and-data/table-and-cell-styles/table-and-cell-styles.html" + }, + { + "language": "zh", + "key": "Next Object Above", + "translation": "上方下一个对象", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/add-graphics-and-media/manage-frames-and-objects/select-nested-overlapping-objects.html" + }, + { + "language": "zh", + "key": "Next Object Below", + "translation": "下方下一个对象", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/add-graphics-and-media/manage-frames-and-objects/select-nested-overlapping-objects.html" + }, + { + "language": "zh", + "key": "Nonprinting", + "translation": "非打印", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/add-graphics-and-media/transform-and-arrange-objects/create-hidden-nonprinting-objects.html" + }, + { + "language": "zh", + "key": "Numbering & Section Options…", + "translation": "页码和章节选项…", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/create-and-organize-pages/page-numbers-chapters-and-sections/add-page-numbers-and-markers.html" + }, + { + "language": "zh", + "key": "Object Liquid Settings", + "translation": "对象自适应设置", + "basis": "consistent terminology in related label", + "source": "https://helpx.adobe.com/cn/indesign/using/alternate-layouts-liquid-layouts.html" + }, + { + "language": "zh", + "key": "Paste without Formatting", + "translation": "粘贴时不包含格式", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/add-and-manage-text/add-and-import-text/add-text-to-documents.html" + }, + { + "language": "zh", + "key": "Remove Styles and Formatting from Text and Tables", + "translation": "从文本和表格中删除样式和格式", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/add-and-manage-text/add-and-import-text/import-options.html" + }, + { + "language": "zh", + "key": "Revert", + "translation": "还原", + "basis": "documented menu control terminology (Edit > Undo / File > Revert)", + "source": "https://helpx.adobe.com/cn/indesign/desktop/get-started/settings-and-preferences/undo-redo-edits.html" + }, + { + "language": "zh", + "key": "Ruby", + "translation": "旁注", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/language-and-proofing/chinese-japanese-and-korean/add-and-format-ruby-text-annotations.html" + }, + { + "language": "zh", + "key": "Ruby:", + "translation": "旁注:", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/language-and-proofing/chinese-japanese-and-korean/add-and-format-ruby-text-annotations.html" + }, + { + "language": "zh", + "key": "Ruby…", + "translation": "旁注…", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/language-and-proofing/chinese-japanese-and-korean/add-and-format-ruby-text-annotations.html" + }, + { + "language": "zh", + "key": "Send Backward", + "translation": "向后移动", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/add-graphics-and-media/manage-frames-and-objects/stack-objects.html" + }, + { + "language": "zh", + "key": "Show All on Spread", + "translation": "在跨页上全部显示", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/add-graphics-and-media/transform-and-arrange-objects/create-hidden-nonprinting-objects.html" + }, + { + "language": "zh", + "key": "Show Prefix/Suffix in:", + "translation": "显示前缀/后缀位置:", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/indexes-and-references/footnotes-and-endnotes/change-footnote-numbering-and-layout-options.html" + }, + { + "language": "zh", + "key": "Show Tag Markers", + "translation": "显示标签标志符", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/add-and-manage-text/conditional-and-variable-text/marker-types.html" + }, + { + "language": "zh", + "key": "Snap to Document Grid", + "translation": "靠齐文档网格", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/layout-and-grid-tools/grids/snap-objects-to-grids.html" + }, + { + "language": "zh", + "key": "Snap to Guides", + "translation": "靠齐参考线", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/layout-and-grid-tools/grids/snap-objects-to-grids.html" + }, + { + "language": "zh", + "key": "Snap to Zone", + "translation": "靠齐区域", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/layout-and-grid-tools/grids/snap-objects-to-grids.html" + }, + { + "language": "zh", + "key": "Span Footnotes Across Columns", + "translation": "跨栏分布脚注", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/indexes-and-references/footnotes-and-endnotes/span-footnotes.html" + }, + { + "language": "zh", + "key": "Start at:", + "translation": "起始编号:", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/indexes-and-references/footnotes-and-endnotes/change-endnote-numbering-and-layout.html" + }, + { + "language": "zh", + "key": "Stop Color:", + "translation": "停止点颜色:", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/apply-color/advanced-color-techniques/create-and-name-gradient-swatches.html" + }, + { + "language": "zh", + "key": "Substituted Fonts", + "translation": "替换字体", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/format-and-style-text/composition-and-text-wrapping/set-text-composition.html" + }, + { + "language": "zh", + "key": "Table Options", + "translation": "表选项", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/add-tables-and-data/table-and-cell-styles/override-table-style.html" + }, + { + "language": "zh", + "key": "Table Style", + "translation": "表样式", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/add-and-manage-text/add-and-import-text/import-options.html" + }, + { + "language": "zh", + "key": "Table Style Options…", + "translation": "表样式选项…", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/add-tables-and-data/table-and-cell-styles/table-and-cell-styles.html" + }, + { + "language": "zh", + "key": "Tate-Chu-Yoko", + "translation": "纵中横", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/language-and-proofing/chinese-japanese-and-korean/apply-tate-chu-yoko-in-vertical-text.html" + }, + { + "language": "zh", + "key": "Track Changes", + "translation": "修订", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/add-and-manage-text/add-and-import-text/import-options.html" + }, + { + "language": "zh", + "key": "Tracking", + "translation": "字距调整", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/format-and-style-text/tabs-indents-and-spacing/adjust-tracking.html" + }, + { + "language": "zh", + "key": "Tracking:", + "translation": "字距调整:", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/format-and-style-text/tabs-indents-and-spacing/adjust-tracking.html" + }, + { + "language": "zh", + "key": "Transformations are Totals", + "translation": "变换为总计", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/add-graphics-and-media/transform-and-arrange-objects/object-transformation-settings-options.html" + }, + { + "language": "zh", + "key": "Transparency Flattener Presets", + "translation": "透明度拼合器预设", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/apply-color/advanced-color-techniques/create-custom-transparency-flattener-presets.html" + }, + { + "language": "zh", + "key": "Undo", + "translation": "还原", + "basis": "documented menu control terminology (Edit > Undo / File > Revert)", + "source": "https://helpx.adobe.com/cn/indesign/desktop/get-started/settings-and-preferences/undo-redo-edits.html" + }, + { + "language": "zh", + "key": "Units & Increments", + "translation": "单位和增量", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/format-and-style-text/tabs-indents-and-spacing/adjust-kerning.html" + }, + { + "language": "zh", + "key": "Words with at Least:", + "translation": "单词最少字符数:", + "basis": "documented control terminology", + "source": "https://helpx.adobe.com/cn/indesign/desktop/format-and-style-text/composition-and-text-wrapping/control-hyphenation-and-word-breaks.html" + } + ] +} From ffcc2ef320de7c337533618749e8f8d56e732576 Mon Sep 17 00:00:00 2001 From: Shao Wenbin <shaowenbin.salih@gmail.com> Date: Wed, 7 Oct 2026 19:20:48 +0800 Subject: [PATCH 08/13] erf(i18n): cache translations and skip unnecessary bidi analysis tr() uses a OnceLock<HashMap>, initialized once, changing lookups from a linear scan to average O(1). Labels without RTL text, such as Chinese, Japanese, and Western-language labels, skip BidiInfo. Arabic, Hebrew, and directional controls retain their existing handling. --- crates/ui-egui/src/i18n.rs | 28 +++++++++++++---- crates/ui-egui/src/rtl.rs | 61 +++++++++++++++++++++++++++++++++++--- 2 files changed, 80 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 27e20f511..7047f8e9e 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -3,6 +3,8 @@ //! Chinese and Arabic feature terminology is checked against public Adobe help where documented; //! project-specific labels are original. Sources and scope: `docs/localization-terminology.json`. +use std::{collections::HashMap, sync::OnceLock}; + mod ar; /// Supported interface languages: (code, name in that language). @@ -2635,14 +2637,13 @@ fn column(lang: &str) -> Option<usize> { /// `s` in `lang` (English, or the string itself, when there's no translation). pub fn tr<'a>(lang: &str, s: &'a str) -> &'a str { + static TRANSLATIONS: OnceLock<HashMap<&'static str, [&'static str; 5]>> = OnceLock::new(); + static ARABIC: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new(); if lang == "ar" { - return ar::TABLE.iter().find(|(en, _)| *en == s).map_or(s, |(_, t)| *t); + return ARABIC.get_or_init(|| ar::TABLE.iter().copied().collect()).get(s).copied().unwrap_or(s); } let Some(c) = column(lang) else { return s }; - match TABLE.iter().find(|(en, _)| *en == s) { - Some((_, t)) => t[c], - None => s, - } + TRANSLATIONS.get_or_init(|| TABLE.iter().copied().collect()).get(s).and_then(|row| row.get(c)).copied().unwrap_or(s) } /// Localize reserved built-in style names only; user-defined names are document data. @@ -2665,6 +2666,23 @@ pub fn workspace_name<'a>(lang: &str, name: &'a str) -> &'a str { mod tests { use super::*; + #[test] + fn cached_lookup_preserves_every_translation_across_language_switches() { + for (key, translations) in TABLE { + for (lang, expected) in ["de", "fr", "es", "ja", "zh"].into_iter().zip(translations) { + assert_eq!(tr(lang, key), *expected, "{lang}: {key}"); + } + assert_eq!(tr("", key), *key); + } + for (key, expected) in ar::TABLE { + assert_eq!(tr("ar", key), *expected, "ar: {key}"); + } + let unknown = String::from("A user-defined untranslated label"); + for lang in ["ar", "zh", "ja", "de", "fr", "es", "", "unknown"] { + assert!(std::ptr::eq(tr(lang, &unknown), unknown.as_str())); + } + } + #[test] fn translates_known_strings_and_keeps_the_rest() { assert_eq!(tr("de", "File"), "Datei"); diff --git a/crates/ui-egui/src/rtl.rs b/crates/ui-egui/src/rtl.rs index 18ad6ab04..bc042c7ab 100644 --- a/crates/ui-egui/src/rtl.rs +++ b/crates/ui-egui/src/rtl.rs @@ -3,13 +3,40 @@ use egui::text::{LayoutJob, TextWrapping}; use egui::{Align, Align2, Color32, FontFamily, FontId, Galley, Pos2, Rect, WidgetText}; use std::{ops::Range, sync::Arc}; -use unicode_bidi::BidiInfo; +use unicode_bidi::{BidiClass, BidiInfo, bidi_class}; use unicode_segmentation::UnicodeSegmentation; +/// Allocation-free preflight: ordinary Latin, CJK and emoji labels need no bidi analysis. +/// Include directional controls so explicit embeddings/overrides keep their existing behavior. +fn may_have_rtl(text: &str) -> bool { + !text.is_ascii() + && text.chars().any(|c| { + matches!( + bidi_class(c), + BidiClass::R + | BidiClass::AL + | BidiClass::AN + | BidiClass::LRE + | BidiClass::RLE + | BidiClass::LRO + | BidiClass::RLO + | BidiClass::PDF + | BidiClass::LRI + | BidiClass::RLI + | BidiClass::FSI + | BidiClass::PDI + ) + }) +} + +fn has_rtl(text: &str) -> bool { + may_have_rtl(text) && BidiInfo::new(text, None).has_rtl() +} + /// Resolve translated labels through bidi, without changing IDs, commands or stored text. pub fn widget(ui: &egui::Ui, text: impl Into<WidgetText>) -> WidgetText { let text = text.into(); - if text.text().is_ascii() || !BidiInfo::new(text.text(), None).has_rtl() { + if !has_rtl(text.text()) { return text; } let mut job = (*text.into_layout_job(ui.style(), egui::FontSelection::Default, Align::Center)).clone(); @@ -21,7 +48,7 @@ pub fn widget(ui: &egui::Ui, text: impl Into<WidgetText>) -> WidgetText { /// Accessibility still receives the original logical string through Galley::job. pub fn label(ui: &mut egui::Ui, text: impl Into<WidgetText>) -> egui::Response { let text = text.into(); - let rtl = !text.text().is_ascii() && BidiInfo::new(text.text(), None).has_rtl(); + let rtl = has_rtl(text.text()); if !rtl { return ui.label(text); } @@ -112,7 +139,7 @@ fn visual_line(job: &LayoutJob, bidi: &BidiInfo<'_>, line: Range<usize>) -> Layo } fn layout(ctx: &egui::Context, mut job: LayoutJob) -> Arc<Galley> { - if job.text.is_ascii() || !BidiInfo::new(&job.text, None).has_rtl() { + if !has_rtl(&job.text) { return ctx.fonts_mut(|fonts| fonts.layout_job(job)); } let original = Arc::new(job.clone()); @@ -219,6 +246,32 @@ mod tests { galley.rows.iter().flat_map(|row| row.glyphs.iter().filter(|g| g.advance_width > 0.0).map(|g| g.chr)).collect() } + #[test] + fn ordinary_multilingual_labels_skip_bidi_analysis() { + for text in ["", "File (123)", "Édition", "中文排版", "スウォッチ", "e\u{301}", "🎨 🙂"] { + assert!(!may_have_rtl(text), "{text:?}"); + assert!(!BidiInfo::new(text, None).has_rtl(), "{text:?}"); + } + for text in ["ملف", "עברית", "PDF ملف 123", "\u{1e900}", "\u{200f}", "\u{202e}abc\u{202c}", "\u{2067}abc\u{2069}", "١٢٣"] { + assert!(may_have_rtl(text), "{text:?}"); + assert_eq!(has_rtl(text), BidiInfo::new(text, None).has_rtl(), "{text:?}"); + } + } + + #[test] + fn non_rtl_widgets_keep_native_text_and_formatting() { + with_fonts(|ctx| { + egui::Area::new(egui::Id::new("rtl-fast-path-test")).show(ctx, |ui| { + for text in ["中文", "日本語", "Édition", "🎨"] { + let rendered = widget(ui, egui::RichText::new(text).strong()); + assert!(matches!(rendered, WidgetText::RichText(_))); + assert_eq!(rendered.text(), text); + assert!(label(ui, text).rect.is_finite()); + } + }); + }); + } + #[test] fn mixed_arabic_latin_digits_and_brackets_preserve_logical_source() { with_fonts(|ctx| { From 7f0d94b24f396f35495377cbed7f724cfa4b24c5 Mon Sep 17 00:00:00 2001 From: Shao Wenbin <shaowenbin.salih@gmail.com> Date: Wed, 7 Oct 2026 19:21:27 +0800 Subject: [PATCH 09/13] Delete localization-terminology.json --- docs/localization-terminology.json | 1241 ---------------------------- 1 file changed, 1241 deletions(-) delete mode 100644 docs/localization-terminology.json diff --git a/docs/localization-terminology.json b/docs/localization-terminology.json deleted file mode 100644 index b2a8f9699..000000000 --- a/docs/localization-terminology.json +++ /dev/null @@ -1,1241 +0,0 @@ -{ - "checked_on": "2026-10-07", - "scope": "Feature terminology verified against public Adobe InDesign help, with related labels made consistent. This is not a complete official UI string catalog. Unverified or ambiguous entries and DesignCraft-specific messages retain their existing translations.", - "arabic_scope": "Adobe documents English or French interfaces for its Arabic-capable editions. Arabic entries here follow Arabic help terminology, not an official Arabic application interface.", - "language_source": "https://helpx.adobe.com/indesign/desktop/get-started/system-and-product-info/available-languages.html", - "review_method": "English/localized control-name alignment was used only to find candidates. Changes were selected by meaning; differing document order, conflicting alternatives, prose accidentally styled as controls and unrelated matches were rejected. Existing wording was retained when already documented. Related-label entries adopt verified terms but do not claim their whole label is an exact Adobe UI string.", - "entries": [ - { - "language": "ar", - "key": "Add Unnamed Colors", - "translation": "إضافة ألوان غير مُسماة", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/apply-color/define-and-manage-color-assets/create-and-define-new-swatches.html" - }, - { - "language": "ar", - "key": "Advanced Type", - "translation": "كتابة متقدمة", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/character-formatting/change-text-case.html" - }, - { - "language": "ar", - "key": "Align From:", - "translation": "محاذاة من:", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-graphics-and-media/manage-frames-and-objects/fit-object-to-frame.html" - }, - { - "language": "ar", - "key": "Alignment", - "translation": "المحاذاة", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/type-on-a-path/apply-effects.html" - }, - { - "language": "ar", - "key": "All Caps", - "translation": "جميع الأحرف كبيرة", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/character-formatting/change-text-case.html" - }, - { - "language": "ar", - "key": "Apply Character Style", - "translation": "تطبيق نمط الحروف", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/text-styles/add-and-manage-text-styles-from-cc-libraries.html" - }, - { - "language": "ar", - "key": "Apply Gradient", - "translation": "تطبيق التدرج اللوني", - "basis": "consistent terminology in related label", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/apply-color/advanced-color-techniques/modify-and-adjust-gradients.html" - }, - { - "language": "ar", - "key": "Ascent", - "translation": "الصعود", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/tabs-indents-and-spacing/adjust-text-baseline.html" - }, - { - "language": "ar", - "key": "Attributes", - "translation": "السمات", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-graphics-and-media/transform-and-arrange-objects/create-hidden-nonprinting-objects.html" - }, - { - "language": "ar", - "key": "Auto-Fit", - "translation": "الملاءمة التلقائية", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-graphics-and-media/manage-frames-and-objects/fit-object-to-frame.html" - }, - { - "language": "ar", - "key": "Based On:", - "translation": "مبني على:", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-graphics-and-media/manage-object-styles/define-and-apply-object-styles.html" - }, - { - "language": "ar", - "key": "Basic Feather", - "translation": "الحواف المنعمة الأساسية", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/character-formatting/apply-transparency-effects.html" - }, - { - "language": "ar", - "key": "Bevel and Emboss", - "translation": "القطع المشطوف والنقش البارز", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/character-formatting/apply-transparency-effects.html" - }, - { - "language": "ar", - "key": "Change", - "translation": "تغيير", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-graphics-and-media/manage-frames-and-objects/find-replace-objects.html" - }, - { - "language": "ar", - "key": "Change All", - "translation": "تغيير الكل", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/add-and-import-text/find-replace-text.html" - }, - { - "language": "ar", - "key": "Change Case", - "translation": "تغيير الحالة", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/character-formatting/change-text-case.html" - }, - { - "language": "ar", - "key": "Change To", - "translation": "تغيير إلى", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/add-and-import-text/find-replace-text.html" - }, - { - "language": "ar", - "key": "Change to:", - "translation": "تغيير إلى:", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/add-and-import-text/find-replace-text.html" - }, - { - "language": "ar", - "key": "Character Style:", - "translation": "نمط الحروف:", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/character-formatting/apply-drop-caps-text-positioning.html" - }, - { - "language": "ar", - "key": "Characters", - "translation": "الأحرف", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/composition-and-text-wrapping/apply-and-manage-text-formatting.html" - }, - { - "language": "ar", - "key": "Color Picker", - "translation": "أداة اختيار الألوان", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/apply-color/define-and-manage-color-assets/apply-colors-to-text-objects-and-backgrounds.html" - }, - { - "language": "ar", - "key": "Columns", - "translation": "الأعمدة", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/add-and-manage-text-frames/change-text-frame-properties.html" - }, - { - "language": "ar", - "key": "Composition", - "translation": "التركيب", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/composition-and-text-wrapping/apply-text-wrap.html" - }, - { - "language": "ar", - "key": "Custom Tracking/Kerning", - "translation": "التتبع/التقنين المخصص", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/tabs-indents-and-spacing/highlight-text-spacing.html" - }, - { - "language": "ar", - "key": "Delete Swatch", - "translation": "حذف عينة الألوان", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/apply-color/define-and-manage-color-assets/create-mixed-ink-swatches-and-groups.html" - }, - { - "language": "ar", - "key": "Desired", - "translation": "المرغوب", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/tabs-indents-and-spacing/adjust-text-spacing.html" - }, - { - "language": "ar", - "key": "Directional Feather", - "translation": "الحواف المنعمة الاتجاهية", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/character-formatting/apply-transparency-effects.html" - }, - { - "language": "ar", - "key": "Document Presets", - "translation": "الإعدادات المسبقة للمستند", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/create-and-organize-pages/create-documents/create-documents-with-presets.html" - }, - { - "language": "ar", - "key": "Drop Shadow", - "translation": "إسقاط الظل", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/character-formatting/apply-transparency-effects.html" - }, - { - "language": "ar", - "key": "Edit Story", - "translation": "تحرير القصة", - "basis": "consistent terminology in related label", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/language-and-proofing/arabic-and-hebrew/change-text-direction.html" - }, - { - "language": "ar", - "key": "Edit in Story Editor", - "translation": "تحرير في محرر القصة", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/add-and-manage-text-frames/open-and-use-story-editor.html" - }, - { - "language": "ar", - "key": "Fast Display", - "translation": "العرض السريع", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/apply-color/advanced-color-techniques/change-transparency-display-settings.html" - }, - { - "language": "ar", - "key": "Fill Frame Proportionally", - "translation": "ملء الإطار بشكل متناسب", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-graphics-and-media/manage-frames-and-objects/fit-object-to-frame.html" - }, - { - "language": "ar", - "key": "Find Next", - "translation": "البحث عن التالي", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/add-and-import-text/find-replace-text.html" - }, - { - "language": "ar", - "key": "Find What", - "translation": "البحث عن", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/add-and-import-text/find-replace-text.html" - }, - { - "language": "ar", - "key": "Find/Change", - "translation": "البحث/التغيير", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-graphics-and-media/manage-frames-and-objects/find-replace-objects.html" - }, - { - "language": "ar", - "key": "Find/Change…", - "translation": "البحث/التغيير…", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-graphics-and-media/manage-frames-and-objects/find-replace-objects.html" - }, - { - "language": "ar", - "key": "Find/Replace Font", - "translation": "البحث عن الخط/استبداله", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/fonts/find-and-replace-fonts.html" - }, - { - "language": "ar", - "key": "Find/Replace Font…", - "translation": "البحث عن الخط/استبداله…", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/fonts/find-and-replace-fonts.html" - }, - { - "language": "ar", - "key": "Fit Content Proportionally", - "translation": "ملاءمة المحتوى بشكل متناسب", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-graphics-and-media/manage-frames-and-objects/fit-object-to-frame.html" - }, - { - "language": "ar", - "key": "Fit Content to Frame", - "translation": "ملاءمة المحتوى مع الإطار", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-graphics-and-media/manage-frames-and-objects/fit-object-to-frame.html" - }, - { - "language": "ar", - "key": "Fit Frame to Content", - "translation": "ملاءمة الإطار مع المحتوى", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-graphics-and-media/manage-frames-and-objects/fit-object-to-frame.html" - }, - { - "language": "ar", - "key": "Fit Page in Window", - "translation": "ملاءمة الصفحة في النافذة", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/get-started/settings-and-preferences/zoom-and-view-options.html" - }, - { - "language": "ar", - "key": "Fit Spread in Window", - "translation": "ملاءمة الصفحة المزدوجة في النافذة", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/get-started/settings-and-preferences/zoom-and-view-options.html" - }, - { - "language": "ar", - "key": "Flattener Preview", - "translation": "معاينة التسطيح", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/apply-color/advanced-color-techniques/transparency-flattener-preset-options.html" - }, - { - "language": "ar", - "key": "Get Story", - "translation": "جلب القصة", - "basis": "consistent terminology in related label", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/language-and-proofing/arabic-and-hebrew/change-text-direction.html" - }, - { - "language": "ar", - "key": "Glyph Scaling:", - "translation": "قياس الحروف الرسومية:", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/tabs-indents-and-spacing/control-glyphing-scaling.html" - }, - { - "language": "ar", - "key": "Gradient", - "translation": "التدرج اللوني", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/apply-color/advanced-color-techniques/modify-and-adjust-gradients.html" - }, - { - "language": "ar", - "key": "Gradient Feather", - "translation": "الحواف المنعمة المتدرجة", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/character-formatting/apply-transparency-effects.html" - }, - { - "language": "ar", - "key": "H&J Violations", - "translation": "انتهاكات H&J", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/tabs-indents-and-spacing/highlight-text-spacing.html" - }, - { - "language": "ar", - "key": "Height", - "translation": "الارتفاع", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/create-and-organize-pages/create-documents/change-document-setup.html" - }, - { - "language": "ar", - "key": "Highlight", - "translation": "الإبراز", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/tabs-indents-and-spacing/highlight-text-spacing.html" - }, - { - "language": "ar", - "key": "Hyphen Limit:", - "translation": "الحد الأقصى للواصلات:", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/composition-and-text-wrapping/control-hyphenation-and-word-breaks.html" - }, - { - "language": "ar", - "key": "Inner Glow", - "translation": "التوهج الداخلي", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/character-formatting/apply-transparency-effects.html" - }, - { - "language": "ar", - "key": "Inner Shadow", - "translation": "الظل الداخلي", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/character-formatting/apply-transparency-effects.html" - }, - { - "language": "ar", - "key": "Insert White Space", - "translation": "إدراج مسافة بيضاء", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/tabs-indents-and-spacing/adjust-text-spacing.html" - }, - { - "language": "ar", - "key": "Kerning", - "translation": "التقنين", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/tabs-indents-and-spacing/highlight-text-spacing.html" - }, - { - "language": "ar", - "key": "Layers", - "translation": "الطبقات", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-graphics-and-media/manage-frames-and-objects/stack-objects.html" - }, - { - "language": "ar", - "key": "Leading", - "translation": "المسافة بين الأسطر", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/tabs-indents-and-spacing/adjust-text-baseline.html" - }, - { - "language": "ar", - "key": "Leading:", - "translation": "المسافة بين الأسطر:", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/tabs-indents-and-spacing/adjust-text-baseline.html" - }, - { - "language": "ar", - "key": "Location:", - "translation": "الموقع:", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/apply-color/advanced-color-techniques/create-and-name-gradient-swatches.html" - }, - { - "language": "ar", - "key": "Margins and Columns…", - "translation": "الهوامش والأعمدة…", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/create-and-organize-pages/create-documents/change-document-setup.html" - }, - { - "language": "ar", - "key": "Metrics", - "translation": "المقاييس", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/tabs-indents-and-spacing/adjust-kerning.html" - }, - { - "language": "ar", - "key": "Mobile", - "translation": "جوال", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/create-and-organize-pages/create-documents/create-new-documents.html" - }, - { - "language": "ar", - "key": "More Options", - "translation": "خيارات أكثر", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/character-formatting/change-text-case.html" - }, - { - "language": "ar", - "key": "Name", - "translation": "الاسم", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/apply-color/advanced-color-techniques/transparency-flattener-preset-options.html" - }, - { - "language": "ar", - "key": "Opacity", - "translation": "العتامة", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/character-formatting/apply-transparency-effects.html" - }, - { - "language": "ar", - "key": "Outer Glow", - "translation": "التوهج الخارجي", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/character-formatting/apply-transparency-effects.html" - }, - { - "language": "ar", - "key": "Overprint Fill", - "translation": "طباعة فوقية للتعبئة", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-tables-and-data/strokes-and-fills/table-stroke-and-fill-options.html" - }, - { - "language": "ar", - "key": "Overprint Stroke", - "translation": "طباعة فوقية للحد", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/add-and-manage-text-frames/set-column-rules.html" - }, - { - "language": "ar", - "key": "Place", - "translation": "وضع", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/add-and-import-text/import-options.html" - }, - { - "language": "ar", - "key": "Place Loaded Graphic", - "translation": "وضع الرسم المحمل", - "basis": "consistent terminology in related label", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/add-and-import-text/import-options.html" - }, - { - "language": "ar", - "key": "Place PDF", - "translation": "وضع PDF", - "basis": "consistent terminology in related label", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/add-and-import-text/import-options.html" - }, - { - "language": "ar", - "key": "Place Snippet", - "translation": "وضع مقتطف", - "basis": "consistent terminology in related label", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/add-and-import-text/import-options.html" - }, - { - "language": "ar", - "key": "Place and Link", - "translation": "وضع وربط", - "basis": "consistent terminology in related label", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/add-and-import-text/import-options.html" - }, - { - "language": "ar", - "key": "Place with Import Options…", - "translation": "وضع مع خيارات الاستيراد…", - "basis": "consistent terminology in related label", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/add-and-import-text/import-options.html" - }, - { - "language": "ar", - "key": "Place…", - "translation": "وضع…", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/add-and-import-text/import-options.html" - }, - { - "language": "ar", - "key": "Position", - "translation": "الموضع", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-graphics-and-media/transform-and-arrange-objects/transform-anchored-objects.html" - }, - { - "language": "ar", - "key": "Quick Actions", - "translation": "الإجراءات السريعة", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/text-styles/auto-style-text-and-paragraphs.html" - }, - { - "language": "ar", - "key": "Remove Document", - "translation": "إزالة المستند", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/create-and-organize-pages/create-and-manage-book-files/remove-replace-documents.html" - }, - { - "language": "ar", - "key": "Replace With", - "translation": "الاستبدال بـ", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/fonts/find-and-replace-fonts.html" - }, - { - "language": "ar", - "key": "Satin", - "translation": "الساتان", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/character-formatting/apply-transparency-effects.html" - }, - { - "language": "ar", - "key": "Set Story Text", - "translation": "تعيين نص القصة", - "basis": "consistent terminology in related label", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/language-and-proofing/arabic-and-hebrew/change-text-direction.html" - }, - { - "language": "ar", - "key": "Shading", - "translation": "التظليل", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/composition-and-text-wrapping/apply-paragraph-borders-and-backgrounds.html" - }, - { - "language": "ar", - "key": "Size", - "translation": "الحجم", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-graphics-and-media/manage-frames-and-objects/apply-object-export-options.html" - }, - { - "language": "ar", - "key": "Small Caps", - "translation": "الأحرف الكبيرة المصغّرة", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/character-formatting/change-text-case.html" - }, - { - "language": "ar", - "key": "Smart Text Reflow", - "translation": "التدفق الذكي للنص", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/add-and-import-text/set-up-smart-text-reflow.html" - }, - { - "language": "ar", - "key": "Space After", - "translation": "المسافة بعد", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/composition-and-text-wrapping/add-or-remove-paragraph-rules.html" - }, - { - "language": "ar", - "key": "Space After:", - "translation": "المسافة بعد:", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/composition-and-text-wrapping/add-or-remove-paragraph-rules.html" - }, - { - "language": "ar", - "key": "Space Before", - "translation": "المسافة قبل", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/type-on-a-path/apply-effects.html" - }, - { - "language": "ar", - "key": "Space Before:", - "translation": "المسافة قبل:", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/type-on-a-path/apply-effects.html" - }, - { - "language": "ar", - "key": "Stop Color:", - "translation": "لون الإيقاف:", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/apply-color/advanced-color-techniques/create-and-name-gradient-swatches.html" - }, - { - "language": "ar", - "key": "Story", - "translation": "القصة", - "basis": "consistent terminology in related label", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/language-and-proofing/arabic-and-hebrew/change-text-direction.html" - }, - { - "language": "ar", - "key": "Story Direction", - "translation": "اتجاه القصة", - "basis": "consistent terminology in related label", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/language-and-proofing/arabic-and-hebrew/change-text-direction.html" - }, - { - "language": "ar", - "key": "Story Editor", - "translation": "محرر القصة", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/add-and-manage-text-frames/open-and-use-story-editor.html" - }, - { - "language": "ar", - "key": "Story Editor Display", - "translation": "عرض محرر القصة", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/add-and-manage-text-frames/open-and-use-story-editor.html" - }, - { - "language": "ar", - "key": "Style Mapping", - "translation": "ربط الأنماط", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/add-and-import-text/import-options.html" - }, - { - "language": "ar", - "key": "Superscript", - "translation": "النص المرتفع", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/character-formatting/apply-drop-caps-text-positioning.html" - }, - { - "language": "ar", - "key": "Text Frame", - "translation": "إطار النص", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-graphics-and-media/manage-object-styles/define-and-apply-object-styles.html" - }, - { - "language": "ar", - "key": "Tracking", - "translation": "التتبع", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/tabs-indents-and-spacing/highlight-text-spacing.html" - }, - { - "language": "ar", - "key": "Tracking:", - "translation": "التتبع:", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/tabs-indents-and-spacing/highlight-text-spacing.html" - }, - { - "language": "ar", - "key": "Transparency Blend Space", - "translation": "مساحة مزج الشفافية", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/apply-color/advanced-color-techniques/control-blending-with-isolation-and-knockouts.html" - }, - { - "language": "ar", - "key": "Type on a Path", - "translation": "الكتابة على مسار", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/type-on-a-path/apply-effects.html" - }, - { - "language": "ar", - "key": "Type on a Path Options…", - "translation": "خيارات الكتابة على مسار…", - "basis": "consistent terminology in related label", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/type-on-a-path/apply-effects.html" - }, - { - "language": "ar", - "key": "Width", - "translation": "العرض", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/add-and-manage-text/add-and-manage-text-frames/change-text-frame-properties.html" - }, - { - "language": "ar", - "key": "story", - "translation": "القصة", - "basis": "consistent terminology in related label", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/language-and-proofing/arabic-and-hebrew/change-text-direction.html" - }, - { - "language": "ar", - "key": "x Height", - "translation": "ارتفاع x", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/mena_ar/indesign/desktop/format-and-style-text/tabs-indents-and-spacing/adjust-text-baseline.html" - }, - { - "language": "zh", - "key": "After First:", - "translation": "连字符前最少字符数:", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/format-and-style-text/composition-and-text-wrapping/control-hyphenation-and-word-breaks.html" - }, - { - "language": "zh", - "key": "Align From:", - "translation": "对齐自:", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/add-graphics-and-media/manage-frames-and-objects/fit-object-to-frame.html" - }, - { - "language": "zh", - "key": "Align to baseline grid", - "translation": "对齐基线网格", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/format-and-style-text/character-formatting/adjust-line-spacing-with-leading.html" - }, - { - "language": "zh", - "key": "Allow Selected Spread to Shuffle", - "translation": "允许所选跨页随机排布", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/create-and-organize-pages/create-documents/create-multi-page-spreads.html" - }, - { - "language": "zh", - "key": "Apply Table Style", - "translation": "应用表样式", - "basis": "consistent terminology in related label", - "source": "https://helpx.adobe.com/cn/indesign/desktop/add-and-manage-text/add-and-import-text/import-options.html" - }, - { - "language": "zh", - "key": "Ascent", - "translation": "上升高度", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/format-and-style-text/tabs-indents-and-spacing/adjust-text-baseline.html" - }, - { - "language": "zh", - "key": "Before Last:", - "translation": "连字符后最少字符数:", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/format-and-style-text/composition-and-text-wrapping/control-hyphenation-and-word-breaks.html" - }, - { - "language": "zh", - "key": "Body Rows", - "translation": "表体行", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/add-tables-and-data/table-and-cell-styles/table-and-cell-styles.html" - }, - { - "language": "zh", - "key": "Bring Forward", - "translation": "向前移动", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/add-graphics-and-media/manage-frames-and-objects/stack-objects.html" - }, - { - "language": "zh", - "key": "Captions", - "translation": "题注", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/indexes-and-references/references-and-bookmarks/create-image-captions.html" - }, - { - "language": "zh", - "key": "Convert to Liquid Guide", - "translation": "转换为自适应参考线", - "basis": "documented feature terminology", - "source": "https://helpx.adobe.com/cn/indesign/using/alternate-layouts-liquid-layouts.html" - }, - { - "language": "zh", - "key": "Custom Tracking/Kerning", - "translation": "自定义字距调整/字距微调", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/format-and-style-text/composition-and-text-wrapping/set-text-composition.html" - }, - { - "language": "zh", - "key": "Dimensions Include Stroke Weight", - "translation": "尺寸包括描边粗细", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/add-graphics-and-media/transform-and-arrange-objects/object-transformation-settings-options.html" - }, - { - "language": "zh", - "key": "Discretionary Ligatures", - "translation": "自由连字", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/format-and-style-text/character-formatting/apply-ligatures-to-letter-pairs.html" - }, - { - "language": "zh", - "key": "Entire Pasteboard", - "translation": "完整粘贴板", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/get-started/settings-and-preferences/zoom-and-view-options.html" - }, - { - "language": "zh", - "key": "Fill with Placeholder Text", - "translation": "用占位符文本填充", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/add-and-manage-text/add-and-import-text/add-text-to-documents.html" - }, - { - "language": "zh", - "key": "First Object Above", - "translation": "上方第一个对象", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/add-graphics-and-media/manage-frames-and-objects/select-nested-overlapping-objects.html" - }, - { - "language": "zh", - "key": "Fit Content Proportionally", - "translation": "按比例适合内容", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/add-graphics-and-media/manage-frames-and-objects/fit-object-to-frame.html" - }, - { - "language": "zh", - "key": "Fit Frame to Content", - "translation": "使框架适合内容", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/add-graphics-and-media/manage-frames-and-objects/fit-object-to-frame.html" - }, - { - "language": "zh", - "key": "Generate Static Caption", - "translation": "生成静态题注", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/indexes-and-references/references-and-bookmarks/create-image-captions.html" - }, - { - "language": "zh", - "key": "Glyph Scaling:", - "translation": "字形缩放比例:", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/format-and-style-text/tabs-indents-and-spacing/control-glyphing-scaling.html" - }, - { - "language": "zh", - "key": "Guides & Pasteboard", - "translation": "参考线和粘贴板", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/create-and-organize-pages/create-documents/change-document-setup.html" - }, - { - "language": "zh", - "key": "High Quality", - "translation": "高品质", - "basis": "consistent terminology in related label", - "source": "https://helpx.adobe.com/cn/indesign/desktop/get-started/settings-and-preferences/keyboard-shortcuts.html" - }, - { - "language": "zh", - "key": "High Quality Display", - "translation": "高品质显示", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/get-started/settings-and-preferences/keyboard-shortcuts.html" - }, - { - "language": "zh", - "key": "Hyphen Limit:", - "translation": "连字符限制:", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/format-and-style-text/composition-and-text-wrapping/control-hyphenation-and-word-breaks.html" - }, - { - "language": "zh", - "key": "Info", - "translation": "信息", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/add-and-manage-text/add-and-manage-text-frames/word-character-count.html" - }, - { - "language": "zh", - "key": "Interactive", - "translation": "交互式", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/indexes-and-references/references-and-bookmarks/arrange-group-sort-bookmarks.html" - }, - { - "language": "zh", - "key": "Kenten", - "translation": "圈点", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/language-and-proofing/chinese-japanese-and-korean/apply-kenten-to-text.html" - }, - { - "language": "zh", - "key": "Line", - "translation": "直线", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/add-and-manage-text/add-and-manage-text-frames/set-column-rules.html" - }, - { - "language": "zh", - "key": "Liquid Layout", - "translation": "自适应版面", - "basis": "documented feature terminology", - "source": "https://helpx.adobe.com/cn/indesign/using/alternate-layouts-liquid-layouts.html" - }, - { - "language": "zh", - "key": "Liquid Page Rule", - "translation": "自适应页面规则", - "basis": "documented feature terminology", - "source": "https://helpx.adobe.com/cn/indesign/using/alternate-layouts-liquid-layouts.html" - }, - { - "language": "zh", - "key": "Liquid guides", - "translation": "自适应参考线", - "basis": "documented feature terminology", - "source": "https://helpx.adobe.com/cn/indesign/using/alternate-layouts-liquid-layouts.html" - }, - { - "language": "zh", - "key": "Mobile", - "translation": "移动设备", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/create-and-organize-pages/create-documents/create-documents-with-presets.html" - }, - { - "language": "zh", - "key": "New Page Reference…", - "translation": "新建页面引用…", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/indexes-and-references/create-an-index/create-index-entries.html" - }, - { - "language": "zh", - "key": "New Table Style…", - "translation": "新建表样式…", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/add-tables-and-data/table-and-cell-styles/table-and-cell-styles.html" - }, - { - "language": "zh", - "key": "Next Object Above", - "translation": "上方下一个对象", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/add-graphics-and-media/manage-frames-and-objects/select-nested-overlapping-objects.html" - }, - { - "language": "zh", - "key": "Next Object Below", - "translation": "下方下一个对象", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/add-graphics-and-media/manage-frames-and-objects/select-nested-overlapping-objects.html" - }, - { - "language": "zh", - "key": "Nonprinting", - "translation": "非打印", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/add-graphics-and-media/transform-and-arrange-objects/create-hidden-nonprinting-objects.html" - }, - { - "language": "zh", - "key": "Numbering & Section Options…", - "translation": "页码和章节选项…", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/create-and-organize-pages/page-numbers-chapters-and-sections/add-page-numbers-and-markers.html" - }, - { - "language": "zh", - "key": "Object Liquid Settings", - "translation": "对象自适应设置", - "basis": "consistent terminology in related label", - "source": "https://helpx.adobe.com/cn/indesign/using/alternate-layouts-liquid-layouts.html" - }, - { - "language": "zh", - "key": "Paste without Formatting", - "translation": "粘贴时不包含格式", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/add-and-manage-text/add-and-import-text/add-text-to-documents.html" - }, - { - "language": "zh", - "key": "Remove Styles and Formatting from Text and Tables", - "translation": "从文本和表格中删除样式和格式", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/add-and-manage-text/add-and-import-text/import-options.html" - }, - { - "language": "zh", - "key": "Revert", - "translation": "还原", - "basis": "documented menu control terminology (Edit > Undo / File > Revert)", - "source": "https://helpx.adobe.com/cn/indesign/desktop/get-started/settings-and-preferences/undo-redo-edits.html" - }, - { - "language": "zh", - "key": "Ruby", - "translation": "旁注", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/language-and-proofing/chinese-japanese-and-korean/add-and-format-ruby-text-annotations.html" - }, - { - "language": "zh", - "key": "Ruby:", - "translation": "旁注:", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/language-and-proofing/chinese-japanese-and-korean/add-and-format-ruby-text-annotations.html" - }, - { - "language": "zh", - "key": "Ruby…", - "translation": "旁注…", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/language-and-proofing/chinese-japanese-and-korean/add-and-format-ruby-text-annotations.html" - }, - { - "language": "zh", - "key": "Send Backward", - "translation": "向后移动", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/add-graphics-and-media/manage-frames-and-objects/stack-objects.html" - }, - { - "language": "zh", - "key": "Show All on Spread", - "translation": "在跨页上全部显示", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/add-graphics-and-media/transform-and-arrange-objects/create-hidden-nonprinting-objects.html" - }, - { - "language": "zh", - "key": "Show Prefix/Suffix in:", - "translation": "显示前缀/后缀位置:", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/indexes-and-references/footnotes-and-endnotes/change-footnote-numbering-and-layout-options.html" - }, - { - "language": "zh", - "key": "Show Tag Markers", - "translation": "显示标签标志符", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/add-and-manage-text/conditional-and-variable-text/marker-types.html" - }, - { - "language": "zh", - "key": "Snap to Document Grid", - "translation": "靠齐文档网格", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/layout-and-grid-tools/grids/snap-objects-to-grids.html" - }, - { - "language": "zh", - "key": "Snap to Guides", - "translation": "靠齐参考线", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/layout-and-grid-tools/grids/snap-objects-to-grids.html" - }, - { - "language": "zh", - "key": "Snap to Zone", - "translation": "靠齐区域", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/layout-and-grid-tools/grids/snap-objects-to-grids.html" - }, - { - "language": "zh", - "key": "Span Footnotes Across Columns", - "translation": "跨栏分布脚注", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/indexes-and-references/footnotes-and-endnotes/span-footnotes.html" - }, - { - "language": "zh", - "key": "Start at:", - "translation": "起始编号:", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/indexes-and-references/footnotes-and-endnotes/change-endnote-numbering-and-layout.html" - }, - { - "language": "zh", - "key": "Stop Color:", - "translation": "停止点颜色:", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/apply-color/advanced-color-techniques/create-and-name-gradient-swatches.html" - }, - { - "language": "zh", - "key": "Substituted Fonts", - "translation": "替换字体", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/format-and-style-text/composition-and-text-wrapping/set-text-composition.html" - }, - { - "language": "zh", - "key": "Table Options", - "translation": "表选项", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/add-tables-and-data/table-and-cell-styles/override-table-style.html" - }, - { - "language": "zh", - "key": "Table Style", - "translation": "表样式", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/add-and-manage-text/add-and-import-text/import-options.html" - }, - { - "language": "zh", - "key": "Table Style Options…", - "translation": "表样式选项…", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/add-tables-and-data/table-and-cell-styles/table-and-cell-styles.html" - }, - { - "language": "zh", - "key": "Tate-Chu-Yoko", - "translation": "纵中横", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/language-and-proofing/chinese-japanese-and-korean/apply-tate-chu-yoko-in-vertical-text.html" - }, - { - "language": "zh", - "key": "Track Changes", - "translation": "修订", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/add-and-manage-text/add-and-import-text/import-options.html" - }, - { - "language": "zh", - "key": "Tracking", - "translation": "字距调整", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/format-and-style-text/tabs-indents-and-spacing/adjust-tracking.html" - }, - { - "language": "zh", - "key": "Tracking:", - "translation": "字距调整:", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/format-and-style-text/tabs-indents-and-spacing/adjust-tracking.html" - }, - { - "language": "zh", - "key": "Transformations are Totals", - "translation": "变换为总计", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/add-graphics-and-media/transform-and-arrange-objects/object-transformation-settings-options.html" - }, - { - "language": "zh", - "key": "Transparency Flattener Presets", - "translation": "透明度拼合器预设", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/apply-color/advanced-color-techniques/create-custom-transparency-flattener-presets.html" - }, - { - "language": "zh", - "key": "Undo", - "translation": "还原", - "basis": "documented menu control terminology (Edit > Undo / File > Revert)", - "source": "https://helpx.adobe.com/cn/indesign/desktop/get-started/settings-and-preferences/undo-redo-edits.html" - }, - { - "language": "zh", - "key": "Units & Increments", - "translation": "单位和增量", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/format-and-style-text/tabs-indents-and-spacing/adjust-kerning.html" - }, - { - "language": "zh", - "key": "Words with at Least:", - "translation": "单词最少字符数:", - "basis": "documented control terminology", - "source": "https://helpx.adobe.com/cn/indesign/desktop/format-and-style-text/composition-and-text-wrapping/control-hyphenation-and-word-breaks.html" - } - ] -} From f52c591b87e7568ccdd77fb4b9f6e4fc63393717 Mon Sep 17 00:00:00 2001 From: Shao Wenbin <shaowenbin.salih@gmail.com> Date: Wed, 7 Oct 2026 19:27:05 +0800 Subject: [PATCH 10/13] M9: cache final RTL label layouts Reuse wrapped and shaped RTL galleys across frames using a bounded, context-local cache keyed by the complete LayoutJob. Invalidate cached results when egui replaces its font-cache token, including font, DPI and atlas changes. Preserve existing wrapping and elision behavior. Add regression coverage for cross-frame reuse, layout parameter changes, font/scale/atlas invalidation and cache eviction, plus an opt-in benchmark. Validation: cargo xtask ci passed fmt, clippy, 590 tests, assets and layers. WASM checks are blocked by the missing wasm32-unknown-unknown standard library in the local Homebrew Rust toolchain (E0463). Arabic menu screenshots remained identical after ten additional frames. Manual benchmark: 200 wrapped labels took 31.46 ms uncached vs 0.082 ms with final-layout cache hits on this machine. --- ROADMAP.md | 2 +- crates/ui-egui/src/rtl.rs | 186 +++++++++++++++++++++++++++++++++++++- 2 files changed, 185 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 75447f470..70b6e09d4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -206,7 +206,7 @@ same references), and from the app's JSON control channel. See [docs/agents.md]( | M6 | Files & export (native, IDML, PDF, PNG/JPEG) | in progress (IDML ✅, PDF export) | | M7 | Long documents (sections, TOC, index, footnotes, books) | in progress (sections, TOC, text variables/running heads, hyperlinks, bookmarks, footnotes, cross-references, index) | | M8 | Tables | ✅ core (table/cell styles, rotation, diagonal lines pending) | -| M9 | Performance (MT composition, tiles) | in progress (glyph path cache, culling, parallel compose 6.7× faster, perf harness) | +| M9 | Performance (MT composition, tiles) | in progress (glyph path cache, bounded RTL label layout cache, culling, parallel compose 6.7× faster, perf harness) | | M10 | Find/Change, spelling, Preflight | in progress (Find/Change ✅, spelling ✅, Preflight) | | M11 | Layout power features (liquid/alternate layouts, data merge) | started (data merge, step & repeat, snippets) | | M12 | Interactive & digital (EPUB, HTML, interactive PDF) | started (EPUB, PDF links/bookmarks) | diff --git a/crates/ui-egui/src/rtl.rs b/crates/ui-egui/src/rtl.rs index bc042c7ab..2521aae20 100644 --- a/crates/ui-egui/src/rtl.rs +++ b/crates/ui-egui/src/rtl.rs @@ -2,7 +2,7 @@ //! Source strings stay logical. Never use these galleys for editable text/caret mapping. use egui::text::{LayoutJob, TextWrapping}; use egui::{Align, Align2, Color32, FontFamily, FontId, Galley, Pos2, Rect, WidgetText}; -use std::{ops::Range, sync::Arc}; +use std::{collections::HashMap, ops::Range, sync::Arc}; use unicode_bidi::{BidiClass, BidiInfo, bidi_class}; use unicode_segmentation::UnicodeSegmentation; @@ -138,7 +138,72 @@ fn visual_line(job: &LayoutJob, bidi: &BidiInfo<'_>, line: Range<usize>) -> Layo visual } -fn layout(ctx: &egui::Context, mut job: LayoutJob) -> Arc<Galley> { +const MAX_CACHED_LABELS: usize = 256; +const MAX_CACHED_TEXT_BYTES: usize = 4096; + +#[derive(Clone)] +struct CachedLayout { + galley: Arc<Galley>, + last_used: u64, +} + +#[derive(Clone, Default)] +struct LayoutCache { + // A tiny galley owned by egui's font cache acts as a lifetime token. Font changes, + // DPI changes, atlas rebuilds and eviction replace it. Holding the old Arc prevents + // address reuse. Unlike a font-definition hash, this also detects atlas resets. + font_token: Option<Arc<Galley>>, + entries: HashMap<u64, CachedLayout>, + clock: u64, +} + +impl LayoutCache { + fn lookup(&mut self, token: Arc<Galley>, key: u64, job: &LayoutJob) -> Option<Arc<Galley>> { + if self.font_token.as_ref().is_none_or(|old| !Arc::ptr_eq(old, &token)) { + self.entries.clear(); + self.font_token = Some(token); + } + self.clock = self.clock.saturating_add(1); + let entry = self.entries.get_mut(&key)?; + // Check equality as well as the hash: collisions must never display another label. + if *entry.galley.job != *job { + return None; + } + entry.last_used = self.clock; + Some(Arc::clone(&entry.galley)) + } + + fn insert(&mut self, key: u64, galley: Arc<Galley>) { + if self.entries.len() >= MAX_CACHED_LABELS + && let Some(oldest) = self.entries.iter().min_by_key(|(_, entry)| entry.last_used).map(|(key, _)| *key) + { + self.entries.remove(&oldest); + } + self.entries.insert(key, CachedLayout { galley, last_used: self.clock }); + } +} + +fn layout(ctx: &egui::Context, job: LayoutJob) -> Arc<Galley> { + if !may_have_rtl(&job.text) { + return ctx.fonts_mut(|fonts| fonts.layout_job(job)); + } + // Keep unusually large/dynamic content from filling a UI-label cache. + if job.text.len() > MAX_CACHED_TEXT_BYTES || job.sections.len() > 256 { + return layout_uncached(ctx, job); + } + let token = ctx.fonts_mut(|fonts| fonts.layout_no_wrap(String::new(), FontId::default(), Color32::TRANSPARENT)); + let id = egui::Id::new("designcraft.rtl.layout-cache"); + let key = egui::util::hash(&job); + let cached = ctx.data_mut(|data| data.get_temp_mut_or_default::<LayoutCache>(id).lookup(token, key, &job)); + if let Some(galley) = cached { + return galley; + } + let galley = layout_uncached(ctx, job); + ctx.data_mut(|data| data.get_temp_mut_or_default::<LayoutCache>(id).insert(key, Arc::clone(&galley))); + galley +} + +fn layout_uncached(ctx: &egui::Context, mut job: LayoutJob) -> Arc<Galley> { if !has_rtl(&job.text) { return ctx.fonts_mut(|fonts| fonts.layout_job(job)); } @@ -246,6 +311,123 @@ mod tests { galley.rows.iter().flat_map(|row| row.glyphs.iter().filter(|g| g.advance_width > 0.0).map(|g| g.chr)).collect() } + fn cache_job() -> LayoutJob { + LayoutJob::simple("ملف (PDF 123) جديد للنشر والطباعة".repeat(4), FontId::proportional(15.0), Color32::WHITE, 150.0) + } + + #[test] + fn cache_reuses_final_layout_across_frames_and_matches_uncached_output() { + with_fonts(|ctx| { + let job = cache_job(); + let first = layout(ctx, job.clone()); + let reference = layout_uncached(ctx, job.clone()); + assert_eq!(displayed(&first), displayed(&reference)); + assert_eq!(first.rect, reference.rect); + assert_eq!(first.rows.len(), reference.rows.len()); + assert!(Arc::ptr_eq(&first, &layout(ctx, job.clone()))); + ctx.end_pass().textures_delta.clear(); + ctx.begin_pass(egui::RawInput { max_texture_side: Some(8192), ..Default::default() }); + assert!(Arc::ptr_eq(&first, &layout(ctx, job))); + }); + } + + #[test] + fn cache_respects_text_formatting_wrapping_and_elision() { + with_fonts(|ctx| { + let job = cache_job(); + let first = layout(ctx, job.clone()); + let mut variants = Vec::new(); + let mut changed = job.clone(); + changed.wrap.max_width = 80.0; + variants.push(changed); + let mut changed = job.clone(); + changed.wrap.max_rows = 1; + variants.push(changed); + let mut changed = job.clone(); + changed.sections[0].format.font_id.size = 24.0; + variants.push(changed); + let mut changed = job.clone(); + changed.sections[0].format.color = Color32::RED; + variants.push(changed); + variants.push(LayoutJob::simple_singleline("עברית 123".into(), FontId::default(), Color32::WHITE)); + for changed in variants { + let cached = layout(ctx, changed.clone()); + let reference = layout_uncached(ctx, changed); + assert!(!Arc::ptr_eq(&first, &cached)); + assert_eq!(cached.job, reference.job); + assert_eq!(cached.rect, reference.rect); + assert_eq!(cached.elided, reference.elided); + assert_eq!(displayed(&cached), displayed(&reference)); + } + }); + } + + #[test] + fn cache_invalidates_when_fonts_or_scale_change() { + with_fonts(|ctx| { + let job = cache_job(); + let first = layout(ctx, job.clone()); + ctx.end_pass().textures_delta.clear(); + ctx.set_pixels_per_point(2.0); + ctx.begin_pass(egui::RawInput { max_texture_side: Some(8192), ..Default::default() }); + let scaled = layout(ctx, job.clone()); + assert!(!Arc::ptr_eq(&first, &scaled)); + ctx.end_pass().textures_delta.clear(); + let mut fonts = ctx.fonts(|fonts| fonts.definitions().clone()); + fonts.families.insert(FontFamily::Name("cache-test".into()), Vec::new()); + ctx.set_fonts(fonts); + ctx.begin_pass(egui::RawInput { max_texture_side: Some(8192), ..Default::default() }); + let new_fonts = layout(ctx, job.clone()); + assert!(!Arc::ptr_eq(&scaled, &new_fonts)); + ctx.end_pass().textures_delta.clear(); + // Changing atlas options recreates the atlas without changing font definitions. + ctx.begin_pass(egui::RawInput { max_texture_side: Some(4096), ..Default::default() }); + let new_atlas = layout(ctx, job.clone()); + assert!(!Arc::ptr_eq(&new_fonts, &new_atlas)); + assert_eq!(displayed(&new_atlas), displayed(&layout_uncached(ctx, job))); + }); + } + + #[test] + fn cache_is_bounded_and_font_token_replacement_invalidates_entries() { + with_fonts(|ctx| { + let job = cache_job(); + let galley = layout_uncached(ctx, job.clone()); + let token = ctx.fonts_mut(|fonts| fonts.layout_no_wrap(String::new(), FontId::default(), Color32::TRANSPARENT)); + let mut cache = LayoutCache::default(); + cache.lookup(token, 0, &job); + for key in 0..=MAX_CACHED_LABELS as u64 { + cache.clock += 1; + cache.insert(key, Arc::clone(&galley)); + } + assert_eq!(cache.entries.len(), MAX_CACHED_LABELS); + assert!(!cache.entries.contains_key(&0)); + // Replacing the font-cache token models atlas recreation, even with identical fonts. + let replacement = Arc::new((*galley).clone()); + assert!(cache.lookup(replacement, 1, &job).is_none()); + assert!(cache.entries.is_empty()); + }); + } + + #[test] + #[ignore = "manual timing comparison, no machine-dependent pass threshold"] + fn benchmark_cached_wrapping() { + with_fonts(|ctx| { + let job = cache_job(); + let _ = layout(ctx, job.clone()); + let start = std::time::Instant::now(); + for _ in 0..200 { + std::hint::black_box(layout_uncached(ctx, job.clone())); + } + let uncached = start.elapsed(); + let start = std::time::Instant::now(); + for _ in 0..200 { + std::hint::black_box(layout(ctx, job.clone())); + } + eprintln!("200 wrapped labels: uncached={uncached:?}, cached={:?}", start.elapsed()); + }); + } + #[test] fn ordinary_multilingual_labels_skip_bidi_analysis() { for text in ["", "File (123)", "Édition", "中文排版", "スウォッチ", "e\u{301}", "🎨 🙂"] { From 1c792b41e4e02916a782689777fb79687c4d34ac Mon Sep 17 00:00:00 2001 From: Shao Wenbin <shaowenbin.salih@gmail.com> Date: Wed, 7 Oct 2026 19:47:38 +0800 Subject: [PATCH 11/13] M9: remove stale localization terminology references Remove links to the deleted localization-terminology.json from ROADMAP.md and the Chinese/Arabic translation module comments. Preserve the scope statement about verified Adobe help terminology and original labels. Validation: cargo xtask ci passed fmt, clippy, tests, assets and layers. WASM checks remain blocked by the missing wasm32-unknown-unknown standard library in the local toolchain. Repository search finds no remaining references to localization-terminology.json. --- ROADMAP.md | 2 +- crates/ui-egui/src/i18n.rs | 2 +- crates/ui-egui/src/i18n/ar.rs | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 70b6e09d4..586be4e6f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -97,7 +97,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Transparency flattener (`file.exportPdf {flatten: high|medium|low|ppi}`, Edit ▸ Transparency Flattener Presets): each object involving transparency becomes an opaque image of its area over what's beneath it, so everything else stays vector (spreads with transparent parent items are rasterised whole). - Video and sound (place .mp4/.mov/.webm/.mp3/.wav…, `media.options/get`, Media panel): media frames show a poster or placeholder on screen, in print and in PDF, and export as HTML5 `<video>`/`<audio>` in EPUB. Interactive PDF export (`file.exportPdf {media: true}`, File ▸ Export PDF (Interactive)) embeds each file and plays it in a Screen annotation through a rendition action (controls, loop, play on page load). No in-app playback. - Right-to-left text (World-Ready basics): right-to-left runs are shaped right to left, lines are reordered by the Unicode Bidirectional Algorithm (matched against the reference implementation), Paragraph Direction (`type.para {direction}`, Paragraph panel, IDML ParagraphDirection, EPUB `dir`). Digits (`digits`: Default, Arabic, Hindi, Farsi, Native by language; Character panel for right-to-left text; IDML DigitsType) draw 0–9 in another script. Kashidas (`kashidas` paragraph attribute, on by default): justified Arabic lines stretch at one join per word (after seen/sad when possible, never inside lam–alef) with a drawn tatweel before word spaces widen. Carets follow the drawing: the caret before a right-to-left glyph sits at its right edge, arrow keys move as drawn (by words in the line's direction), clicks find the nearest drawn caret position and selections highlight the selected glyphs. Binding: Right to Left (Document Setup, `layout.documentSetup {binding}`, IDML PageBinding) makes page 1 a left page and lays spreads out right to left. -- Interface language (Edit ▸ Interface Language, `app.language`): German, French, Spanish, Japanese, Simplified Chinese and Arabic menu titles, common menu items and panel names (our own translations). Common dialog, panel and tooltip labels are also localized, with English fallback for untranslated strings. Arabic translations cover every key in the shared interface translation table, including menu commands and utility labels. Chinese and Arabic feature terminology is checked against public Adobe help where verified; [terminology sources](docs/localization-terminology.json) distinguish documented terms from related-label adaptations. The Type menu's Story Direction, Horizontal/Vertical, Tate-Chu-Yoko, Ruby… and Kenten are translated too. Arabic labels use Unicode bidi run ordering and contextual shaping; welcome and new-document form layouts follow RTL direction independently of document binding. CJK and Arabic glyphs come from [craft-fonts](https://github.com/storytold/craft-fonts) when built with `CRAFT_FONTS_DIR` (all releases): BIZ UDPGothic and Noto Sans CJK SC for the UI (Chinese forms first in the Chinese interface), Noto Sans Arabic, and Mincho for Japanese documents (the web build embeds BIZ UDPGothic only); otherwise from system fonts. +- Interface language (Edit ▸ Interface Language, `app.language`): German, French, Spanish, Japanese, Simplified Chinese and Arabic menu titles, common menu items and panel names (our own translations). Common dialog, panel and tooltip labels are also localized, with English fallback for untranslated strings. Arabic translations cover every key in the shared interface translation table, including menu commands and utility labels. Chinese and Arabic feature terminology is checked against public Adobe help where verified; project-specific labels remain original translations. The Type menu's Story Direction, Horizontal/Vertical, Tate-Chu-Yoko, Ruby… and Kenten are translated too. Arabic labels use Unicode bidi run ordering and contextual shaping; welcome and new-document form layouts follow RTL direction independently of document binding. CJK and Arabic glyphs come from [craft-fonts](https://github.com/storytold/craft-fonts) when built with `CRAFT_FONTS_DIR` (all releases): BIZ UDPGothic and Noto Sans CJK SC for the UI (Chinese forms first in the Chinese interface), Noto Sans Arabic, and Mincho for Japanese documents (the web build embeds BIZ UDPGothic only); otherwise from system fonts. - Page transitions (`page.transition/transitions`, Page Transitions panel): twelve transition types with speed and direction per spread, written into exported PDFs as `/Trans` entries through an incremental update. - PDF/X-4 export (`file.exportPdf {standard: "x4"}`): an output intent with our own Generic CMYK ICC profile (lut16 tables generated from the parametric press model), GTS_PDFXVersion in Info and XMP, and built-in PDF/X-4 checks reported as warnings. qpdf finds no errors in the result. - EPS place: placed at its (high-resolution) bounding box and shown, printed and exported through its TIFF preview, or a placeholder when it has none. PostScript itself isn't interpreted. diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 7047f8e9e..8958c4447 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1,7 +1,7 @@ //! Interface language (Edit › Interface Language): translations of menu titles, the most used //! menu items and panel names. Untranslated strings stay English. //! Chinese and Arabic feature terminology is checked against public Adobe help where documented; -//! project-specific labels are original. Sources and scope: `docs/localization-terminology.json`. +//! project-specific labels are original. use std::{collections::HashMap, sync::OnceLock}; diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 2b364cd1c..65b22db79 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1,6 +1,5 @@ //! Arabic interface translations, stored in logical Unicode order. //! Feature terminology follows Adobe Arabic help where verified; this is not an official Adobe UI. -//! See `docs/localization-terminology.json` for sources and scope. pub(super) const TABLE: &[(&str, &str)] = &[ ("File", "ملف"), ("Edit", "تحرير"), From 2531f039fe9894c77211670c0f3dc52deccf1326 Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Wed, 7 Oct 2026 21:54:38 -0400 Subject: [PATCH 12/13] Data merge: review fixes (bounded reads, no truncation, safe links, docs) - Remove the docs/superpowers planning spec. - textimport: cap decompressed zip parts at 64 MiB (protects Place and xlsx data sources); oversized part is an error. Regression test. - datamerge: refuse data files over 256 MiB and skip image candidates over 256 MiB by metadata before reading. - datamerge: assign_page uses get_mut instead of indexing spreads. - datamerge: one/limit over u32 are errors, not truncated. - select_records dedupes with a HashSet (was O(n^2)), order kept. - Hyperlink cells: only http, https, ftp and mailto schemes link. - Document data.* commands and data.merge creating a new document. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/datamerge/fill.rs | 34 ++- crates/engine/src/cmd/datamerge/mod.rs | 21 +- crates/engine/src/cmd/datamerge/parse.rs | 5 +- crates/engine/src/cmd/datamerge/tests.rs | 25 ++- crates/textimport/src/docx.rs | 40 +++- crates/textimport/src/xlsx.rs | 26 ++- docs/agents.md | 14 ++ docs/control-protocol.md | 4 + .../specs/2026-10-07-data-merge-design.md | 208 ------------------ 9 files changed, 145 insertions(+), 232 deletions(-) delete mode 100644 docs/superpowers/specs/2026-10-07-data-merge-design.md diff --git a/crates/engine/src/cmd/datamerge/fill.rs b/crates/engine/src/cmd/datamerge/fill.rs index 1d26e24c9..deb0948bc 100644 --- a/crates/engine/src/cmd/datamerge/fill.rs +++ b/crates/engine/src/cmd/datamerge/fill.rs @@ -324,7 +324,14 @@ fn place_image( report: &mut FillReport, ) { let cands = image_candidates(cell, data_dir, doc_dir); - let found = cands.iter().find_map(|p| std::fs::read(p).ok().filter(|b| !b.is_empty()).map(|b| (p.clone(), b))); + let found = cands.iter().find_map(|p| { + // Refuse huge files before reading them; a too-large image counts as missing. + let len = std::fs::metadata(p).ok()?.len(); + if len == 0 || len > super::MAX_IMAGE_FILE_BYTES { + return None; + } + std::fs::read(p).ok().filter(|b| !b.is_empty()).map(|b| (p.clone(), b)) + }); let Some((path, bytes)) = found else { if let Some(it) = doc.item_mut(item) { it.content = Content::Unassigned; @@ -414,6 +421,15 @@ fn classify_link(cell: &str) -> Option<HyperlinkDest> { if value.is_empty() { return None; } + // An explicit scheme must be a web or mail one; `javascript:`, `file:` and the like stay text. + if let Some((scheme, _)) = value.split_once(':') { + let is_scheme = scheme.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) + && scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')); + if is_scheme { + let scheme = scheme.to_ascii_lowercase(); + return matches!(scheme.as_str(), "http" | "https" | "ftp" | "mailto").then(|| HyperlinkDest::Url(value.to_string())); + } + } if value.contains("://") || value.chars().any(char::is_whitespace) { return Some(HyperlinkDest::Url(value.to_string())); } @@ -436,3 +452,19 @@ fn anchor_is_parent(doc: &designcraft_doc::Document, anchor: &PlaceholderAnchor) fn cell_of<'a>(fields: &[DataField], row: &'a [String], name: &str) -> &'a str { fields.iter().position(|f| f.name == name).and_then(|i| row.get(i)).map(String::as_str).unwrap_or("") } + +#[cfg(test)] +mod link_tests { + use super::*; + + #[test] + fn only_web_and_mail_schemes_become_links() { + for ok in ["https://example.com", "HTTP://example.com", "ftp://files.example.com", "mailto:a@example.com", "www.example.com"] { + assert!(matches!(classify_link(ok), Some(HyperlinkDest::Url(_))), "{ok}"); + } + assert!(matches!(classify_link("a@example.com"), Some(HyperlinkDest::Email(_)))); + for bad in ["javascript:alert(1)", "file:///etc/passwd", "data:text/html,hi", "vbscript:x", ""] { + assert!(classify_link(bad).is_none(), "{bad}"); + } + } +} diff --git a/crates/engine/src/cmd/datamerge/mod.rs b/crates/engine/src/cmd/datamerge/mod.rs index c698a5ea7..3b3e1dd3b 100644 --- a/crates/engine/src/cmd/datamerge/mod.rs +++ b/crates/engine/src/cmd/datamerge/mod.rs @@ -413,7 +413,8 @@ fn block_of(doc: &Document) -> (Rect, Vec<ItemId>) { fn assign_page(dst: &mut Document, abs: usize, from: &Page) { let Some((si, pi)) = dst.page_loc(abs) else { return }; - let sp = Arc::make_mut(&mut dst.spreads[si]); + let Some(sp) = dst.spreads.get_mut(si) else { return }; + let sp = Arc::make_mut(sp); let Some(p) = sp.pages.get_mut(pi) else { return }; let size_changed = (p.width - from.width).abs() > 0.01 || (p.height - from.height).abs() > 0.01; p.guides = from.guides.clone(); @@ -757,7 +758,21 @@ fn parse_file(bytes: &[u8], name: &str, delimiter: Option<Delimiter>, sheet: Opt table_from_bytes(bytes, name, delimiter) } +/// Largest data source file read (bytes); bigger files are refused before reading. +pub(crate) const MAX_DATA_FILE_BYTES: u64 = 256 * 1024 * 1024; +/// Largest image file a merge places (bytes); bigger candidates are skipped before reading. +pub(crate) const MAX_IMAGE_FILE_BYTES: u64 = 256 * 1024 * 1024; + fn read_path(path: &str) -> std::result::Result<Vec<u8>, String> { + read_path_capped(path, MAX_DATA_FILE_BYTES) +} + +fn read_path_capped(path: &str, cap: u64) -> std::result::Result<Vec<u8>, String> { + match std::fs::metadata(path) { + Ok(m) if m.len() > cap => return Err(format!("{path}: the data file is larger than {} MiB", cap / (1024 * 1024))), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(format!("{path}: the data file is missing")), + _ => {} + } match std::fs::read(path) { Ok(b) => Ok(b), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(format!("{path}: the data file is missing")), @@ -794,7 +809,7 @@ fn apply_options(opt: &mut MergeOptions, p: &Value) -> std::result::Result<(), S opt.records = v.to_string(); } if let Some(n) = p.get("one").and_then(Value::as_u64) { - opt.one = n as u32; + opt.one = u32::try_from(n).map_err(|_| "one is too large".to_string())?; } else if p.get("one").is_some() { return Err("one must be a record number".into()); } @@ -837,7 +852,7 @@ fn apply_options(opt: &mut MergeOptions, p: &Value) -> std::result::Result<(), S if v.is_null() { opt.limit = None; } else if let Some(n) = v.as_u64() { - opt.limit = Some(n as u32); + opt.limit = Some(u32::try_from(n).map_err(|_| "limit is too large".to_string())?); } else { return Err("limit must be a number".into()); } diff --git a/crates/engine/src/cmd/datamerge/parse.rs b/crates/engine/src/cmd/datamerge/parse.rs index d2f91fe5c..e65493dfc 100644 --- a/crates/engine/src/cmd/datamerge/parse.rs +++ b/crates/engine/src/cmd/datamerge/parse.rs @@ -152,6 +152,7 @@ fn cell_string(v: Option<&Value>) -> Result<String, String> { /// 0-based indexes into `count` rows, in first-seen order. pub fn select_records(count: usize, records: &str, one: u32, range: &str, limit: Option<u32>) -> Result<Vec<usize>, String> { let mut idx = Vec::new(); + let mut seen = std::collections::HashSet::new(); match records { "all" => idx.extend(0..count), "one" => { @@ -176,7 +177,7 @@ pub fn select_records(count: usize, records: &str, one: u32, range: &str, limit: let mut n = a; while n <= b { let i = record_index(n, count)?; - if !idx.contains(&i) { + if seen.insert(i) { idx.push(i); } n = n.saturating_add(1); @@ -184,7 +185,7 @@ pub fn select_records(count: usize, records: &str, one: u32, range: &str, limit: } else { let n = parse_record_no(token)?; let i = record_index(n, count)?; - if !idx.contains(&i) { + if seen.insert(i) { idx.push(i); } } diff --git a/crates/engine/src/cmd/datamerge/tests.rs b/crates/engine/src/cmd/datamerge/tests.rs index f7fc09846..6153a1d61 100644 --- a/crates/engine/src/cmd/datamerge/tests.rs +++ b/crates/engine/src/cmd/datamerge/tests.rs @@ -6,7 +6,7 @@ use designcraft_doc::{Content, DataField, DataFieldKind, DataSource, Delimiter, use serde_json::{Value, json}; use super::parse::{self, parse_csv}; -use super::{relative_between, resolve_sources_on_open}; +use super::{read_path_capped, relative_between, resolve_sources_on_open}; use crate::Session; fn scratch(name: &str) -> PathBuf { @@ -634,6 +634,11 @@ fn update_remove_preview_inline_rows_and_old_files() { assert!(msg.contains("reversed"), "{msg}"); let msg = err(&mut s, "data.merge", json!({"csv": "Name\nAda\n", "limit": 0})); assert!(msg.contains("limit"), "{msg}"); + // Numbers past u32 are an error, not a silent truncation to a small record number. + let msg = err(&mut s, "data.merge", json!({"csv": "Name\nAda\n", "records": "one", "one": 4_294_967_297u64})); + assert!(msg.contains("too large"), "{msg}"); + let msg = err(&mut s, "data.merge", json!({"csv": "Name\nAda\n", "limit": 4_294_967_296u64})); + assert!(msg.contains("too large"), "{msg}"); assert_eq!(s.documents().len(), before); let fresh = dir.join("fresh.designcraft"); @@ -785,3 +790,21 @@ fn tile_slots_match_the_insets() { let slots: Vec<_> = plan.hits.iter().map(|h| (h.col, h.row)).collect(); assert_eq!(slots, vec![(0, 0), (1, 0), (2, 0), (0, 1), (1, 1), (2, 1)]); } + +#[test] +fn select_records_dedupes_large_ranges_in_first_seen_order() { + let idx = parse::select_records(100_000, "range", 1, "5,1-100000,3", None).unwrap(); + assert_eq!(idx.len(), 100_000); + assert_eq!(&idx[..3], &[4, 0, 1]); +} + +#[test] +fn oversized_data_file_is_refused_before_reading() { + let dir = scratch("oversized"); + let path = dir.join("big.csv"); + write(&path, &"x".repeat(2048)); + let path = path.to_string_lossy().to_string(); + let msg = read_path_capped(&path, 1024).unwrap_err(); + assert!(msg.contains("larger than"), "{msg}"); + assert_eq!(read_path_capped(&path, 4096).unwrap().len(), 2048); +} diff --git a/crates/textimport/src/docx.rs b/crates/textimport/src/docx.rs index 048529574..563eb7289 100644 --- a/crates/textimport/src/docx.rs +++ b/crates/textimport/src/docx.rs @@ -125,11 +125,26 @@ pub(crate) fn parse(xml: &str) -> Result<El, ImportError> { Ok(root.kids.drain(..).find_map(|n| if let Node::El(e) = n { Some(e) } else { None }).unwrap_or_default()) } -pub(crate) fn part(zip: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>, name: &str) -> Option<String> { - let mut f = zip.by_name(name).ok()?; +/// Largest decompressed zip part read (a small file can inflate to gigabytes). +pub(crate) const PART_LIMIT: u64 = 64 * 1024 * 1024; + +/// A zip part as text. `Ok(None)` when the part is missing or isn't text; an error when it +/// decompresses to more than [`PART_LIMIT`]. +pub(crate) fn part(zip: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>, name: &str) -> Result<Option<String>, ImportError> { + part_capped(zip, name, PART_LIMIT) +} + +pub(crate) fn part_capped(zip: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>, name: &str, limit: u64) -> Result<Option<String>, ImportError> { + let Ok(f) = zip.by_name(name) else { return Ok(None) }; let mut s = String::new(); - f.read_to_string(&mut s).ok()?; - Some(s) + // Read one byte past the limit to tell "exactly at the limit" from "over it". + if f.take(limit.saturating_add(1)).read_to_string(&mut s).is_err() { + return Ok(None); + } + if s.len() as u64 > limit { + return Err(ImportError::Corrupt(format!("{name} is larger than {} MiB uncompressed", limit / (1024 * 1024)))); + } + Ok(Some(s)) } /// Character attributes from `<w:rPr>`. @@ -313,13 +328,13 @@ impl Ctx { pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> { let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| ImportError::Corrupt(e.to_string()))?; - let doc = part(&mut zip, "word/document.xml").ok_or_else(|| ImportError::Corrupt("no word/document.xml (not a Word document)".into()))?; + let doc = part(&mut zip, "word/document.xml")?.ok_or_else(|| ImportError::Corrupt("no word/document.xml (not a Word document)".into()))?; let doc = parse(&doc)?; // Styles. let mut ctx = Ctx { para_names: HashMap::new(), char_names: HashMap::new(), footnotes: HashMap::new(), warnings: vec![], next_table: 0 }; let mut para_styles = Vec::new(); let mut char_styles = Vec::new(); - if let Some(styles) = part(&mut zip, "word/styles.xml").map(|s| parse(&s)).transpose()? { + if let Some(styles) = part(&mut zip, "word/styles.xml")?.map(|s| parse(&s)).transpose()? { let ids: HashMap<String, String> = styles.els().filter(|s| s.name == "style").filter_map(|s| Some((s.attr("styleId")?.to_string(), s.val("name")?))).collect(); for s in styles.els().filter(|s| s.name == "style") { @@ -351,7 +366,7 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> { } } // Footnotes (ids -1 / 0 are the separators). - if let Some(f) = part(&mut zip, "word/footnotes.xml").map(|s| parse(&s)).transpose()? { + if let Some(f) = part(&mut zip, "word/footnotes.xml")?.map(|s| parse(&s)).transpose()? { for n in f.els().filter(|n| n.name == "footnote") { if let Some(id) = n.attr("id").filter(|id| id.parse::<i64>().is_ok_and(|v| v > 0)) { ctx.footnotes.insert(id.to_string(), n.clone()); @@ -399,6 +414,17 @@ mod tests { const W: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#; + #[test] + fn oversized_part_is_an_error_not_a_huge_read() { + // A highly compressible part: 64 KiB of spaces inflates past a 4 KiB cap. + let big = docx(&" ".repeat(64 * 1024), "", ""); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(big.as_slice())).unwrap(); + assert!(matches!(part_capped(&mut zip, "word/document.xml", 4096), Err(ImportError::Corrupt(_)))); + // At or under the cap reads fine; a missing part is `None`. + assert_eq!(part_capped(&mut zip, "word/document.xml", 64 * 1024).unwrap().map(|s| s.len()), Some(64 * 1024)); + assert_eq!(part_capped(&mut zip, "word/missing.xml", 4096).unwrap(), None); + } + #[test] fn imports_styles_runs_footnotes_and_tables() { let styles = format!( diff --git a/crates/textimport/src/xlsx.rs b/crates/textimport/src/xlsx.rs index 5f93d3458..8f243711a 100644 --- a/crates/textimport/src/xlsx.rs +++ b/crates/textimport/src/xlsx.rs @@ -21,10 +21,16 @@ fn cell_ref(r: &str) -> Option<(usize, usize)> { } /// The worksheet part of the first sheet (workbook order), via the workbook relationships. -fn first_sheet(zip: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>) -> Option<String> { - let wb = parse(&part(zip, "xl/workbook.xml")?).ok()?; +fn first_sheet(zip: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>) -> Result<Option<String>, ImportError> { + let Some(wb) = part(zip, "xl/workbook.xml")? else { return Ok(None) }; + let Some(rels) = part(zip, "xl/_rels/workbook.xml.rels")? else { return Ok(None) }; + Ok(first_sheet_target(&wb, &rels)) +} + +fn first_sheet_target(wb: &str, rels: &str) -> Option<String> { + let wb = parse(wb).ok()?; let rid = wb.child("sheets")?.els().find(|e| e.name == "sheet")?.attr("id")?.to_string(); - let rels = parse(&part(zip, "xl/_rels/workbook.xml.rels")?).ok()?; + let rels = parse(rels).ok()?; let target = rels.els().find(|r| r.attr("Id") == Some(rid.as_str()))?.attr("Target")?.trim_start_matches('/').to_string(); Some(if target.starts_with("xl/") { target } else { format!("xl/{target}") }) } @@ -60,8 +66,8 @@ fn value(c: &El, shared: &[String]) -> String { pub fn records(bytes: &[u8], sheet: Option<&str>) -> Result<Vec<Vec<String>>, ImportError> { let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| ImportError::Corrupt(e.to_string()))?; let sheet_part = sheet_target(&mut zip, sheet)?; - let sheet_xml = parse(&part(&mut zip, &sheet_part).ok_or_else(|| ImportError::Corrupt(format!("no {sheet_part} (not an Excel workbook)")))?)?; - let shared: Vec<String> = part(&mut zip, "xl/sharedStrings.xml") + let sheet_xml = parse(&part(&mut zip, &sheet_part)?.ok_or_else(|| ImportError::Corrupt(format!("no {sheet_part} (not an Excel workbook)")))?)?; + let shared: Vec<String> = part(&mut zip, "xl/sharedStrings.xml")? .map(|x| parse(&x)) .transpose()? .map(|sst| sst.els().filter(|e| e.name == "si").map(El::text).collect()) @@ -146,7 +152,7 @@ pub fn records(bytes: &[u8], sheet: Option<&str>) -> Result<Vec<Vec<String>>, Im /// Workbook order, or the sheet named `wanted`. fn sheet_target(zip: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>, wanted: Option<&str>) -> Result<String, ImportError> { - let wb = parse(&part(zip, "xl/workbook.xml").ok_or_else(|| ImportError::Corrupt("no xl/workbook.xml (not an Excel workbook)".into()))?)?; + let wb = parse(&part(zip, "xl/workbook.xml")?.ok_or_else(|| ImportError::Corrupt("no xl/workbook.xml (not an Excel workbook)".into()))?)?; let sheets: Vec<(String, String)> = wb .child("sheets") .map(|s| s.els().filter(|e| e.name == "sheet").filter_map(|e| Some((e.attr("name")?.to_string(), e.attr("id")?.to_string()))).collect()) @@ -155,7 +161,7 @@ fn sheet_target(zip: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>, wanted: Optio None => sheets.first().cloned().ok_or_else(|| ImportError::Corrupt("the workbook has no sheets".into()))?, Some(w) => sheets.into_iter().find(|(n, _)| n == w).ok_or_else(|| ImportError::Corrupt(format!("no worksheet \"{w}\"")))?, }; - let rels = parse(&part(zip, "xl/_rels/workbook.xml.rels").ok_or_else(|| ImportError::Corrupt("no workbook relationships".into()))?)?; + let rels = parse(&part(zip, "xl/_rels/workbook.xml.rels")?.ok_or_else(|| ImportError::Corrupt("no workbook relationships".into()))?)?; let target = rels .els() .find(|r| r.attr("Id") == Some(rid.as_str())) @@ -167,9 +173,9 @@ fn sheet_target(zip: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>, wanted: Optio pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> { let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| ImportError::Corrupt(e.to_string()))?; - let sheet_part = first_sheet(&mut zip).unwrap_or_else(|| "xl/worksheets/sheet1.xml".into()); - let sheet = parse(&part(&mut zip, &sheet_part).ok_or_else(|| ImportError::Corrupt(format!("no {sheet_part} (not an Excel workbook)")))?)?; - let shared: Vec<String> = part(&mut zip, "xl/sharedStrings.xml") + let sheet_part = first_sheet(&mut zip)?.unwrap_or_else(|| "xl/worksheets/sheet1.xml".into()); + let sheet = parse(&part(&mut zip, &sheet_part)?.ok_or_else(|| ImportError::Corrupt(format!("no {sheet_part} (not an Excel workbook)")))?)?; + let shared: Vec<String> = part(&mut zip, "xl/sharedStrings.xml")? .map(|x| parse(&x)) .transpose()? .map(|sst| sst.els().filter(|e| e.name == "si").map(El::text).collect()) diff --git a/docs/agents.md b/docs/agents.md index 0d87c8f4d..4571b2797 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -41,6 +41,20 @@ The result is JSON: `{"completed": n, "results": [...]}`, plus `failedIndex` / ` fails (the exit status is non-zero; `--keep-going` records errors and continues). `run --cmd ID=JSON` accepts the same references, and MCP's `batch` tool takes `commands` or `script` text with them. +## Data merge + +`data.source.select {path, sheet?}` links a CSV/TSV/`.xlsx` file to the document (`data.source.update` re-reads it, +`data.source.remove` drops it); `data.fields` lists its fields. `data.placeholder.add {field, role?, story?, at?, end?, item?}` +marks text or a frame as a field (`role`: text, image, qr, hyperlink); `data.options` sets records, tiling and image +fitting; `data.preview {record}` / `data.preview.stop` show one record without saving it. + +`data.merge` now **creates a new document** and leaves the template unchanged (the old `spread` parameter, which +appended pages to the template, is an error). It uses the linked source, or inline `csv` / `rows` / `path` / `bytes`: + +```sh +echo 'data.merge {"path": "people.csv", "records": "range", "range": "1-20"}' | designcraft-cli script - --in template.designcraft --save merged.designcraft +``` + ## The running app ```sh diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0c6f334e1..4650f29c2 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -27,6 +27,10 @@ Start the app with `--control <port>` (or `DESIGNCRAFT_CONTROL_PORT`). The serve | `ui.render` | `{path?, page?, scale?, bleed?}` | Render a page headlessly (PNG; base64 if no path) | | `app.open` / `app.save` / `app.export` / `app.quit` | | Files | +Data merge runs through `engine.execute` like any command: `data.source.select`, `data.fields`, `data.placeholder.add` / +`.remove`, `data.options`, `data.preview` / `data.preview.stop`, `data.merge`. `data.merge` creates and activates a new +merged document; the template stays as it was ([agents.md](agents.md#data-merge)). + Headless window screenshots (locked screen, hidden window): `cargo run -p designcraft-ui-egui --example ui_shot -- script.jsonl`, where each line is one of the requests above, `{"shot": "/abs/out.png"}` or `{"steps": n}` (renders the whole UI offscreen with wgpu). The MCP server (`designcraft-cli mcp`) wraps the same methods for Claude and other agents. diff --git a/docs/superpowers/specs/2026-10-07-data-merge-design.md b/docs/superpowers/specs/2026-10-07-data-merge-design.md deleted file mode 100644 index a3a6846af..000000000 --- a/docs/superpowers/specs/2026-10-07-data-merge-design.md +++ /dev/null @@ -1,208 +0,0 @@ -# Data merge - -Design for phase 1 of data merge. Approved in conversation on 2026-10-07. - -Phase 1 extends the existing `data.merge` command. One template, one linked table, a temporary preview, then a new document. Each record gets a copy of the template, or the items on a single page are tiled. - -Phase 2 is not part of this spec. It adds more than one source, JSON, filter and sort, and a drawn grid. Phase 1 stores a source id on every placeholder so that work can land later without rewriting placeholders. Tiling stays when the grid arrives. - -## What exists today - -`crates/engine/src/cmd/datamerge.rs` takes an inline `csv` string or `rows` array, copies one template spread once per record inside the same document, and replaces `<<Field>>` in story text. One undo step. There is no file on disk, no Excel, no pictures, no QR codes, no hyperlinks, no preview, and no tiling. - -`crates/textimport` already reads the first worksheet of an `.xlsx` file as a table of cached cell values. Place uses that path. Data merge needs rows with empty cells preserved, so place keeps its own reader. - -Pictures go through `file.place` and the Links panel. Hyperlinks are `hyperlink.create` (`url`, `email`, or `page`). QR codes are `object.qrCode` (vector path, types `url`, `text`, `sms`, `email`, `vcard`). Frame fitting is `Fitting` in `crates/doc`: `none`, `fillProportionally`, `fitProportionally`, `fitContentToFrame`, `centerContent`. - -The engine `Session` holds a list of documents and an active index. A merge adds a document. It does not replace the template. - -## Document model - -New fields on `Document` use serde defaults so older files open. Empty data merge state is omitted from the save. - -`DataMerge` on the document: - -- `sources`: a list. Phase 1 has zero or one entry. -- `placeholders`: the list below. -- `options`: the last merge options, saved with the document. - -`DataSource`: - -- `id` -- `path`: absolute path, if the source is a file -- `relative_path`: path relative to the document file, set when the document has been saved -- `name`: file name, used when the source was passed as bytes and has no path -- `delimiter`: `comma`, `tab`, or `semicolon` (text only) -- `sheet`: worksheet name, or absent for the first sheet (Excel only) -- `fields`: `{ name, kind }` where `kind` is `text`, `image`, or `qr` -- `rows`: cached rows, each the same length as `fields`. Missing cells are `""` -- `fingerprint`: file size and modification time of the last successful read, if there is a path -- `status`: `ok`, `missing`, or `modified` - -`Placeholder`: - -- `id` -- `source_id` -- `field`: the name, without `@` or `#` -- `role`: `text`, `image`, `qr`, or `hyperlink` -- `anchor`: either `{ story, start, end }` in Unicode scalar offsets, matching `HyperlinkSource::Text`, or `{ item }` for a frame - -`MergeOptions`, with these defaults: - -- `records`: `all`. Also `one` with a 1-based record number, or `range` with a range string -- `per_page`: `single` or `multiple`. Default `single` -- `arrange`: `rows` or `columns`. Default `rows`. Used only when `per_page` is `multiple` -- `insets`: top, right, bottom, left, in points. Default 36, 36, 36, 36. Used only when tiling -- `column_spacing` and `row_spacing`: points. Default 0. Used only when tiling -- `fitting`: `fitProportionally` (default), `fillProportionally`, `fitContentToFrame`, or `none` -- `center`: bool, default false. Applied after fitting, by moving the graphic to the middle of the frame -- `link_images`: bool, default true -- `limit`: absent, or a positive count taken after the record selection - -Preview is session state on the open document, not a field of `Document`. It stores the 1-based record being shown, or nothing. Saving writes the unfilled template. `data.preview.stop`, closing the document, and a successful merge all clear it. - -## Reading a source - -`data.source.select` accepts `path`, or `bytes` plus `name`. The name's extension selects the reader. `delimiter` (`comma`, `tab`, `semicolon`) overrides sniffing for text. `sheet` names an Excel worksheet. The first sheet in workbook order is the default. - -Text is UTF-8. A leading byte-order mark is stripped. Any other encoding is an error that names the file. The command does not guess a legacy encoding. - -Delimiter sniff, only when the command does not set one: - -- `.tsv` or `.tab`: tab -- `.csv`: comma -- `.txt`: tab if the header line contains a tab; otherwise semicolon if that line contains a semicolon and no comma; otherwise comma -- anything else with text contents: the same sniff as `.txt` - -Excel (`.xlsx` only): - -- Cached values. The reader does not evaluate formulas. A formula cell with no cached value is empty. -- A date is the stored number, not a formatted date. -- Booleans are `TRUE` and `FALSE`. Whole numbers drop a trailing `.0`. -- Empty cells inside the header's width are `""`. -- `.xlsm` and `.xls` are errors. - -Rows: - -- The first row is field names, trimmed. An empty name is an error. A duplicate name is an error. -- A leading apostrophe on a header is ignored. Then a leading `@` sets `image` and a leading `#` sets `qr`. The mark is not part of the name. Any other field is `text`. -- Each later row is one record. A short row is padded with `""`. A row whose cells are all empty is skipped. Cells past the header are ignored, and the result's `warnings` names the row. -- More than 100,000 data rows or 200 columns is an error. The command does not allocate them. - -Inline `csv` on `data.merge` and `data.fields` uses this same text parser and does not create a source. Inline `rows` is an array of objects. The field list is the key order of the first object. Later objects are read by those keys. A missing key is `""`. An extra key is ignored, with a warning. `@` and `#` on a key work the same way as a header cell. Non-strings are stringified with JSON's string form for numbers and bools, and rejected for arrays and objects. - -`data.source.select` returns `{ fields, records, warnings }`. `fields` is `{ name, kind }`. `records` is the data-row count. After a file read, status is `ok` and the fingerprint is stored. On open, and before a merge, if the path is set: missing file sets `missing`; a different size or modification time sets `modified`. A merge uses the cached rows either way and adds a warning for `missing` or `modified`. `data.source.update` re-reads. With no path, update is an error. `data.source.remove` drops the source and leaves placeholders in place. - -When the document is saved, `relative_path` is refreshed from the document's path. On open, a missing absolute path is tried again as `relative_path` beside the document. - -## Placeholders - -`data.placeholder.add` takes `field`, `role`, and a target. The target is the text caret (`story` plus `at`) or a frame (`item`). The field must exist on the document's source. Phase 1 has one source, and the placeholder stores that source id. - -Defaults the panel uses, which the command may also set by omitting `role`: - -- Caret in a story: `text` -- A selected frame and an `image` field: `image` -- A selected frame and a `qr` field: `qr` -- A plain `text` field on a frame is `image` only when `role` is `image`. The panel calls that Bind to frame. -- `hyperlink` is always explicit. - -Inserting `text` writes `<<Name>>` at the caret, using the character style already at that point, and anchors the placeholder to that range. `<<` and `>>` are ASCII 0x3C and 0x3E. Inserting `image` or `qr` anchors the placeholder to the frame and shows `<<Name>>` as the frame's label. It does not place a picture yet. Pictures and QR codes are frames, not inline in a story. Inserting `hyperlink` anchors a text range or a frame and does not change the visible text. - -`data.placeholder.remove` deletes the object. A text placeholder also deletes the `<<Name>>` characters it owns. Deleting those characters by editing deletes the placeholder. The role on the placeholder wins over the column kind. An `image` column inserted as `text` merges as the path string. A `text` column with role `image` merges as a picture. A `qr` column inserted as `text` merges as the payload string. - -A typed `<<Name>>` that is not covered by a text placeholder is still replaced with the field's text at preview and at merge. It never becomes a picture, a QR code, or a hyperlink. An unknown `<<Nope>>` is left as typed. - -## Filling one record - -Used by preview and by the merge. Record numbers are 1-based positions in the cached rows after empty rows are skipped. - -- `text`: replace the anchored characters, or the typed `<<Name>>`, with the cell. Keep the character style of the first character of that range. -- `image`: resolve the cell as a file path. The first existing file wins: the path as given if it is absolute, then the path beside the data file, then the path beside the saved document. `http` and `https` are not downloaded. They are missing images. Place into that frame. `link_images` true creates a normal link. False embeds the bytes in the document assets and creates no external link. Fitting and `center` come from the merge options. A missing or unreadable file leaves the frame empty, adds `{ record, field, path }` to `missingImages`, and continues. -- `qr`: build a vector code with the same encoder as `object.qrCode` type `text`, using the cell as `content`, inside the frame, black. An empty cell leaves the frame empty and adds a warning. -- `hyperlink`: do not change visible text. If this same range is also a text placeholder, the text rule still runs. Trim the cell. Empty adds no link. If the value contains `://` or whitespace, the destination is a URL. Otherwise, if it contains exactly one `@` and no `:`, the destination is an email. Otherwise it is a URL. Create one hyperlink on that anchor for this record. A failed create adds a warning and continues. - -A placeholder whose frame or story lives on a parent spread is not filled. The merge copies it onto the new document's parent still showing `<<Name>>`, and adds one warning. - -## Preview - -`data.preview` takes `record`. The record stepper clamps to the cached rows. Preview fills the live template, and the session keeps a stash of the unfilled document. Save, `data.preview.stop`, switching away, removing the source, and `data.merge` restore that stash before they continue. Any other edit stops preview first, so the edit applies to the unfilled template. The file on disk never contains the preview text. - -## Merge - -`data.merge` reads the linked source when `path`, `bytes`, `csv`, and `rows` are all absent. Passing one of those reads it for that run only and does not attach a source. Attaching is `data.source.select`. - -The template document is not modified. Its undo stack is unchanged. Preview is cleared before the copy is made, so the new document is filled from the unfilled template, not from the preview. The command builds a new document, appends it to the session, and makes it active. The new document is a normal document: edit it and save it, or close it and discard the run. - -Record selection, applied to the cached rows: - -- `all`: every row -- `one`: that 1-based row. Out of range is an error -- `range`: a comma-separated list of tokens. A token is `N` or `A-B`, both ends inclusive, optional whitespace. `A` greater than `B` is an error. A number outside `1..=row count` is an error. Duplicates are dropped. Order is the order of first appearance -- Then `limit`, if set, keeps the first N selected records. `0` is an error - -No selected records is an error. No document is created. - -`per_page: single` copies the whole template once per record. A 3-page template writes 3 pages per record, all filled with that record. Pages, parent pages, styles, swatches, layers, and sections are copied. Document-page placeholders are filled. Parent placeholders stay unfilled. - -`per_page: multiple` is tiling. It is an error, and no document is created, when facing pages are on or the template has more than one page. The block is every item on the document page whose bounds intersect the page, except ruler guides. Pasteboard items that miss the page are drawn on the first merged page only. Ruler guides are drawn on every merged page and are not stepped. - -The block's width and height are the union of those items. `step_x` is that width plus `column_spacing`. `step_y` is that height plus `row_spacing`. The first record is placed at the block's original position, even when that position crosses an inset. Later records use whole steps `(col, row)` from that origin, with `col` and `row` starting at 0. - -Rows first increases `col` until the candidate's right edge would pass `page_width - right_inset`, then sets `col` to 0 and increases `row`. Columns first increases `row` until the candidate's bottom edge would pass `page_height - bottom_inset`, then sets `row` to 0 and increases `col`. A candidate that fails the other inset (`row` against the bottom, or `col` against the right) starts a new page, and that record is `(0, 0)` on the new page. The left and top insets do not move the origin. If a second copy cannot be placed on the first page at all, every record gets its own page, with one warning. - -Each merged page has the template page's size, margins, and guides. Pages after the first repeat the block and the guides, not the pasteboard-only items. The last page may be partly empty. No empty records are added to fill a grid. - -The command returns: - -- `records`: how many records were written -- `pages`: pages in the new document -- `missingImages`: `{ record, field, path }` -- `oversetStories`: how many stories in the new document are overset -- `warnings`: strings - -Overset text does not fail the merge. The old `spread` parameter is removed. - -## Panel - -Window > Utilities > Data Merge opens the panel. It does not merge by itself. The panel reads engine state and calls `app.run`. - -The panel shows the source file name, or "No data source", plus Update and Remove. The field list shows a kind icon drawn in code (a T, a picture mark, a module grid), the field name, and how many placeholders use that field. Preview is a checkbox. The stepper is first, previous, a record number, next, and last. It is disabled until preview is on. Create Merged Document runs `data.merge` with the saved options. Those options are edited in that dialog: which records, records per page, and, when multiple is selected, arrange, insets, and spacing. Image options are fitting, center, and link images, plus the optional limit. - -Colors come from `theme::Tokens`. New strings go through the existing i18n table. No Adobe icons. - -On the web, and from the control channel, the caller passes `csv`, `rows`, or `bytes` plus `name`. There is no filesystem dialog. - -## Errors - -Every command returns `Result`. A bad path, a bad sheet name, a bad encoding, a bad header, a bad range, a tiling refusal, and the row or column cap are errors and create no document. A missing image, an empty QR cell, a failed hyperlink, a modified source, an ignored extra cell, and a parent placeholder are warnings on a successful merge. - -No `unwrap`, `expect`, `panic`, or `unsafe` in the new code. Sizes and offsets use checked arithmetic. The row and column caps bound the allocation. Locks follow the crate's poison policy. - -## Tests - -Engine tests, with synthetic strings and tiny files in memory: - -- Quoted commas, doubled quotes, and a quoted newline stay one field. The existing comma parser already covers the first two. -- Tab and semicolon files, and `.txt` sniffing for each. -- Excel: first sheet, a named sheet, a cached number, an empty formula cell, a boolean, and a date serial left as digits. -- A non-UTF-8 file, an empty header, and a duplicate header are errors. -- `@Photo` and `#Code` become fields `Photo`/`image` and `Code`/`qr`. -- Typed `<<Title>>` is replaced. Typed `<<Nope>>` stays. A text placeholder keeps its character style. -- Image resolution: absolute, beside the data file, beside the document. A missing file continues, with `missingImages` set and an empty frame. An `https` value is missing and is not fetched. -- A `text` field with role `image` places a picture. An `image` field with role `text` inserts the path. -- A QR placeholder encodes the cell text. An empty cell warns. -- Hyperlink: `a@b.c` is email, `https://example.com` is a URL, empty adds no link. -- Single-record merge of a 2-page template and 3 records writes 6 pages. The template's page count and undo length are unchanged. The new document is active. -- Tiling a single non-facing page, six records, rows first, produces the measured grid: three across, then the next row, step equal to the block plus spacing. A block that crosses the bottom inset starts a new page. -- Tiling a facing-page document, and tiling a two-page document, each return an error and add no document. -- Update re-reads a changed fingerprint. Merge with `modified` warns and uses the cache. Remove leaves placeholders. -- 100,001 rows is an error. -- Preview of record 2 shows that row. Stop restores `<<Name>>`. The saved document bytes do not contain the preview text. -- Inline `rows` still merges, including a non-string number. - -UI: the panel lists fields from `data.fields`, and Create Merged Document calls `data.merge`. A headless `designcraft-cli` run covers the command. Wasm still accepts `rows`. - -## Out of scope - -Multiple sources, enabling and disabling sources, JSON, filter rules, sort, skip-on-warning, source filename and merge index fields, a drawn grid (rows, columns, gutter, record offset, record advance, start corner), QR codes from an unmarked column via the QR tool, inline pictures in a story, per-record parent-page fields, downloading remote images, evaluating Excel formulas, formatting Excel dates, `.xls`, and merging straight to PDF. From fdffbb4b2367a6a524b97fd7f52c14c458e911a8 Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Wed, 7 Oct 2026 22:01:57 -0400 Subject: [PATCH 13/13] M9: distinct zh label for Revert; translations are our own Revert no longer shares Undo's Simplified Chinese label. Drop the comments and ROADMAP wording that tied terminology to a vendor's help. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 2 +- crates/ui-egui/src/i18n.rs | 6 ++---- crates/ui-egui/src/i18n/ar.rs | 3 +-- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 586be4e6f..306e3b06e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -97,7 +97,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Transparency flattener (`file.exportPdf {flatten: high|medium|low|ppi}`, Edit ▸ Transparency Flattener Presets): each object involving transparency becomes an opaque image of its area over what's beneath it, so everything else stays vector (spreads with transparent parent items are rasterised whole). - Video and sound (place .mp4/.mov/.webm/.mp3/.wav…, `media.options/get`, Media panel): media frames show a poster or placeholder on screen, in print and in PDF, and export as HTML5 `<video>`/`<audio>` in EPUB. Interactive PDF export (`file.exportPdf {media: true}`, File ▸ Export PDF (Interactive)) embeds each file and plays it in a Screen annotation through a rendition action (controls, loop, play on page load). No in-app playback. - Right-to-left text (World-Ready basics): right-to-left runs are shaped right to left, lines are reordered by the Unicode Bidirectional Algorithm (matched against the reference implementation), Paragraph Direction (`type.para {direction}`, Paragraph panel, IDML ParagraphDirection, EPUB `dir`). Digits (`digits`: Default, Arabic, Hindi, Farsi, Native by language; Character panel for right-to-left text; IDML DigitsType) draw 0–9 in another script. Kashidas (`kashidas` paragraph attribute, on by default): justified Arabic lines stretch at one join per word (after seen/sad when possible, never inside lam–alef) with a drawn tatweel before word spaces widen. Carets follow the drawing: the caret before a right-to-left glyph sits at its right edge, arrow keys move as drawn (by words in the line's direction), clicks find the nearest drawn caret position and selections highlight the selected glyphs. Binding: Right to Left (Document Setup, `layout.documentSetup {binding}`, IDML PageBinding) makes page 1 a left page and lays spreads out right to left. -- Interface language (Edit ▸ Interface Language, `app.language`): German, French, Spanish, Japanese, Simplified Chinese and Arabic menu titles, common menu items and panel names (our own translations). Common dialog, panel and tooltip labels are also localized, with English fallback for untranslated strings. Arabic translations cover every key in the shared interface translation table, including menu commands and utility labels. Chinese and Arabic feature terminology is checked against public Adobe help where verified; project-specific labels remain original translations. The Type menu's Story Direction, Horizontal/Vertical, Tate-Chu-Yoko, Ruby… and Kenten are translated too. Arabic labels use Unicode bidi run ordering and contextual shaping; welcome and new-document form layouts follow RTL direction independently of document binding. CJK and Arabic glyphs come from [craft-fonts](https://github.com/storytold/craft-fonts) when built with `CRAFT_FONTS_DIR` (all releases): BIZ UDPGothic and Noto Sans CJK SC for the UI (Chinese forms first in the Chinese interface), Noto Sans Arabic, and Mincho for Japanese documents (the web build embeds BIZ UDPGothic only); otherwise from system fonts. +- Interface language (Edit ▸ Interface Language, `app.language`): German, French, Spanish, Japanese, Simplified Chinese and Arabic menu titles, common menu items and panel names (our own translations). Common dialog, panel and tooltip labels are also localized, with English fallback for untranslated strings. Arabic translations cover every key in the shared interface translation table, including menu commands and utility labels. The Type menu's Story Direction, Horizontal/Vertical, Tate-Chu-Yoko, Ruby… and Kenten are translated too. Arabic labels use Unicode bidi run ordering and contextual shaping; welcome and new-document form layouts follow RTL direction independently of document binding. CJK and Arabic glyphs come from [craft-fonts](https://github.com/storytold/craft-fonts) when built with `CRAFT_FONTS_DIR` (all releases): BIZ UDPGothic and Noto Sans CJK SC for the UI (Chinese forms first in the Chinese interface), Noto Sans Arabic, and Mincho for Japanese documents (the web build embeds BIZ UDPGothic only); otherwise from system fonts. - Page transitions (`page.transition/transitions`, Page Transitions panel): twelve transition types with speed and direction per spread, written into exported PDFs as `/Trans` entries through an incremental update. - PDF/X-4 export (`file.exportPdf {standard: "x4"}`): an output intent with our own Generic CMYK ICC profile (lut16 tables generated from the parametric press model), GTS_PDFXVersion in Info and XMP, and built-in PDF/X-4 checks reported as warnings. qpdf finds no errors in the result. - EPS place: placed at its (high-resolution) bounding box and shown, printed and exported through its TIFF preview, or a placeholder when it has none. PostScript itself isn't interpreted. diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 8958c4447..d7657220a 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1,7 +1,5 @@ //! Interface language (Edit › Interface Language): translations of menu titles, the most used -//! menu items and panel names. Untranslated strings stay English. -//! Chinese and Arabic feature terminology is checked against public Adobe help where documented; -//! project-specific labels are original. +//! menu items and panel names. Untranslated strings stay English. The translations are our own. use std::{collections::HashMap, sync::OnceLock}; @@ -31,7 +29,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Save", ["Speichern", "Enregistrer", "Guardar", "保存", "保存"]), ("Save As…", ["Speichern unter …", "Enregistrer sous…", "Guardar como…", "別名で保存…", "另存为…"]), ("Save a Copy…", ["Kopie speichern …", "Enregistrer une copie…", "Guardar una copia…", "コピーを保存…", "保存副本…"]), - ("Revert", ["Zurück zur letzten Version", "Version précédente", "Volver a la versión guardada", "復帰", "还原"]), + ("Revert", ["Zurück zur letzten Version", "Version précédente", "Volver a la versión guardada", "復帰", "恢复到上次保存"]), ("Place…", ["Platzieren …", "Importer…", "Colocar…", "配置…", "置入…"]), ("Export PDF…", ["PDF exportieren …", "Exporter en PDF…", "Exportar PDF…", "PDF を書き出し…", "导出PDF…"]), ("Print…", ["Drucken …", "Imprimer…", "Imprimir…", "プリント…", "打印…"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 65b22db79..375447746 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1,5 +1,4 @@ -//! Arabic interface translations, stored in logical Unicode order. -//! Feature terminology follows Adobe Arabic help where verified; this is not an official Adobe UI. +//! Original Arabic interface translations, stored in logical Unicode order. pub(super) const TABLE: &[(&str, &str)] = &[ ("File", "ملف"), ("Edit", "تحرير"),