From aaaf68c6c73757866270e72e8cf98b6ab5316c57 Mon Sep 17 00:00:00 2001 From: alexanderalber <90604107+alexanderalber@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:09:49 +0200 Subject: [PATCH 01/26] Contributors: add my own entry (alexanderalber) (#566) generated with Claude Code Co-authored-by: Brandon Thomas --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 16c281c..1fd42c2 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -40,3 +40,6 @@ sync_github_name = false [people.henno] sync_github_name = true + +[people.alexanderalber] +real_name = "Alexander Alber" From e9da654903ea8997ffab8eb17bd28de5cfebd452 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:12:39 +0200 Subject: [PATCH 02/26] IME composition text uses document fallback fonts instead of boxes (#565) (#578) Co-authored-by: Brandon Thomas --- crates/ui-egui/src/canvas.rs | 16 +++++++++ crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/theme.rs | 67 +++++++++++++++++++++++++++++++++++- 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f930ba7..dd3e306 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -37,6 +37,8 @@ pub struct CanvasState { pub focused: bool, pub render_ms: f64, pub ime_preedit: String, + /// Document faces added to the interface fonts to draw the input method's composition (#565). + pub(crate) ime_faces: Vec>, pub want_focus: bool, pub context_issue: Option, pub context_synonyms: Option, @@ -109,6 +111,7 @@ impl Default for CanvasState { focused: false, render_ms: 0.0, ime_preedit: String::new(), + ime_faces: Vec::new(), want_focus: true, context_issue: None, context_synonyms: None, @@ -676,6 +679,19 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { }); } if !app.canvas.ime_preedit.is_empty() { + // The composition is interface text, but it may be in a script the interface fonts lack + // (Bangla, Thai ...): borrow the document fonts' fallback faces, or it draws as boxes (#565). + let new = crate::theme::faces_for_preedit( + &app.canvas.ime_preedit, + &app.canvas.ime_faces, + |c| (c as u32) < 0x250, + |c| wordcraft_fonts::FontDb::global().fallback_for(c, u32::MAX), + ); + if !new.is_empty() { + app.canvas.ime_faces.extend(new); + crate::theme::install_fonts_extra(ui.ctx(), app.fonts_hans, app.fonts_system_cjk, &app.canvas.ime_faces); + ui.ctx().request_repaint(); + } let g = painter.text(pos2(x, y1), egui::Align2::LEFT_BOTTOM, &app.canvas.ime_preedit, regular(c.height * scale * 0.8), caret_color); painter.line_segment([pos2(g.min.x, g.max.y), pos2(g.max.x, g.max.y)], Stroke::new(1.0, caret_color)); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c458e35..3a37d05 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -945,7 +945,7 @@ impl WordApp { // interface that doesn't open the language list. let system_cjk = self.fonts_system_cjk || self.want_system_cjk || lang.uses_cjk(); if !self.styled || lang.prefers_hans() != self.fonts_hans || system_cjk != self.fonts_system_cjk { - theme::install_fonts_with(ctx, lang.prefers_hans(), system_cjk); + theme::install_fonts_extra(ctx, lang.prefers_hans(), system_cjk, &self.canvas.ime_faces); self.fonts_hans = lang.prefers_hans(); self.fonts_system_cjk = system_cjk; // Mod with -, = and 0 are Word shortcuts (optional hyphen, subscript, paragraph spacing); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 2ac7bbe..a6d330d 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -275,7 +275,52 @@ pub fn install_fonts_for(ctx: &egui::Context, prefer_hans: bool) { /// a glyph (#241, #486). Reading that font is a large file read, so callers ask for it only when /// CJK text is on screen: a CJK interface language, or the language names in Options. pub fn install_fonts_with(ctx: &egui::Context, prefer_hans: bool, system_cjk: bool) { - ctx.set_fonts(font_definitions(prefer_hans, system_cjk)); + install_fonts_extra(ctx, prefer_hans, system_cjk, &[]); +} + +/// [`install_fonts_with`], also adding document-font `faces` as last-resort fallbacks (#565): the +/// faces that cover the text an input method is composing. +pub fn install_fonts_extra(ctx: &egui::Context, prefer_hans: bool, system_cjk: bool, faces: &[Arc]) { + let mut fonts = font_definitions(prefer_hans, system_cjk); + add_fallback_faces(&mut fonts, faces); + ctx.set_fonts(fonts); +} + +/// Add `faces` after every other font of the interface families. +pub fn add_fallback_faces(fonts: &mut FontDefinitions, faces: &[Arc]) { + for f in faces { + let name = format!("fallback {} {} {}", f.family, f.style, f.id()); + let mut data = FontData::from_owned(f.data().to_vec()); + data.index = f.index(); + fonts.font_data.insert(name.clone(), Arc::new(data)); + for fam in [FontFamily::Proportional, FontFamily::Name("medium".into()), FontFamily::Name("semibold".into())] { + if let Some(v) = fonts.families.get_mut(&fam) { + v.push(name.clone()); + } + } + } +} + +/// Document faces needed to draw `text` (the input method's composition), one for each character +/// the interface fonts cannot draw, skipping faces in `have` (#565). Characters of the embedded +/// interface fonts are not looked up; very large faces (system CJK collections) are left to the CJK +/// fallback. +pub fn faces_for_preedit( + text: &str, + have: &[Arc], + covered: impl Fn(char) -> bool, + lookup: impl Fn(char) -> Option>, +) -> Vec> { + let mut out: Vec> = Vec::new(); + for c in text.chars().filter(|c| !c.is_ascii() && !c.is_whitespace() && !covered(*c)) { + if have.iter().chain(&out).any(|f| f.covers(c)) { + continue; + } + if let Some(f) = lookup(c).filter(|f| f.data().len() <= 16 << 20 && !have.iter().chain(&out).any(|h| h.id() == f.id())) { + out.push(f); + } + } + out } /// The interface fonts; see [`install_fonts_with`]. @@ -510,6 +555,26 @@ pub fn c32(c: wordcraft_doc::Rgb) -> Color32 { mod tests { use std::collections::BTreeSet; + /// #565: the composition of an input method (Bangla through ibus) drew as boxes because only the + /// interface fonts were used. Faces found for the uncovered characters are added as fallbacks, + /// once, and not for characters the interface fonts draw. + #[test] + fn preedit_gets_document_fallback_faces() { + let face = wordcraft_fonts::FontDb::global().face("Source Sans 3", "Regular"); + let lookup = |_: char| Some(face.clone()); + let bangla = "\u{0986}\u{09AE}\u{09BF}"; + let got = super::faces_for_preedit(bangla, &[], |_| false, lookup); + assert_eq!(got.len(), 1, "one face, not one per character"); + assert!(super::faces_for_preedit(bangla, &got, |_| false, lookup).is_empty(), "already added"); + assert!(super::faces_for_preedit(bangla, &[], |_| true, lookup).is_empty(), "the interface fonts draw it"); + assert!(super::faces_for_preedit("abc", &[], |_| false, lookup).is_empty(), "ASCII never needs one"); + let mut defs = super::font_definitions(false, false); + let before = defs.font_data.len(); + super::add_fallback_faces(&mut defs, &got); + assert_eq!(defs.font_data.len(), before + 1); + assert!(defs.families[&egui::FontFamily::Proportional].last().is_some_and(|n| n.starts_with("fallback "))); + } + /// Symbols written as text in the interface code must exist in the interface fonts (Inter and /// egui's emoji fallbacks); a missing glyph draws as an empty box, as the panes' "✕" close /// button once did. Letters are the catalogs' business (see the i18n font tests). From 0db810d0e82e040e090cc468976c6a7952bbc3ec Mon Sep 17 00:00:00 2001 From: alexanderalber <90604107+alexanderalber@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:15:27 +0200 Subject: [PATCH 03/26] =?UTF-8?q?Layout=20=E2=80=BA=20Page=20Setup:=20the?= =?UTF-8?q?=20group=20launcher=20opens=20the=20Page=20Setup=20dialog=20(#4?= =?UTF-8?q?71)=20(#563)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The launcher ran ui.dialog, which needs a dialog name, but group launchers run their command with no parameters, so nothing opened. It now runs layout.pageSetup, which asks the UI for the Page Setup dialog, as the Font and Paragraph launchers run their dialog commands. The test opens the dialog through layout.pageSetup and checks that no ribbon group uses ui.dialog as its launcher. generated with Claude Code Co-authored-by: Brandon Thomas --- crates/ui-egui/src/ribbon.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index c91c16e..9308e8e 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -994,7 +994,7 @@ fn design(app: &mut WordApp, ui: &mut Ui) { } fn layout(app: &mut WordApp, ui: &mut Ui) { - group(ui, "Page Setup", Some("ui.dialog"), app, |ui, app| { + group(ui, "Page Setup", Some("layout.pageSetup"), app, |ui, app| { // Text Direction works on what the caret is in: table cells, a text box, else the section. let dir = wordcraft_engine::cmd::page::text_direction_at(&app.session); menu_button(ui, app, "textDirection", Some("Text\nDirection"), "Text Direction", true, |ui, app| { @@ -2293,6 +2293,21 @@ mod tests { assert!(!contextual_tabs(&s).contains(&"Shape Format")); } + #[test] + fn page_setup_launcher_opens_the_dialog() { + // A group launcher runs its command with no parameters (#471). + let mut app = crate::WordApp::new(Session::new(wordcraft_doc::Document::new()), crate::Services::default()); + app.run("layout.pageSetup", json!({})).unwrap(); + assert!(matches!(app.dialog, Some(crate::dialogs::Dialog::PageSetup { .. }))); + // `ui.dialog` needs a dialog name, so it can't be a launcher. + let launchers: Vec<&str> = include_str!("ribbon.rs") + .split("group(ui, \"") + .filter_map(|g| g.split_once('"')?.1.strip_prefix(", Some(\"")?.split('"').next()) + .collect(); + assert!(launchers.contains(&"layout.pageSetup"), "{launchers:?}"); + assert!(!launchers.contains(&"ui.dialog"), "{launchers:?}"); + } + #[test] fn smart_art_picker_inserts_and_design_tab_follows_the_selection() { let mut app = crate::WordApp::new(Session::new(wordcraft_doc::Document::new()), Default::default()); From cfb65364c73e0f382b0675c0445777647146dd85 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:18:07 +0200 Subject: [PATCH 04/26] Web: a CSV picked as recipients no longer turns the next TXT drop into recipients (#472) (#579) Co-authored-by: Brandon Thomas --- crates/ui-egui/src/lib.rs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3a37d05..4f0038d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1135,11 +1135,12 @@ impl WordApp { for (name, bytes) in files { let lower = name.to_ascii_lowercase(); let img = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"].iter().any(|e| lower.ends_with(e)); - let list = - lower.ends_with(".csv") || lower.ends_with(".tsv") || (lower.ends_with(".txt") && std::mem::take(&mut self.recipient_list_pending)); + // Any recipient file ends the pending picker request, so a later .txt drop opens normally. + let asked = std::mem::take(&mut self.recipient_list_pending); + let list = lower.ends_with(".csv") || lower.ends_with(".tsv") || (lower.ends_with(".txt") && asked); let sheet = [".xlsx", ".xlsm", ".ods"].iter().any(|e| lower.ends_with(e)); + self.recipient_list_pending = asked && !list && !sheet; let r = if sheet { - self.recipient_list_pending = false; self.load_recipients(json!({"path": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)})) } else if list { self.load_recipients(json!({"csv": wordcraft_engine::io::decode_text(&bytes)})) @@ -2885,6 +2886,17 @@ mod tests { inbox.lock().unwrap().push(("more.csv".into(), b"Name\nBo\nCy\n".to_vec())); a.drain_inbox(); assert_eq!(a.session.merge.rows.len(), 2); + // A CSV picked through the picker must not turn the next .txt drop into recipients. + a.run("ui.openRecipientList", json!({})).unwrap(); + inbox.lock().unwrap().push(("people.csv".into(), b"Name,City\nAda,London\n".to_vec())); + a.drain_inbox(); + assert!(!a.recipient_list_pending); + inbox.lock().unwrap().push(("notes.txt".into(), b"A plain text document.\nAnother line.\n".to_vec())); + a.drain_inbox(); + assert_eq!(prompt(&a), Some("saveChanges"), "the TXT opens like any document"); + a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); + assert_eq!(a.session.merge.headers, vec!["Name", "City"], "the recipients stay"); + assert!(body_text(&a).contains("A plain text document."), "the TXT opened as a document"); // A cancelled picker leaves nothing pending once the user does something else. a.run("ui.openRecipientList", json!({})).unwrap(); a.run("format.bold", json!({})).unwrap(); From 387873ae05e515033ed02451fe78b8b05d0c094c Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:20:33 +0200 Subject: [PATCH 05/26] Zotero: a pending citation no longer edits a replacement document (#470) (#580) Co-authored-by: Brandon Thomas --- crates/ui-egui/src/zotero.rs | 32 +++++++++++++++++++++++++++++++- docs/zotero.md | 4 +++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/zotero.rs b/crates/ui-egui/src/zotero.rs index a8dc1a4..0437df5 100644 --- a/crates/ui-egui/src/zotero.rs +++ b/crates/ui-egui/src/zotero.rs @@ -38,6 +38,8 @@ pub struct ZoteroLink { alert: Option, /// The command in flight. pub busy: Option, + /// The document the command in flight started in; its calls are refused once another is open. + document: u64, /// How the last command ended. pub last: Option, /// Zotero's calls in the command in flight (logged when it ends). @@ -96,6 +98,7 @@ fn start(app: &mut WordApp, cmd: Command, ctx: Option) -> Result< if let Some(b) = app.zotero.busy { return Err(format!("Zotero is still working on {}; finish in Zotero's window first", b.wire_name())); } + let document = app.session.document_id(); let (call_tx, call_rx) = channel::(); let (done_tx, done_rx) = channel(); let wake = ctx.clone(); @@ -123,6 +126,7 @@ fn start(app: &mut WordApp, cmd: Command, ctx: Option) -> Result< z.calls = Some(call_rx); z.done = Some(done_rx); z.busy = Some(cmd); + z.document = document; log::info!("Zotero: {} started", cmd.wire_name()); app.status(tl!("Waiting for Zotero…")); Ok(json!({"started": cmd.wire_name()})) @@ -156,7 +160,7 @@ pub fn poll(app: &mut WordApp, ctx: &egui::Context) { // One alert at a time: Zotero sends nothing else while it waits for the answer. while app.zotero.alert.is_none() { let Some(p) = app.zotero.calls.as_ref().and_then(|rx| rx.try_recv().ok()) else { break }; - if p.call.method == "Document_displayAlert" { + if p.call.method == "Document_displayAlert" && app.session.document_id() == app.zotero.document { let text = p.call.args.get(1).and_then(Value::as_str).unwrap_or("").to_string(); let buttons = p.call.args.get(3).and_then(Value::as_i64).unwrap_or(0); app.zotero.methods.push(p.call.method.clone()); @@ -164,6 +168,11 @@ pub fn poll(app: &mut WordApp, ctx: &egui::Context) { continue; } app.zotero.methods.push(p.call.method.clone()); + if app.session.document_id() != app.zotero.document { + // New or Open replaced the document this command started in: its edits must not land in the new one. + let _ = p.reply.send(Err("WordCraft replaced the document this command started in".into())); + continue; + } let rev = app.session.rev(); let mut host = AppHost { ctx }; let r = app.zotero.bridge.handle(&mut app.session, &mut host, &p.call); @@ -313,6 +322,27 @@ mod tests { assert_eq!(app.session.doc.plain_text(StoryRef::Body), "Cite: "); } + #[test] + fn a_pending_citation_does_not_insert_into_a_replacement_document() { + let (addr, server) = fake_zotero(vec![ + json!(["Application_getActiveDocument", [3]]), + json!(["Document_insertField", [1, "ReferenceMark", 0]]), + json!(["Field_setCode", [1, 1, "ITEM CSL_CITATION {}"]]), + json!(["Field_setText", [1, 1, "(Sample, 2020)", false]]), + json!(["Document_complete", [1]]), + ]); + let mut app = WordApp::new(Session::new(Document::new()), Services::default()); + app.zotero.options.addr = addr; + let ctx = egui::Context::default(); + app.ctx = Some(ctx.clone()); + app.run("ui.zotero.addEditCitation", json!({})).unwrap(); + app.run("file.new", json!({"template": "blank"})).unwrap(); + pump(&mut app, &ctx, |a| a.zotero.busy.is_none()); + server.join().unwrap(); + assert_eq!(app.session.doc.plain_text(StoryRef::Body), ""); + assert!(!app.session.can_undo(), "the old command left no undo step in the new document"); + } + #[test] fn no_zotero_is_a_clear_error() { let free = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap(); diff --git a/docs/zotero.md b/docs/zotero.md index 4041691..f05b62c 100644 --- a/docs/zotero.md +++ b/docs/zotero.md @@ -21,7 +21,9 @@ Citations (each runs one of the commands below). They run in the app (and through the control channel and MCP bridged to the app). A command returns at once with `{"started": …}`; Zotero then works in its own window and WordCraft answers -it in the background. All changes from one command are one undo step. +it in the background. All changes from one command are one undo step. If New or Open replaces +the document while a command is running, Zotero's remaining calls for it get an error and the +new document is left as it is. From a terminal, against a file: From d1064bbaad2e55063f574e3990a573a152fea991 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:23:54 +0200 Subject: [PATCH 06/26] Zotero: a note with a picture stays when its citation moves to body text (#469) (#581) Co-authored-by: Brandon Thomas --- crates/zotero/src/bridge.rs | 4 ++++ crates/zotero/src/tests.rs | 16 ++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/crates/zotero/src/bridge.rs b/crates/zotero/src/bridge.rs index c95e67a..b5d00bb 100644 --- a/crates/zotero/src/bridge.rs +++ b/crates/zotero/src/bridge.rs @@ -568,6 +568,10 @@ fn remove_empty_note(s: &mut Session, f: &ZField) -> Option { for b in blocks { if let Some(p) = b.as_para() { rest.push_str(&p.text); + // A picture, shape, equation... is content too, even though it has no text. + if !p.objects.iter().all(|o| o.is_marker() || matches!(o, InlineObject::NoteRef { .. })) { + return None; + } } } if rest.chars().any(|c| c != OBJ && !c.is_whitespace()) { diff --git a/crates/zotero/src/tests.rs b/crates/zotero/src/tests.rs index a4a365d..4a8d581 100644 --- a/crates/zotero/src/tests.rs +++ b/crates/zotero/src/tests.rs @@ -512,6 +512,22 @@ fn a_note_with_other_text_stays_when_its_citation_leaves() { assert!(z.call("Document_convert", json!([1, [999], [], [1], 1])).is_err()); } +#[test] +fn a_note_with_a_picture_stays_when_its_citation_leaves() { + let mut z = Z::new("Text.", 5); + z.begin(); + let a = z.cite(1, "Doe, Title."); + let pid = *z.s.doc.parts.keys().next().unwrap(); + let p = z.s.doc.para_mut(StoryRef::Part(pid), &Path::top(0)).unwrap(); + let n = p.len(); + let image = InlineObject::Image { media: "m".into(), w: 1.0, h: 1.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None }; + p.insert_object(n, image, &Default::default()).unwrap(); + z.ok("Field_convert", json!([1, a, "ReferenceMark", 0])); + assert_eq!(z.s.doc.parts.len(), 1); + assert!(z.s.doc.para(StoryRef::Part(pid), &Path::top(0)).unwrap().objects.iter().any(|o| matches!(o, InlineObject::Image { .. }))); + assert!(z.text().ends_with("Doe, Title.")); +} + /// Zotero reads a new bibliography field back before filling it, and unlinks it when it is /// empty (it takes that for a bibliography the user deleted). Seen with Zotero 10: Add/Edit /// Bibliography did nothing. From 0652e7f72fb572307e48af4f0aa3b3afa3cbce6b Mon Sep 17 00:00:00 2001 From: DevCoderMax Date: Sun, 11 Oct 2026 23:27:44 -0300 Subject: [PATCH 07/26] Pictures in headers/footers can be moved, resized and rotated (#591) * Pictures in headers/footers can be moved, resized and rotated Hit testing (layout.object_at_in), arrange.bounds/position/nudge/group and the canvas selection, drag, resize and rotation handles used to be body-only, so pictures in headers and footers could not be moved (#82). They now work within their own header/footer story: drag to move, handles to resize, rotation handle to turn, arrows to nudge. Regression test: header_pictures_can_be_resized_and_dragged. * Credit DevCoderMax (Gleyson do Nascimento Carvalho) in About --------- Co-authored-by: Brandon Thomas --- contributors/people.toml | 4 ++ crates/engine/src/cmd/draw.rs | 10 ++-- crates/engine/src/cmd/objects.rs | 95 +++++++++++++++++++++++--------- crates/layout/src/hit.rs | 40 +++++++++++++- crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/objects.rs | 61 ++++++++++++++------ 6 files changed, 162 insertions(+), 50 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 1fd42c2..766bfd8 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -41,5 +41,9 @@ sync_github_name = false [people.henno] sync_github_name = true +[people.DevCoderMax] +real_name = "Gleyson do Nascimento Carvalho" +display_name = "Gleyson Carvalho" +sync_github_name = false [people.alexanderalber] real_name = "Alexander Alber" diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index 7c15c5b..e9cd535 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -470,11 +470,13 @@ fn stroke(s: &mut Session, v: &Value) -> CmdResult { /// under `anchor_y` (positioned from its column and top), or, where no paragraph starts on the /// page, to the nearest line and positioned on the page. Returns where its character went. fn place(s: &mut Session, layout: &DocLayout, obj: InlineObject, page: usize, (x, y): (f32, f32), anchor_y: f32) -> Result { - let (at, on_page) = match super::objects::anchor_paragraph(layout, page, anchor_y) { + let (at, on_page) = match super::objects::anchor_paragraph(layout, page, anchor_y, StoryRef::Body) { Some(path) => (Pos { story: StoryRef::Body, path, off: 0 }, false), - None => { - (super::objects::nearest_line(layout, page, anchor_y).ok_or_else(|| CmdError::Params("no text on that page to anchor to".into()))?, true) - } + None => ( + super::objects::nearest_line(layout, page, anchor_y, StoryRef::Body) + .ok_or_else(|| CmdError::Params("no text on that page to anchor to".into()))?, + true, + ), }; s.doc.insert_object(&at, obj, &CharProps::default())?; let len = OBJ.len_utf8(); diff --git a/crates/engine/src/cmd/objects.rs b/crates/engine/src/cmd/objects.rs index e815aa6..fc658de 100644 --- a/crates/engine/src/cmd/objects.rs +++ b/crates/engine/src/cmd/objects.rs @@ -28,6 +28,16 @@ fn has_floating(s: &Session) -> Option<&'static str> { _ => Some("select a floating picture or shape first"), } } +/// Stories whose objects can be moved, resized and arranged: the body and headers/footers. +/// Objects in table cells (deeper paths), notes and text boxes stay where their text is. +fn movable_story(s: &Session, story: StoryRef) -> bool { + match story { + StoryRef::Body => true, + StoryRef::Part(id) => { + s.doc.parts.get(&id).is_some_and(|p| matches!(p.kind, wordcraft_doc::PartKind::Header | wordcraft_doc::PartKind::Footer)) + } + } +} /// Charts and diagrams are not written back on save yet, so their size, position and wrapping stay /// as imported: they can be selected and deleted, not moved, resized or arranged. const FROZEN: &str = "charts and diagrams can't be moved or resized yet"; @@ -688,9 +698,10 @@ fn bounds(s: &mut Session, v: &Value) -> CmdResult { /// position afterwards. fn move_object(s: &mut Session, pos: Pos, page: Option, x: f32, y: f32, (w, h): (f32, f32)) -> Result { use wordcraft_doc::para::Anchor; - if pos.story != StoryRef::Body || pos.path.depth() > 0 { - return Err(CmdError::Disabled("only objects in the body text can be moved".into())); + if pos.path.depth() > 0 || !movable_story(s, pos.story) { + return Err(CmdError::Disabled("only objects in the body text, headers and footers can be moved".into())); } + let story = pos.story; let layout = s.layout(); let cur = layout.object(&pos, s.page_hint).ok_or_else(|| CmdError::Failed("the object isn't laid out".into()))?; let page = page.map_or(cur.page, |n| usize::try_from(n).unwrap_or(usize::MAX)); @@ -698,19 +709,19 @@ fn move_object(s: &mut Session, pos: Pos, page: Option, x: f32, y: f32, (w, // Keep a corner of it on the page. let x = x.max(12.0 - w).min(pg.w - 12.0); let y = y.max(12.0 - h).min(pg.h.min(MAX_OFFSET) - 12.0); - let (anchor, on_page) = match anchor_paragraph(&layout, page, y) { - Some(path) => (Pos { story: StoryRef::Body, path, off: 0 }, false), - None => (nearest_line(&layout, page, y).ok_or_else(|| CmdError::Params("no text on that page to anchor to".into()))?, true), + let (anchor, on_page) = match anchor_paragraph(&layout, page, y, story) { + Some(path) => (Pos { story, path, off: 0 }, false), + None => (nearest_line(&layout, page, y, story).ok_or_else(|| CmdError::Params("no text on that page to anchor to".into()))?, true), }; // Move its character to the anchor (unless it's already in that paragraph). - let at = if anchor.path == pos.path && !on_page { + let at = if anchor.story == pos.story && anchor.path == pos.path && !on_page { pos } else { let obj = s.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)).cloned().ok_or_else(|| CmdError::Failed("object vanished".into()))?; let len = wordcraft_doc::para::OBJ.len_utf8(); s.doc.delete_range(&pos, &Pos { off: pos.off + len, ..pos.clone() })?; let mut at = anchor; - if at.path == pos.path && at.off > pos.off { + if at.story == pos.story && at.path == pos.path && at.off > pos.off { at.off = at.off.saturating_sub(len); } let at = s.doc.clamp(&at); @@ -798,31 +809,33 @@ fn move_with_text(s: &mut Session, with_text: bool) -> CmdResult { Ok(json!({"object": serde_json::to_value(o).unwrap_or(Value::Null)})) } -/// The top-level body paragraph under `y` on `page`: the last one starting at or above it (else -/// the first starting on the page). -pub(crate) fn anchor_paragraph(layout: &wordcraft_layout::DocLayout, page: usize, y: f32) -> Option { - let starts: Vec<(&wordcraft_doc::Path, f32)> = layout - .pages - .get(page)? - .items - .iter() +/// The top-level paragraph of `story` under `y` on `page`: the last one starting at or above +/// it (else the first starting on the page). Body paragraphs come from the page's items; +/// header/footer ones from its header and footer. +pub(crate) fn anchor_paragraph(layout: &wordcraft_layout::DocLayout, page: usize, y: f32, story: StoryRef) -> Option { + let pg = layout.pages.get(page)?; + let items: Box + '_> = match story { + StoryRef::Body => Box::new(pg.items.iter()), + StoryRef::Part(_) => Box::new(pg.header.iter().chain(pg.footer.iter())), + }; + let starts: Vec<(&wordcraft_doc::Path, f32)> = items .filter_map(|it| match it { - wordcraft_layout::Placed::Lines { story: StoryRef::Body, path, l0: 0, y: top, .. } if path.depth() == 0 => Some((path, *top)), + wordcraft_layout::Placed::Lines { story: s, path, l0: 0, y: top, .. } if *s == story && path.depth() == 0 => Some((path, *top)), _ => None, }) .collect(); starts.iter().rev().find(|(_, top)| *top <= y + 0.5).or(starts.first()).map(|(p, _)| (*p).clone()) } -/// The start of the top-level body line on `page` nearest to `y`. -pub(crate) fn nearest_line(layout: &wordcraft_layout::DocLayout, page: usize, y: f32) -> Option { - let lines = wordcraft_layout::hit::page_lines(layout.pages.get(page)?, StoryRef::Body); +/// The start of the top-level line of `story` on `page` nearest to `y`. +pub(crate) fn nearest_line(layout: &wordcraft_layout::DocLayout, page: usize, y: f32, story: StoryRef) -> Option { + let lines = wordcraft_layout::hit::page_lines(layout.pages.get(page)?, story); let best = lines.iter().filter(|l| l.path.depth() == 0).min_by(|a, b| { let d = |l: &wordcraft_layout::hit::LineHit| if y < l.top { l.top - y } else { (y - l.bottom).max(0.0) }; d(a).total_cmp(&d(b)) })?; let off = best.para.lines.get(best.li)?.start; - Some(Pos { story: StoryRef::Body, path: best.path.clone(), off }) + Some(Pos { story, path: best.path.clone(), off }) } fn obj_size(o: &InlineObject) -> (f32, f32) { @@ -1103,7 +1116,7 @@ fn object_ref(s: &Session, v: &Value) -> Result { Some(n) => all_objects(s).into_iter().nth(usize::try_from(n).unwrap_or(usize::MAX)).map(|(p, _)| p), None => super::parse_pos(v), }; - pos.filter(|p| p.story == StoryRef::Body && s.doc.para_at(p).and_then(|q| q.object_at(p.off)).is_some_and(InlineObject::is_drawing)) + pos.filter(|p| movable_story(s, p.story) && s.doc.para_at(p).and_then(|q| q.object_at(p.off)).is_some_and(InlineObject::is_drawing)) .ok_or_else(|| CmdError::Params("no picture or shape there".into())) } @@ -1155,9 +1168,13 @@ fn group(s: &mut Session, _: &Value) -> CmdResult { picks.sort(); let layout = s.layout(); let mut found = Vec::new(); + let story = picks.first().cloned().map(|p| p.story).ok_or_else(|| CmdError::Failed("nothing to group".into()))?; + if picks.iter().any(|p| p.story != story) { + return Err(CmdError::Disabled("objects in different stories (body, header, footer) can't be grouped".into())); + } for pos in &picks { - if pos.story != StoryRef::Body || pos.path.depth() > 0 { - return Err(CmdError::Disabled("only objects in the body text (not in tables) can be grouped".into())); + if pos.path.depth() > 0 || !movable_story(s, pos.story) { + return Err(CmdError::Disabled("only objects in the body text, headers and footers (not in tables) can be grouped".into())); } let obj = s.doc.para_at(pos).and_then(|p| p.object_at(pos.off)).cloned().ok_or_else(|| CmdError::Failed("object vanished".into()))?; if matches!(obj, InlineObject::Graphic { .. }) { @@ -1241,8 +1258,8 @@ fn ungroup(s: &mut Session, _: &Value) -> CmdResult { let InlineObject::Group { w, h, float, .. } = &obj else { return Err(CmdError::Disabled("select a group first".into())) }; // Its offsets, explicit: the members are placed from the same anchor. let pos = if float.wrap == Wrap::Inline { - if pos.story != StoryRef::Body || pos.path.depth() > 0 { - return Err(CmdError::Disabled("only groups in the body text can be ungrouped in line with text".into())); + if pos.path.depth() > 0 || !movable_story(s, pos.story) { + return Err(CmdError::Disabled("only groups in the body text, headers and footers can be ungrouped in line with text".into())); } let hit = s.layout().object(&pos, s.page_hint).ok_or_else(|| CmdError::Failed("the object isn't laid out".into()))?; move_object(s, pos, Some(hit.page as u64), hit.rect.x, hit.rect.y, (*w, *h))? @@ -1698,6 +1715,34 @@ mod tests { assert_eq!(crop, [0.1, 0.2, 0.05, 0.0]); } + /// A header picture resizes and drags like a body one: the move stays in the header story + /// and lands where dropped; the header hit test finds it. + #[test] + fn header_pictures_can_be_resized_and_dragged() { + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("insert.editHeader", &json!({})).unwrap(); + let story = s.sel.focus.story; + assert!(matches!(story, StoryRef::Part(_)), "editing the header, got {story:?}"); + let data = super::super::insert::base64_encode(&png(20, 10)); + s.run("insert.picture", &json!({"data": data, "width": 100})).unwrap(); + let (pos, _) = selected(&s).expect("a new header picture is selected"); + assert_eq!(pos.story, story); + s.run("arrange.bounds", &json!({"width": 160, "height": 90})).unwrap(); + let r = s.run("arrange.bounds", &json!({"page": 0, "x": 200, "y": 60})).unwrap(); + assert_eq!(r["object"]["float"]["wrap"], "square"); + let o = s.layout().object(&selected(&s).unwrap().0, 0).expect("laid out"); + assert_eq!(o.story, story); + assert!((o.rect.x - 200.0).abs() < 1.0 && (o.rect.y - 60.0).abs() < 1.0, "{:?}", o.rect); + let hit = s.layout().object_at_in(o.page, o.rect.x + 5.0, o.rect.y + 5.0, 4.0, story).expect("header hit"); + assert_eq!(hit.pos(), o.pos()); + s.run("arrange.nudge", &json!({"dx": 6, "dy": 4})).unwrap(); + let n = s.layout().object(&selected(&s).unwrap().0, 0).expect("laid out"); + assert!((n.rect.x - 206.0).abs() < 1.0 && (n.rect.y - 64.0).abs() < 1.0, "{:?}", n.rect); + s.run("edit.undo", &json!({})).unwrap(); + let u = s.layout().object(&selected(&s).unwrap().0, 0).expect("laid out"); + assert!((u.rect.x - 200.0).abs() < 1.0 && (u.rect.y - 60.0).abs() < 1.0, "{:?}", u.rect); + } + /// Shape Format › Text (#369): direction and alignment on the box the caret is in, then /// Create Link to an empty box and Break Link, each one undo step. #[test] diff --git a/crates/layout/src/hit.rs b/crates/layout/src/hit.rs index b78aff7..00f2fe7 100644 --- a/crates/layout/src/hit.rs +++ b/crates/layout/src/hit.rs @@ -137,6 +137,25 @@ fn items_of(page: &Page, story: StoryRef) -> Box + } } +/// Whether header/footer part `id` has text near (x, y) on the page (its header or footer +/// lines). Used so an object behind the header text is only grabbed where there's no text, +/// mirroring `text_at` for the body. +fn hf_text_at(p: &Page, id: u32, x: f32, y: f32) -> bool { + let story = StoryRef::Part(id); + p.header + .iter() + .chain(p.footer.iter()) + .filter_map(|it| match it { + Placed::Lines { story: s, para, l0, l1, x: lx, y: ly, .. } if *s == story => Some((para, l0, l1, lx, ly)), + _ => None, + }) + .any(|(para, l0, l1, lx, ly)| { + let (Some(first), Some(last)) = (para.lines.get(*l0), para.lines.get(l1.checked_sub(1).unwrap_or(0))) else { return false }; + let bottom = ly + last.top + last.height - first.top; + y >= *ly - 2.0 && y <= bottom + 2.0 && x >= *lx - 40.0 && x <= lx + last.right + 40.0 + }) +} + /// Every upright line on a page that belongs to `story` (body, or a header/footer part). /// Turned lines (see [`turned_hit`]) are left out. pub fn page_lines(page: &Page, story: StoryRef) -> Vec> { @@ -237,15 +256,32 @@ impl DocLayout { /// border (within `edge` points either side; inside is its text). Topmost first; objects /// behind the text only where there's no text. pub fn object_at(&self, page: usize, x: f32, y: f32, edge: f32) -> Option { + self.object_at_in(page, x, y, edge, StoryRef::Body) + } + + /// The object of `story` a press at (x, y) grabs. Body looks only at the page's own items + /// (as before); a header/footer part looks only at that part's objects in the page's + /// header and footer, so a header picture placed over the body never steals body clicks. + pub fn object_at_in(&self, page: usize, x: f32, y: f32, edge: f32, story: StoryRef) -> Option { let p = self.pages.get(page)?; // A rotated object is grabbed on its rotated shape: the point is taken into its frame. let grabs = |o: &ObjectHit| { let pt = o.unspin(x, y); o.rect.expand(edge).contains(pt) && (o.text_box.is_none() || !o.rect.expand(-edge).contains(pt) || o.rect.w.min(o.rect.h) <= edge * 3.0) }; - objects(p, page) + let cands: Vec = match story { + StoryRef::Body => objects(p, page).collect(), + StoryRef::Part(_) => all_objects(p, page).filter(|o| o.story == story).collect(), + }; + let text_here = match story { + StoryRef::Body => text_at(p, page, x, y).is_some(), + StoryRef::Part(id) => hf_text_at(p, id, x, y), + }; + cands + .iter() .find(|o| !o.behind() && grabs(o)) - .or_else(|| if text_at(p, page, x, y).is_some() { None } else { objects(p, page).find(|o| o.behind() && grabs(o)) }) + .or_else(|| if text_here { None } else { cands.iter().find(|o| o.behind() && grabs(o)) }) + .cloned() } /// The text box in the header or footer at (x, y) on `page`. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index dd3e306..3bbc132 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1567,7 +1567,7 @@ fn part_kind(app: &WordApp, s: StoryRef) -> Option { } /// The story a text box's story sits in (where the box is), when the caret is in a text box. -fn text_box_host(app: &WordApp, layout: &DocLayout) -> Option { +pub(crate) fn text_box_host(app: &WordApp, layout: &DocLayout) -> Option { match app.session.sel.focus.story { StoryRef::Part(id) if in_text_box(app) => layout.text_box(id, app.session.page_hint).map(|o| o.story), _ => None, diff --git a/crates/ui-egui/src/objects.rs b/crates/ui-egui/src/objects.rs index 14c8111..044784b 100644 --- a/crates/ui-egui/src/objects.rs +++ b/crates/ui-egui/src/objects.rs @@ -72,9 +72,31 @@ fn connector_end_at(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f3 } } -/// The selected object: the selection is exactly one picture, shape or text box in the body. +/// The selected object: the selection is exactly one picture, shape or text box, in the body +/// or in a header/footer. pub fn selected(app: &WordApp) -> Option<(Pos, &InlineObject)> { - wordcraft_engine::cmd::objects::object_selection(&app.session).filter(|(p, _)| p.story == StoryRef::Body) + wordcraft_engine::cmd::objects::object_selection(&app.session).filter(|(p, _)| selectable_story(app, p.story)) +} + +/// Whether objects of `story` can be selected and dragged on the canvas: the body and +/// headers/footers. +fn selectable_story(app: &WordApp, story: StoryRef) -> bool { + match story { + StoryRef::Body => true, + StoryRef::Part(id) => app.session.doc.parts.get(&id).is_some_and(|p| matches!(p.kind, PartKind::Header | PartKind::Footer)), + } +} + +/// The story whose objects the canvas handles: the header/footer being edited, else the body. +fn active_story(app: &WordApp, layout: &DocLayout) -> StoryRef { + let story = crate::canvas::text_box_host(app, layout).unwrap_or(app.session.sel.focus.story); + match story { + StoryRef::Body => StoryRef::Body, + StoryRef::Part(id) if selectable_story(app, StoryRef::Part(id)) => StoryRef::Part(id), + // Notes and text-box text: no top-level objects live here, so grabs find nothing and + // the press falls through to text handling. + other => other, + } } /// Whether the object under `hit` keeps its place and size: charts and diagrams can be selected @@ -151,7 +173,7 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect] return true; } let multi = resp.double_clicked() || resp.triple_clicked(); - if !(pressed || multi) || crate::canvas::editing_header_footer(app, layout) { + if !(pressed || multi) { return false; } // A selected connector's end: drag it to glue it. @@ -176,7 +198,7 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect] None => return true, }; // Objects in table cells can be selected and resized, not moved; charts neither. - if !fixed(app, &object) && (grab != Grab::Move || movable(&object)) { + if !fixed(app, &object) && (grab != Grab::Move || movable(app, &object)) { app.canvas.obj_drag = Some(ObjectDrag { drag: Drag::new(grab, object.page, object.rect, object.spin.deg, at), object, min, picture, end: None }); } @@ -221,9 +243,11 @@ pub fn select_at(app: &mut WordApp, layout: &DocLayout, pages: &[Rect], scale: f true } -/// What a press at `at` would grab: a handle of the shown frame, else an object. +/// What a press at `at` would grab: a handle of the shown frame, else an object of the story +/// being edited (body, or the header/footer). fn grab_at(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at: Pos2) -> Option<(ObjectHit, Grab)> { - let handle = active(app, layout).filter(|(o, _)| o.story == StoryRef::Body && !fixed(app, o)).and_then(|(o, _)| { + let story = active_story(app, layout); + let handle = active(app, layout).filter(|(o, _)| o.story == story && !fixed(app, o)).and_then(|(o, _)| { let f = screen(pages, scale, o.page, o.rect)?; if frame::rotate_handle_at(f, o.spin.deg, at) { return Some((o, Grab::Rotate)); @@ -232,14 +256,16 @@ fn grab_at(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at: Po }); handle.or_else(|| { let (page, x, y) = crate::canvas::page_at(pages, layout, scale, at)?; - // Selectable: body objects (table cells too); notes' objects aren't yet. - layout.object_at(page, x, y, EDGE / scale.max(0.01)).filter(|o| o.story == StoryRef::Body).map(|o| (o, Grab::Move)) + // Selectable: body objects (table cells too) or the header/footer's own objects; + // notes' objects aren't yet. + layout.object_at_in(page, x, y, EDGE / scale.max(0.01), story).map(|o| (o, Grab::Move)) }) } -/// Objects that can be dragged to a new place: those in the body's own paragraphs. -fn movable(o: &ObjectHit) -> bool { - o.story == StoryRef::Body && o.path.depth() == 0 +/// Objects that can be dragged to a new place: those in the own paragraphs of the body or of a +/// header/footer. +fn movable(app: &WordApp, o: &ObjectHit) -> bool { + o.path.depth() == 0 && selectable_story(app, o.story) } /// Commit a finished drag as one command. @@ -255,7 +281,9 @@ fn drop(app: &mut WordApp, d: &ObjectDrag) { } Grab::Move => json!({"page": d.drag.to_page, "x": r.x, "y": r.y}), // An inline object stays in the text; a floating one keeps its far edges put. - Grab::Resize(_) if d.object.floating() && movable(&d.object) => json!({"width": r.w, "height": r.h, "page": d.drag.page, "x": r.x, "y": r.y}), + Grab::Resize(_) if d.object.floating() && movable(app, &d.object) => { + json!({"width": r.w, "height": r.h, "page": d.drag.page, "x": r.x, "y": r.y}) + } Grab::Resize(_) => json!({"width": r.w, "height": r.h}), }; let _ = app.run("arrange.bounds", params); @@ -317,9 +345,9 @@ pub fn paint(app: &WordApp, painter: &Painter, t: &Tokens, layout: &DocLayout, p if let Some((o, editing)) = active(app, layout) && let Some(f) = screen(pages, scale, o.page, o.rect) { - // Handles where it can be resized and turned (body objects, not charts): else a plain - // frame. - let handles = o.story == StoryRef::Body && !fixed(app, &o); + // Handles where it can be resized and turned (body and header/footer objects, + // not charts): else a plain frame. + let handles = selectable_story(app, o.story) && !fixed(app, &o); frame::paint(painter, t, f, o.spin.deg, editing, handles, handles); } } @@ -369,9 +397,6 @@ pub fn cursor(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at: if let Some(d) = &app.canvas.obj_drag { return Some(if d.end.is_some() { egui::CursorIcon::Crosshair } else { d.drag.grab.cursor() }); } - if crate::canvas::editing_header_footer(app, layout) { - return None; - } if connector_end_at(app, layout, pages, scale, at).is_some() { return Some(egui::CursorIcon::Crosshair); } From 29ee256c8698b59e8d82fb0efc68aff31f29c3ea Mon Sep 17 00:00:00 2001 From: alexanderalber <90604107+alexanderalber@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:30:57 +0200 Subject: [PATCH 08/26] Paragraph dialog: OK keeps Exactly and At Least line spacing (#467) (#564) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dialog shows line spacing as a multiple, so Exactly and At Least read as 1.0, and OK (and Tabs…) always sent that back as Multiple 1.0. Accepting the dialog unchanged, or changing only Keep with next, turned a fixed rule into single spacing. The dialog now remembers the value it showed and sends line spacing only when it changed, as it already does for Asian Typography. A changed value still sets a multiple. generated with Claude Code Co-authored-by: Brandon Thomas --- crates/ui-egui/src/dialogs.rs | 108 +++++++++++++++++++++++++++++++--- 1 file changed, 101 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7a76d26..0976439 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -159,7 +159,12 @@ pub enum Dialog { first: f32, before: f32, after: f32, + /// Line spacing as a multiple; Exactly and At Least show as 1.0. line: f32, + /// `line` when the dialog opened: OK keeps the paragraph's spacing, whatever its rule, + /// unless this changed (#467). + #[serde(skip)] + line_was: f32, keep_next: bool, keep_lines: bool, page_break: bool, @@ -969,6 +974,7 @@ impl Dialog { before: rp.space_before, after: rp.space_after, line, + line_was: line, keep_next: rp.keep_next, keep_lines: rp.keep_lines, page_break: rp.page_break_before, @@ -1503,6 +1509,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { before, after, line, + line_was, keep_next, keep_lines, page_break, @@ -1620,7 +1627,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { *first, *before, *after, - *line, + (*line, *line_was), [*keep_next, *keep_lines, *page_break, *widow], (*opts, *opts_was), ); @@ -2762,11 +2769,12 @@ fn apply_paragraph( first: f32, before: f32, after: f32, - line: f32, + line: (f32, f32), flags: [bool; 4], opts: ([bool; 4], [bool; 4]), ) { let [keep_next, keep_lines, page_break, widow] = flags; + let (line, line_was) = line; // Direction first: the alignment and indents are as seen on the page, and their logical // values depend on it. let _ = app.run(if rtl { "para.rtl" } else { "para.ltr" }, json!({})); @@ -2774,9 +2782,15 @@ fn apply_paragraph( let (start, end) = if rtl { (right, left) } else { (left, right) }; let mut props = json!({ "indentLeft": start * crate::options::unit().pt_per_unit(), "indentRight": end * crate::options::unit().pt_per_unit(), "indentFirst": first * crate::options::unit().pt_per_unit(), - "spaceBefore": before, "spaceAfter": after, "lineSpacing": {"rule": "multiple", "value": line}, + "spaceBefore": before, "spaceAfter": after, "keepNext": keep_next, "keepLines": keep_lines, "pageBreakBefore": page_break, "widowControl": widow, }); + // The dialog edits only multiples: an untouched value keeps Exactly and At Least. + if line != line_was + && let Some(m) = props.as_object_mut() + { + m.insert("lineSpacing".into(), json!({"rule": "multiple", "value": line})); + } let (now, was) = opts; for (key, (on, was)) in ["mirrorIndents", "contextualSpacing", "suppressLineNumbers", "suppressHyphens"].into_iter().zip(now.into_iter().zip(was)) { @@ -3135,14 +3149,40 @@ mod tests { /// Open the Paragraph dialog, change its options (`opts`) with `edit` and press OK. fn ok_with(app: &mut WordApp, edit: impl FnOnce(&mut [bool; 4])) { let Some(Dialog::Paragraph { - rtl, align, left, right, first, before, after, line, keep_next, keep_lines, page_break, widow, mut opts, .. + rtl, + align, + left, + right, + first, + before, + after, + line, + line_was, + keep_next, + keep_lines, + page_break, + widow, + mut opts, + .. }) = Dialog::open("paragraph", app) else { panic!("no paragraph dialog") }; let was = opts; edit(&mut opts); - apply_paragraph(app, rtl, &align, left, right, first, before, after, line, [keep_next, keep_lines, page_break, widow], (opts, was)); + apply_paragraph( + app, + rtl, + &align, + left, + right, + first, + before, + after, + (line, line_was), + [keep_next, keep_lines, page_break, widow], + (opts, was), + ); } #[test] @@ -3191,6 +3231,60 @@ mod tests { } } + #[test] + fn paragraph_dialog_keeps_fixed_line_spacing() { + use wordcraft_doc::props::LineSpacing; + for (cmd, rule) in [(json!({"exactly": 24}), LineSpacing::Exactly(24.0)), (json!({"atLeast": 24}), LineSpacing::AtLeast(24.0))] { + // OK without changes, and OK after changing only Keep with next (#467). + let mut app = app_with("Example"); + let _ = app.run("para.lineSpacing", cmd.clone()); + ok_unchanged(&mut app); + assert_eq!(props(&app).line_spacing, Some(rule), "unchanged OK, {cmd}"); + let Some(Dialog::Paragraph { rtl, align, left, right, first, before, after, line, line_was, keep_lines, page_break, widow, .. }) = + Dialog::open("paragraph", &mut app) + else { + panic!("no paragraph dialog") + }; + apply_paragraph( + &mut app, + rtl, + &align, + left, + right, + first, + before, + after, + (line, line_was), + [true, keep_lines, page_break, widow], + ([false; 4], [false; 4]), + ); + let p = props(&app); + assert_eq!((p.line_spacing, p.keep_next), (Some(rule), Some(true)), "Keep with next, {cmd}"); + } + // A changed value still sets a multiple. + let mut app = app_with("Example"); + let _ = app.run("para.lineSpacing", json!({"exactly": 24})); + let Some(Dialog::Paragraph { rtl, align, left, right, first, before, after, line_was, keep_next, keep_lines, page_break, widow, .. }) = + Dialog::open("paragraph", &mut app) + else { + panic!("no paragraph dialog") + }; + apply_paragraph( + &mut app, + rtl, + &align, + left, + right, + first, + before, + after, + (1.5, line_was), + [keep_next, keep_lines, page_break, widow], + ([false; 4], [false; 4]), + ); + assert_eq!(props(&app).line_spacing, Some(LineSpacing::Multiple(1.5))); + } + #[test] fn font_dialog_sets_the_asian_and_latin_fonts_separately() { // #505: the Asian text font box and the Latin text font box each set their own font, and @@ -3223,7 +3317,7 @@ mod tests { #[test] fn paragraph_dialog_changes_direction() { let mut app = app_with("Hello"); - let Some(Dialog::Paragraph { align, left, right, first, before, after, line, keep_next, keep_lines, page_break, widow, .. }) = + let Some(Dialog::Paragraph { align, left, right, first, before, after, line, line_was, keep_next, keep_lines, page_break, widow, .. }) = Dialog::open("paragraph", &mut app) else { panic!() @@ -3238,7 +3332,7 @@ mod tests { first, before, after, - line, + (line, line_was), [keep_next, keep_lines, page_break, widow], ([false; 4], [false; 4]), ); From a37253109e230f5221c86d52d2160d84bd87b9c7 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Mon, 12 Oct 2026 04:33:33 +0200 Subject: [PATCH 09/26] #665: rustdoc names private items as code, not links (#683) Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/dialogs.rs | 2 +- crates/ui-egui/src/i18n/mod.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 0976439..0044f60 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -288,7 +288,7 @@ pub enum Dialog { name: String, /// Style id. based_on: String, - /// Index into `regions` (see [`REGION_LABELS`]). + /// Index into `regions` (see `REGION_LABELS`). region: usize, regions: Box<[TableRegion; 7]>, /// What the regions showed when the dialog opened (or the base style changed): only diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 7d806bd..7a466fe 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -239,7 +239,7 @@ pub fn t(s: &str) -> &str { tr(current(), s) } -/// `s` in the current language as worded for `context` (one of [`catalog::CONTEXTS`]), falling +/// `s` in the current language as worded for `context` (one of `catalog::CONTEXTS`), falling /// back to the plain translation and then to English. pub fn tc<'a>(context: &str, s: &'a str) -> &'a str { trc(current(), context, s) From 4fffa8100373fc42718e764b2e04918f312e1a62 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Mon, 12 Oct 2026 04:39:47 +0200 Subject: [PATCH 10/26] #664: brand licence names WordCraft, not DesignCraft (#682) Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- docs/brand/LICENSE-brand.txt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/brand/LICENSE-brand.txt b/docs/brand/LICENSE-brand.txt index 6319613..3611943 100644 --- a/docs/brand/LICENSE-brand.txt +++ b/docs/brand/LICENSE-brand.txt @@ -2,7 +2,7 @@ ArtCraft brand assets: license The ArtCraft name, wordmark and mark (the "ArtCraft Marks"), including every file in this directory in every color, size and format, are owned by the ArtCraft Team. They are trademarks -and are NOT open source. They are not covered by DesignCraft's MIT OR Apache-2.0 license, and nothing in +and are NOT open source. They are not covered by WordCraft's MIT OR Apache-2.0 license, and nothing in that license, in NOTICE or in any other file of this repository grants any right to them. Permission @@ -11,7 +11,7 @@ The ArtCraft Team grants a limited, non-exclusive, non-transferable, revocable, permission to use the ArtCraft Marks only: 1. as part of copies of this repository; and - 2. within DesignCraft itself, its documentation and its official builds, to identify DesignCraft as made + 2. within WordCraft itself, its documentation and its official builds, to identify WordCraft as made by the ArtCraft team and community and to link to getartcraft.com and its community. Conditions @@ -21,17 +21,17 @@ The permission holds only while every one of these is true: - The marks are used exactly as supplied in this directory. Do not redraw, recolor, crop, stretch, rotate, animate, outline or add effects to them, extract parts of them, or combine them with any other name, mark or artwork. - - They are used only in the context of DesignCraft, and only to say that the ArtCraft team makes it. + - They are used only in the context of WordCraft, and only to say that the ArtCraft team makes it. They must never suggest that the ArtCraft Team made, sponsors, endorses, certifies or supports any other product, service, fork, modified version, plug-in, organization or person. - Neither the marks nor the ArtCraft name appear in the name, logo, icon, domain, social media - handle, app-store listing, advertising or marketing of anything other than DesignCraft. + handle, app-store listing, advertising or marketing of anything other than WordCraft. Forks and modified versions -If you distribute or publish a modified version of DesignCraft, or any work derived from it, you must +If you distribute or publish a modified version of WordCraft, or any work derived from it, you must remove the ArtCraft Marks from it (or replace them with your own) and must not call it ArtCraft or -present it as an ArtCraft product. You may say, in plain text, that your work is based on DesignCraft by +present it as an ArtCraft product. You may say, in plain text, that your work is based on WordCraft by the ArtCraft team. A fork kept only to propose changes back to this repository may keep the marks unchanged. From b75bd3c56a15eda4cc396e664928e0e2ad4cb1e5 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Mon, 12 Oct 2026 04:42:05 +0200 Subject: [PATCH 11/26] Fonts: Palatino Linotype and Book Antiqua prefer Palatino look-alikes (#670) (#677) Co-authored-by: Claude Sonnet 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 1 + crates/fonts/src/word.rs | 34 ++++++++++++++++++++++++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d9e5430..d7c31a5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -130,6 +130,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log | Date | What landed | +| 2026-10-11 | Fonts: Palatino Linotype and Book Antiqua fall back to Palatino (macOS) or TeX Gyre Pagella / P052 / URW Palladio (Linux) before generic serif (#670) | |---|---| | 2026-10-11 | Word XML files (#548): Word XML Document (Flat OPC `.xml`) opens and saves through the DOCX reader and writer; Word 2003 XML opens (styles, lists, tables, sections, notes, comments, pictures, hyperlinks) | | 2026-10-11 | Font fallback indexes which characters each installed font maps, reading each font file once (#605): text in a script no installed font covers no longer re-reads every system font per distinct character (200 such characters: 13 s → 0.2 s) | diff --git a/crates/fonts/src/word.rs b/crates/fonts/src/word.rs index b4461c0..5cb79f5 100644 --- a/crates/fonts/src/word.rs +++ b/crates/fonts/src/word.rs @@ -36,6 +36,24 @@ fn substitutes(family: &str) -> &'static [&'static str] { // headings and screens. Open fonts with full Persian coverage first. const NASKH: &[&str] = &["Noto Naskh Arabic", "XB Zar", "XB Niloofar", "Amiri", "Vazirmatn", "Noto Sans Arabic", "DejaVu Sans", "Source Serif 4"]; const ARABIC_SANS: &[&str] = &["Vazirmatn", "Sahel", "Shabnam", "Samim", "Noto Sans Arabic", "Noto Naskh Arabic", "DejaVu Sans", "Source Sans 3"]; + // Palatino Linotype / Book Antiqua: macOS ships the same design as "Palatino"; open + // look-alikes with matching metrics (TeX Gyre Pagella, URW P052 / Palladio) on Linux. + const PALATINO: &[&str] = &[ + "Palatino Linotype", + "Palatino", + "Book Antiqua", + "TeX Gyre Pagella", + "P052", + "URW Palladio L", + "Palladio", + "Caladea", + "Times New Roman", + "Liberation Serif", + "Georgia", + "Noto Serif", + "DejaVu Serif", + "Source Serif 4", + ]; let f = family.to_ascii_lowercase(); if is_persian_naskh(&f) { return NASKH; @@ -44,6 +62,7 @@ fn substitutes(family: &str) -> &'static [&'static str] { return ARABIC_SANS; } match f.as_str() { + "palatino linotype" | "book antiqua" | "palatino" => PALATINO, "arial" | "helvetica" | "arial nova" => ARIAL, "times new roman" | "times" => TIMES, "courier new" | "courier" | "consolas" | "lucida console" | "cascadia code" | "cascadia mono" => MONO, @@ -52,8 +71,6 @@ fn substitutes(family: &str) -> &'static [&'static str] { | "cambria math" | "georgia" | "garamond" - | "book antiqua" - | "palatino linotype" | "constantia" | "baskerville old face" | "century" @@ -250,6 +267,19 @@ mod tests { assert!(a > 0.0 && d >= 0.0); } + #[test] + fn palatino_family_prefers_palatino_look_alikes_over_generic_serif() { + for name in ["Palatino Linotype", "Book Antiqua", "palatino"] { + let list = substitutes(name); + let pos = |n: &str| list.iter().position(|x| *x == n); + assert!(pos("Palatino").is_some() && pos("TeX Gyre Pagella").is_some() && pos("P052").is_some(), "{name}"); + // All of them come before the generic serif fallbacks (Caladea, Times New Roman). + assert!(pos("Palatino") < pos("Caladea") && pos("TeX Gyre Pagella") < pos("Times New Roman"), "{name}"); + } + // Other serif families are unchanged. + assert_eq!(substitutes("Georgia").first(), Some(&"Caladea")); + } + #[test] fn symbol_font_chars_map_to_unicode() { assert_eq!(symbol_font_char("Symbol", '\u{F0B7}'), Some('\u{2022}')); From 0eea21c2c8c587572ea2900c99683f7253a7ad38 Mon Sep 17 00:00:00 2001 From: Rees <109076338+OPUZZLES@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:47:03 +0200 Subject: [PATCH 12/26] M1: preserve omitted DOCX paragraph defaults (#647) Co-authored-by: Brandon Thomas --- ROADMAP.md | 2 ++ crates/doc/src/styles.rs | 18 +++++++++++++- crates/docx/src/read/mod.rs | 1 + crates/docx/src/write/mod.rs | 12 +++++---- crates/docx/tests/fixtures.rs | 46 +++++++++++++++++++++++++++++++++++ docs/file-format-parity.md | 22 +++++++++++++++++ docs/gaps.md | 3 ++- docs/roadmap.md | 4 ++- 8 files changed, 100 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d7c31a5..700ccd9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -167,6 +167,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-11 | Turkish (`tr`) interface: all 1,307 catalog entries, `tr-TR`/`tr-CY` system locales and a saved language choice; command search keeps Turkish dotted and dotless I apart (`italik` finds İtalik); locale, catalog, search and bundled-font tests; no Turkish proofing resources | | 2026-10-11 | Citations and bibliography in all 12 of Word's styles: Harvard, ISO 690 (author-date and numerical), Turabian, GB/T 7714, GOST (name and title sort) and SIST02 join APA, MLA, Chicago and IEEE; numbered styles count in order of first citation (#383) | | 2026-10-11 | Interface size setting (#475): File › Options › General scales the interface 80–200 % on top of the system display scale, kept between runs; `ui.interfaceSize` for agents | +| 2026-10-11 | DOCX paragraph defaults: preserve omission separately from explicit empty defaults, with repeated-save and default-edit regressions | | 2026-10-11 | Formatting revisions (#41): `w:rPrChange`/`w:pPrChange`, table/row/cell, section and numbering changes kept in DOCX; formatting recorded as revisions while tracking; accept/reject, Reviewing Pane descriptions, "Formatted: …" balloons and change bars | | 2026-10-11 | Draw: Lasso Select (move, recolour, delete the strokes inside a loop), Add Pen gallery kept between runs, Ink to Shape (own recogniser: line, rectangle/square, ellipse/circle, triangle, polygon), Ink Replay in drawing order with play/pause/rewind/forward; drawing order kept in DOCX (#395); catalog 406/434 (93.5%) | | 2026-10-10 | Estonian (`et`) interface (#294): 1,161 translated entries, locale and saved-preference tests, bundled-font checks; no Estonian proofing resources | @@ -202,6 +203,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-11 | trivial | Insert › Icons landed (#309): catalog 402/434 → 403/434 (92.9%) | | 2026-10-11 | trivial | Enclose Characters (#297): catalog 403/434 (92.9%) | | 2026-10-11 | trivial | Progress log: Customize Ribbon and the Quick Access Toolbar (#379) | +| 2026-10-11 | trivial | Omitted `w:pPrDefault` stays omitted across saves; explicit empty/default properties and later spacing edits remain writable. Synthetic preservation checks only; readiness estimates unchanged | | 2026-10-11 | trivial | Progress log: Customize Keyboard (#368) | | 2026-10-11 | trivial | Word's common keyboard shortcuts (#330): 125 of ~250 | | 2026-10-11 | trivial | Collapsed duplicate rows left by parallel merges | diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index 2bfbefc..0053c03 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -138,6 +138,9 @@ pub struct StyleSheet { /// Document defaults (`w:docDefaults`). pub default_chr: CharProps, pub default_para: ParaProps, + /// Preserve an explicit `w:pPrDefault`, even when empty. Omission leaves defaults to + /// the application; nonempty `default_para` is always written after spacing edits. + pub default_para_present: bool, pub styles: Vec