From e8ec23a88d71e4b004634e4967df0346ad91bd44 Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:51:54 +0200 Subject: [PATCH 1/5] TOC-1: preserve right-indent separators and expose their picker --- ROADMAP.md | 2 ++ crates/doc/src/story.rs | 29 ++++++++++++++++++++++ crates/engine/src/tests_idml.rs | 2 +- crates/idml/src/names.rs | 39 +++++++++++++----------------- crates/idml/src/tests.rs | 2 +- crates/ui-egui/src/dialogs.rs | 43 ++++++++++++++++++++++++++++++--- docs/ui-parity.md | 8 +++++- 7 files changed, 97 insertions(+), 28 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9bd0274ef..9a52fcd2b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -346,6 +346,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log +- **2026-10-18** · TOC separators preserve right-indent tabs through the dialog and IDML, including localized `$ID/^y` values. A separator picker exposes Tab Character, Right Indent Tab, Em Space and En Space. Existing paragraph tab leaders now work with the tutorial's TOC workflow. No readiness percentage change. + Newest first. - **2026-10-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change. diff --git a/crates/doc/src/story.rs b/crates/doc/src/story.rs index e3a0e39ee..2f9860d31 100644 --- a/crates/doc/src/story.rs +++ b/crates/doc/src/story.rs @@ -31,6 +31,35 @@ pub const FRAME_BREAK: char = '\u{E003}'; pub const PAGE_BREAK: char = '\u{E004}'; pub const INDENT_HERE: char = '\u{E005}'; pub const RIGHT_INDENT_TAB: char = '\u{E006}'; + +/// Encode a table-of-contents separator using InDesign's special-character notation. +pub fn toc_separator_notation(text: &str) -> String { + text.replace('^', "^^").replace('\t', "^t").replace(RIGHT_INDENT_TAB, "^y").replace('\u{2003}', "^m").replace('\u{2002}', "^>") +} + +/// Decode TOC notation, preserving unknown codes and an escaped literal caret. +pub fn toc_separator_text(notation: &str) -> String { + let mut text = String::new(); + let mut chars = notation.chars(); + while let Some(ch) = chars.next() { + if ch != '^' { + text.push(ch); + continue; + } + match chars.next() { + Some('t') => text.push('\t'), + Some('y') => text.push(RIGHT_INDENT_TAB), + Some('m') => text.push('\u{2003}'), + Some('>') => text.push('\u{2002}'), + Some('^') | None => text.push('^'), + Some(other) => { + text.push('^'); + text.push(other); + } + } + } + text +} pub const NEXT_PAGE_NUMBER: char = '\u{E007}'; pub const PREV_PAGE_NUMBER: char = '\u{E008}'; /// Anchor of a table: alone in its paragraph, whose [`ParaFormat::table`] names the table. diff --git a/crates/engine/src/tests_idml.rs b/crates/engine/src/tests_idml.rs index 0701052d0..686687781 100644 --- a/crates/engine/src/tests_idml.rs +++ b/crates/engine/src/tests_idml.rs @@ -510,7 +510,7 @@ fn table_of_contents_definition_round_trips_through_idml_and_updates() { s.execute( "toc.generate", &json!({"page": page, "title": "Inside", "titleStyle": basic, "entries": [ - {"style": "Cover Title", "level": 1, "pageNumber": "before", "separator": " ^ "}, + {"style": "Cover Title", "level": 1, "pageNumber": "before", "separator": " ^ \u{E006} "}, {"style": "Headline", "level": 2, "entryStyle": basic, "pageNumber": "none"}, ]}), ) diff --git a/crates/idml/src/names.rs b/crates/idml/src/names.rs index c242a1626..69d17c50b 100644 --- a/crates/idml/src/names.rs +++ b/crates/idml/src/names.rs @@ -837,30 +837,25 @@ pub fn digits_in(s: &str) -> Option { [Digits::Default, Digits::Arabic, Digits::Hindi, Digits::Farsi, Digits::Native].into_iter().find(|d| digits_out(*d) == s) } -/// A TOC separator in IDML's special-character notation: `^t` is a tab, `^^` a caret. +/// A TOC separator in IDML's special-character notation. pub fn toc_separator_out(s: &str) -> String { - s.replace('^', "^^").replace('\t', "^t") + designcraft_doc::story::toc_separator_notation(s) } -/// IDML TOC separator → text (`^t` and `^y`, the right indent tab, are tabs; other codes stay). +/// IDML may prefix a separator with its localized-string marker. pub fn toc_separator_in(s: &str) -> String { - // InDesign writes its built-in values with the `$ID/` prefix (`$ID/^y`, the right indent tab). - let s = s.strip_prefix("$ID/").unwrap_or(s); - let mut out = String::new(); - let mut it = s.chars(); - while let Some(c) = it.next() { - if c != '^' { - out.push(c); - continue; - } - match it.next() { - Some('t' | 'y') => out.push('\t'), - Some('^') => out.push('^'), - Some(o) => { - out.push('^'); - out.push(o); - } - None => out.push('^'), - } + designcraft_doc::story::toc_separator_text(s.strip_prefix("$ID/").unwrap_or(s)) +} + +#[cfg(test)] +mod toc_separator_tests { + use super::*; + + #[test] + fn localized_right_indent_tab_is_not_an_ordinary_tab() { + assert_eq!(toc_separator_in("$ID/^y"), "\u{E006}"); + assert_eq!(toc_separator_in("$ID/ ^y "), " \u{E006} "); + assert_eq!(toc_separator_in("^t"), "\t"); + assert_eq!(toc_separator_out("\u{E006}"), "^y"); + assert_eq!(toc_separator_in("^^y ^q ^"), "^y ^q ^"); } - out } diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 785bf458f..a1551b3e6 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -4216,7 +4216,7 @@ fn toc_style_entries_read_id_prefixed_separators_and_styles() { let d = import_idml(&fixture_with_table_styles(TOC_STYLES, TOC_STORY)).unwrap(); let toc = d.toc.as_ref().expect("the story's TOC style is imported"); let entry = toc.entries.iter().find(|e| e.style == "Heading 1").unwrap(); - assert_eq!(entry.separator, None, "`$ID/^y` is the right indent tab, the default separator, not text: {:?}", entry.separator); + assert_eq!(entry.separator.as_deref(), Some("\u{E006}"), "right-indent tabs must not become ordinary tab stops"); assert_eq!(entry.entry_style.as_deref(), Some("TOC/TOC body text")); assert_eq!(entry.page_number_style.as_deref(), Some("TOC Page Number")); assert_eq!(toc.title_style.as_deref(), Some("TOC/TOC Header")); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 358416a4b..c1eff1078 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -6212,11 +6212,11 @@ struct TocRow { } fn toc_separator_shown(s: &str) -> String { - s.replace('\t', "^t").replace('\u{2003}', "^m").replace('\u{2002}', "^>") + designcraft_doc::story::toc_separator_notation(s) } fn toc_separator_typed(s: &str) -> String { - s.replace("^t", "\t").replace("^m", "\u{2003}").replace("^>", "\u{2002}") + designcraft_doc::story::toc_separator_text(s) } fn toc_rows(d: &Dialog) -> Vec { @@ -6407,7 +6407,19 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { toc_style_combo(ui, "page_number", &char_styles, none, &mut r.page_number_style); ui.end_row(); crate::rtl::label(ui, tr("Between Entry and Number:")); - ui.add(egui::TextEdit::singleline(&mut r.separator).desired_width(80.0).horizontal_align(egui::Align::Min)); + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(&mut r.separator).desired_width(80.0).horizontal_align(egui::Align::Min)); + ui.menu_button("▸", |ui| { + for (label, code) in [("Tab Character", "^t"), ("Right Indent Tab", "^y"), ("Em Space", "^m"), ("En Space", "^>")] { + if ui.button(tr(label)).clicked() { + r.separator.push_str(code); + ui.close(); + } + } + }) + .response + .on_hover_text(tr("Insert Special Character")); + }); crate::rtl::label(ui, tr("Style:")); toc_style_combo(ui, "separator", &char_styles, none, &mut r.separator_style); ui.end_row(); @@ -7957,6 +7969,31 @@ mod tests { assert_eq!(opts(&app), before); } + #[test] + fn toc_right_indent_separator_survives_the_dialog_and_update() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.newSample", json!({})).unwrap(); + open_toc(&mut app); + app.ui.dialog.as_mut().unwrap().fields.insert( + "included".into(), + json!([ + {"style": "Headline", "level": 1, "separator": " ^y "} + ]), + ); + confirm(&mut app).unwrap(); + let separator = format!(" {} ", designcraft_doc::story::RIGHT_INDENT_TAB); + let d = &app.session.doc().unwrap().doc; + let toc = d.toc.as_ref().unwrap(); + assert_eq!(toc.entries[0].separator.as_deref(), Some(separator.as_str())); + assert!(d.story(toc.story).unwrap().text.contains(&separator)); + app.run("toc.update", json!({})).unwrap(); + open_toc(&mut app); + assert_eq!(toc_rows(app.ui.dialog.as_ref().unwrap())[0].separator, " ^y "); + for text in ["^y", "^t^y", "^m^>", "unknown ^q and trailing ^", "a\t\u{E006}\u{2003}\u{2002}z"] { + assert_eq!(toc_separator_typed(&toc_separator_shown(text)), text); + } + } + #[test] fn toc_dialog_adds_a_style_and_ok_makes_the_table_of_contents() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2dcf4687a..d7984e115 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (live corners work; document views row: scrollbars, Alt pans while editing text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; paragraph style override feedback and clearing) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (live corners work; document views row: scrollbars, Alt pans while editing text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; paragraph style override feedback and clearing) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS) Tools, handles, snapping, nudging, modifiers, shortcuts, panels, menus, context menus and feel, against InDesign 2026. The look was measured from the running app @@ -70,6 +70,12 @@ history (DC-1). ## Remaining effort +The Table of Contents separator field accepts `^y` (Right Indent Tab), with a special-character +picker alongside it. Literal carets use `^^`; ordinary tabs, em/en spaces and surrounding spaces +survive reopening. IDML preserves right-indent separators, including `$ID/^y`, so updating an +imported TOC keeps page numbers at the right edge and inherits paragraph tab leaders. This covers +10:54–16:08 of [Michael Bullo's TOC tutorial](https://www.youtube.com/watch?v=m4y2tm6LH_c). + Applying a paragraph or character style now removes local values equal to the applied style, so text used to capture a style follows later edits to that style. Different local formatting and named character styles stay intact. Paragraph-style application treats selection endpoints From 81913c2f482cecfcef98c6e8ce40368e58f8dee2 Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:07:02 +0200 Subject: [PATCH 2/5] TOC-1: localize separator picker and address review cleanup --- ROADMAP.md | 4 ++-- crates/doc/src/story.rs | 15 ++++++++------- crates/ui-egui/src/i18n.rs | 1 + crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/cs.rs | 1 + crates/ui-egui/src/i18n/de.rs | 1 + crates/ui-egui/src/i18n/it.rs | 4 ++++ crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/ka.rs | 1 + crates/ui-egui/src/i18n/pl.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 4 ++++ crates/ui-egui/src/i18n/ru.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 4 ++++ 13 files changed, 30 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9a52fcd2b..3b8d62fd4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -346,10 +346,10 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log -- **2026-10-18** · TOC separators preserve right-indent tabs through the dialog and IDML, including localized `$ID/^y` values. A separator picker exposes Tab Character, Right Indent Tab, Em Space and En Space. Existing paragraph tab leaders now work with the tutorial's TOC workflow. No readiness percentage change. - Newest first. +- **2026-10-18** · TOC separators preserve right-indent tabs through the dialog and IDML, including localized `$ID/^y` values. A separator picker exposes Tab Character, Right Indent Tab, Em Space and En Space. Existing paragraph tab leaders now work with the tutorial's TOC workflow. No readiness percentage change. + - **2026-10-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change. - **2026-10-16** · Greek text hyphenates. The patterns were never compiled in, so Greek set unhyphenated however narrow the column; `"Greek"`, `"Greek: Greece"`, `el` and `el-GR` all resolve now and ανθρωπος breaks αν-θρω-πος. They are the monotonic patterns (hyph-el-monoton, Dimitrios Filippou, under MIT), which is what polytonic text hyphenates by too. Five of InDesign's ~40 hyphenation languages. No readiness percentage change. - **2026-10-16** · A swatch applied to a placed greyscale or bitmap image colours it, as in InDesign: IDML's `` is read and written, greyscale pixels become a tint of the swatch on opaque white and a bitmap's white lets the frame show through, on screen and in PDF (spot swatches print on their plate). Colour images keep their colours. Applying the swatch from the Swatches panel comes next. No readiness percentage change. diff --git a/crates/doc/src/story.rs b/crates/doc/src/story.rs index 2f9860d31..510c2d849 100644 --- a/crates/doc/src/story.rs +++ b/crates/doc/src/story.rs @@ -32,6 +32,14 @@ pub const PAGE_BREAK: char = '\u{E004}'; pub const INDENT_HERE: char = '\u{E005}'; pub const RIGHT_INDENT_TAB: char = '\u{E006}'; +pub const NEXT_PAGE_NUMBER: char = '\u{E007}'; +pub const PREV_PAGE_NUMBER: char = '\u{E008}'; +/// Anchor of a table: alone in its paragraph, whose [`ParaFormat::table`] names the table. +pub const TABLE_ANCHOR: char = '\u{E009}'; +pub const FORCED_LINE_BREAK: char = '\u{2028}'; +pub const ODD_PAGE_BREAK: char = '\u{E011}'; +pub const EVEN_PAGE_BREAK: char = '\u{E012}'; + /// Encode a table-of-contents separator using InDesign's special-character notation. pub fn toc_separator_notation(text: &str) -> String { text.replace('^', "^^").replace('\t', "^t").replace(RIGHT_INDENT_TAB, "^y").replace('\u{2003}', "^m").replace('\u{2002}', "^>") @@ -60,13 +68,6 @@ pub fn toc_separator_text(notation: &str) -> String { } text } -pub const NEXT_PAGE_NUMBER: char = '\u{E007}'; -pub const PREV_PAGE_NUMBER: char = '\u{E008}'; -/// Anchor of a table: alone in its paragraph, whose [`ParaFormat::table`] names the table. -pub const TABLE_ANCHOR: char = '\u{E009}'; -pub const FORCED_LINE_BREAK: char = '\u{2028}'; -pub const ODD_PAGE_BREAK: char = '\u{E011}'; -pub const EVEN_PAGE_BREAK: char = '\u{E012}'; /// A column, frame or page break character. It ends its paragraph: the story stores it as the /// paragraph's last character, followed by the `\n` separator (also at the end of the story), and diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index efe089ef9..a2f3f269e 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -4397,6 +4397,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("OpenType font", ["OpenType-Schrift", "Police OpenType", "Fuente OpenType", "OpenType フォント", "OpenType 字体"]), ("Variable font", ["Variable Schrift", "Police variable", "Fuente variable", "バリアブルフォント", "可变字体"]), ("Color font", ["Farbschrift", "Police couleur", "Fuente de color", "カラーフォント", "彩色字体"]), + ("Tab Character", ["Tabulatorzeichen", "Caractère de tabulation", "Carácter de tabulación", "タブ文字", "制表符"]), // Table of Contents. ( "Sort Entries in Alphabetical Order", diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index b050de38b..5b22f124b 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1,5 +1,6 @@ //! Original Arabic interface translations, stored in logical Unicode order. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Tab Character", "محرف جدولة"), ("File", "ملف"), ("Edit", "تحرير"), ("Layout", "تخطيط"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e77e72a1c..f47746c4a 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -3,6 +3,7 @@ //! product's localization was used). Phrases shared with the MIT OR Apache-2.0 Czech catalogs of //! the ArtCraft siblings (VectorCraft, PhotoCraft) are kept identical; see ASSETS.md for sources. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Tab Character", "Znak tabulátoru"), ("File", "Soubor"), ("Edit", "Úpravy"), ("Layout", "Rozvržení"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index efc128858..80874fe91 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -3,6 +3,7 @@ //! Brazilian Portuguese, Italian and Ukrainian tables has a row here; the German column of the shared //! table is carried over and stays the fallback for keys added there after this table. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Tab Character", "Tabulatorzeichen"), ("File", "Datei"), ("Edit", "Bearbeiten"), ("Layout", "Layout"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index a918a9829..f8175c619 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1,6 +1,10 @@ //! Original Italian interface translations, written from the English source //! using ordinary Italian desktop-publishing vocabulary. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Tab Character", "Carattere di tabulazione"), + ("Right Indent Tab", "Tabulazione rientro destro"), + ("Em Space", "Spazio em"), + ("En Space", "Spazio en"), ("File", "File"), ("Edit", "Modifica"), ("Layout", "Layout"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 5647ca940..919b10ed4 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -3,6 +3,7 @@ //! Portuguese tables has a row here; the menu titles and panel names that were already Japanese in the //! shared table are carried over unchanged. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Tab Character", "タブ文字"), ("File", "ファイル"), ("Edit", "編集"), ("Layout", "レイアウト"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 975da9ca1..dcd92b4d7 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -4,6 +4,7 @@ //! Portuguese, Japanese, Italian and Ukrainian tables has a row here, plus a few labels that only //! the code or the command table use. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Tab Character", "ტაბულაციის სიმბოლო"), ("File", "ფაილი"), ("Edit", "რედაქტირება"), ("Layout", "განლაგება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 68d685d99..a36518fda 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -2,6 +2,7 @@ //! are welcome. Terminology follows common Polish DTP usage //! (rozkładówka, łam, strona wzorcowa, obszar informacyjny) and the sibling ArtCraft catalogs. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Tab Character", "Znak tabulacji"), ("File", "Plik"), ("Edit", "Edycja"), ("Layout", "Układ"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 8521de594..888387797 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -1,6 +1,10 @@ //! Original Brazilian Portuguese interface translations, written from the English source //! using ordinary Brazilian Portuguese computing vocabulary. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Tab Character", "Caractere de tabulação"), + ("Right Indent Tab", "Tabulação de recuo à direita"), + ("Em Space", "Espaço eme"), + ("En Space", "Espaço ene"), ("File", "Arquivo"), ("Edit", "Editar"), ("Layout", "Layout"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 251555ca5..4069930c8 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -3,6 +3,7 @@ //! The translations are our own; counted nouns are genitive plurals because the caption puts the //! category before the number ("страниц: 5"), see `count_label`. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Tab Character", "Символ табуляции"), ("File", "Файл"), ("Edit", "Редактирование"), ("Can't Open the File", "Не удалось открыть файл"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index dd4a1434d..43030ff08 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -2,6 +2,10 @@ //! Common phrases are adapted from the MIT OR Apache-2.0 ArtCraft sibling catalogs //! (VectorCraft, PhotoCraft, WordCraft and DeckCraft); see ASSETS.md for pinned sources. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Tab Character", "Символ табуляції"), + ("Right Indent Tab", "Табуляція правого відступу"), + ("Em Space", "Кругла шпація"), + ("En Space", "Півкругла шпація"), ("File", "Файл"), ("Edit", "Редагування"), ("Layout", "Макет"), From 0c5dd0ede83256dee41f1f4a329c435de627e305 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 17:03:46 +0200 Subject: [PATCH 3/5] PR-1255: TOC style references in indented properties resolve; the picker keeps a trailing caret Co-Authored-By: Claude Opus 5.5 --- crates/idml/src/import.rs | 11 +++++++++-- crates/idml/src/tests.rs | 23 +++++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 17 ++++++++++++++++- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 76e2104f1..260911582 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1498,10 +1498,12 @@ impl<'r> Importer<'r> { let story = top.iter().find(|e| e.local() == "Story" && e.get("AppliedTOCStyle") == Some(id))?; Some((ts, *self.story_ids.get(story.get("Self")?)?)) })?; - let known = |r: Option<&str>| r.map(|r| self.para_style_ref(r)).filter(|n| self.styles.para(n).is_some()); + // Property children are element text: formatting whitespace around a reference is dropped + // unless the reference as written names a style. + let known = |r: Option<&str>| r.map(|r| self.listed_para_style_ref(r)).filter(|n| self.styles.para(n).is_some()); // Character styles the document has; [None] is stored as no style. let known_chars = |r: Option<&str>| { - r.map(|r| self.char_style_ref(r)).filter(|n| n != designcraft_doc::NO_CHAR_STYLE && self.styles.char_style(n).is_some()) + r.map(|r| self.listed_char_style_ref(r)).filter(|n| n != designcraft_doc::NO_CHAR_STYLE && self.styles.char_style(n).is_some()) }; let entries: Vec = ts .elements() @@ -1538,6 +1540,11 @@ impl<'r> Importer<'r> { fn para_style_ref(&self, r: &str) -> String { self.para_names.get(r).cloned().unwrap_or_else(|| names::style_name_in(PARA_BUILTINS, &unescape_id(r.trim_start_matches("ParagraphStyle/")))) } + /// A paragraph style reference written as an element's text: as written when it names a + /// style, otherwise without surrounding whitespace (see [`Self::listed_char_style_ref`]). + fn listed_para_style_ref(&self, r: &str) -> String { + if self.para_names.contains_key(r) { self.para_style_ref(r) } else { self.para_style_ref(r.trim()) } + } /// A character style reference written as an element's text (nested, line and GREP styles). /// A style name may end in a space, so the reference is used as written when it names a /// style; only otherwise is surrounding whitespace dropped. diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 1c992192a..582242255 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -4266,3 +4266,26 @@ fn toc_style_entries_read_id_prefixed_separators_and_styles() { assert_eq!(entry.page_number_style.as_deref(), Some("TOC Page Number")); assert_eq!(toc.title_style.as_deref(), Some("TOC/TOC Header")); } + +#[test] +fn toc_style_references_in_indented_properties_resolve() { + // Property children laid out on their own lines: the whitespace around a reference isn't + // part of the style name. + let styles = TOC_STYLES + .replace( + ">ParagraphStyle/$ID/TOC%3aTOC body text<", + "> + ParagraphStyle/$ID/TOC%3aTOC body text + <", + ) + .replace( + ">CharacterStyle/TOC Page Number<", + "> + CharacterStyle/TOC Page Number + <", + ); + let d = import_idml(&fixture_with_table_styles(&styles, TOC_STORY)).unwrap(); + let entry = d.toc.as_ref().unwrap().entries.iter().find(|e| e.style == "Heading 1").unwrap(); + assert_eq!(entry.entry_style.as_deref(), Some("TOC/TOC body text")); + assert_eq!(entry.page_number_style.as_deref(), Some("TOC Page Number")); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index af1e75146..69cc222ea 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -6221,6 +6221,15 @@ fn toc_separator_typed(s: &str) -> String { designcraft_doc::story::toc_separator_text(s) } +/// Appends the special-character `code` (such as `^y`) to the separator field's notation. A +/// trailing unescaped caret is a literal caret: it's escaped first, so it doesn't swallow the code. +fn toc_separator_append(field: &mut String, code: &str) { + if field.chars().rev().take_while(|&c| c == '^').count() % 2 == 1 { + field.push('^'); + } + field.push_str(code); +} + fn toc_rows(d: &Dialog) -> Vec { d.fields.get("included").cloned().and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default() } @@ -6414,7 +6423,7 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.menu_button("▸", |ui| { for (label, code) in [("Tab Character", "^t"), ("Right Indent Tab", "^y"), ("Em Space", "^m"), ("En Space", "^>")] { if ui.button(tr(label)).clicked() { - r.separator.push_str(code); + toc_separator_append(&mut r.separator, code); ui.close(); } } @@ -8008,6 +8017,12 @@ mod tests { for text in ["^y", "^t^y", "^m^>", "unknown ^q and trailing ^", "a\t\u{E006}\u{2003}\u{2002}z"] { assert_eq!(toc_separator_typed(&toc_separator_shown(text)), text); } + // The picker after a literal caret: the caret stays, and the code is the code. + for (field, typed) in [("^", "^\u{E006}"), ("^^", "^\u{E006}"), ("a", "a\u{E006}"), ("^^^", "^^\u{E006}")] { + let mut f = field.to_string(); + toc_separator_append(&mut f, "^y"); + assert_eq!(toc_separator_typed(&f), typed, "{field:?} → {f:?}"); + } } #[test] From 1d59b6e739ffaa227a3b383f35e05bf8f20bab9e Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 19:16:05 +0200 Subject: [PATCH 4/5] PR-1255: test the TOC importer's fallbacks for malformed and unsupported values Co-Authored-By: Claude Opus 5.5 --- crates/idml/src/tests.rs | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 582242255..98934c869 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -4267,6 +4267,43 @@ fn toc_style_entries_read_id_prefixed_separators_and_styles() { assert_eq!(toc.title_style.as_deref(), Some("TOC/TOC Header")); } +#[test] +fn toc_style_entries_fall_back_on_malformed_and_unsupported_values() { + let long = "x".repeat(65); + let entries = format!( + r#" + ParagraphStyle/Missing + CharacterStyle/$ID/[None] + CharacterStyle/Missing + + + + "# + ); + let start = TOC_STYLES.find("").unwrap(); + let styles = format!("{}{entries}\n{}", &TOC_STYLES[..start], &TOC_STYLES[end..]) + .replace(r#"TitleStyle="ParagraphStyle/TOC%3aTOC Header""#, r#"TitleStyle="ParagraphStyle/Missing""#); + let d = import_idml(&fixture_with_table_styles(&styles, TOC_STORY)).unwrap(); + d.check().unwrap(); + let toc = d.toc.as_ref().expect("the TOC style is still imported"); + assert_eq!(toc.title_style, None, "a title style the document lacks is dropped"); + assert_eq!(toc.entries.len(), 3, "an entry without a style name is skipped: {:?}", toc.entries); + let e1 = &toc.entries[0]; + assert_eq!(e1.level, 1, "an unreadable level falls back to 1"); + assert_eq!(e1.page_number, designcraft_doc::TocPageNumber::After, "an unknown page-number position falls back to After"); + assert_eq!(e1.separator.as_deref(), Some("^q"), "an unknown special-character code is kept as written"); + assert_eq!( + (e1.entry_style.as_deref(), e1.page_number_style.as_deref(), e1.separator_style.as_deref()), + (None, None, None), + "undefined and [None] styles are no style" + ); + let e2 = &toc.entries[1]; + assert_eq!(e2.level, 9, "a level past 9 is clamped"); + assert_eq!(e2.separator, None, "an over-long separator is dropped"); + assert_eq!((toc.entries[2].level, toc.entries[2].separator.as_deref()), (2, None), "a plain tab is the default separator"); +} + #[test] fn toc_style_references_in_indented_properties_resolve() { // Property children laid out on their own lines: the whitespace around a reference isn't From 83aa235cbc85a927e0f0c14ad79ace9a3f0a9053 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:30:31 +0100 Subject: [PATCH 5/5] TOC import: the listed style reference shared from main's split import styles module Co-Authored-By: Claude Opus 5.5 --- crates/idml/src/import/styles.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/idml/src/import/styles.rs b/crates/idml/src/import/styles.rs index a05e7d50b..13b140733 100644 --- a/crates/idml/src/import/styles.rs +++ b/crates/idml/src/import/styles.rs @@ -225,7 +225,7 @@ impl<'r> Importer<'r> { /// A paragraph style reference written as an element's text: as written when it names a /// style, otherwise without surrounding whitespace (see [`Self::listed_char_style_ref`]). - fn listed_para_style_ref(&self, r: &str) -> String { + pub(super) fn listed_para_style_ref(&self, r: &str) -> String { if self.para_names.contains_key(r) { self.para_style_ref(r) } else { self.para_style_ref(r.trim()) } } /// A character style reference written as an element's text (nested, line and GREP styles).