From 737bab404f6b5e772dcff8adea3f380b8d701f61 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Thu, 5 Nov 2026 04:39:30 -0600 Subject: [PATCH] Preserve text frame anchor locks (#5382) * Preserve text frame anchor locks * Update lockfile for DOCX test dependency * Assert frame geometry survives document round trips * Preserve text frame locks on paragraph updates * Remove duplicate DOCX test dependency --- crates/doc/src/props/para_props.rs | 3 +++ crates/docx/src/read/props.rs | 1 + crates/docx/src/write/props.rs | 3 +++ crates/docx/tests/it/fixtures.rs | 40 ++++++++++++++++++++++++++++ crates/docx/tests/it/roundtrip.rs | 1 + crates/engine/src/cmd/para/format.rs | 8 ++++++ crates/engine/src/tests.rs | 16 +++++++++++ docs/roadmap.md | 1 + 8 files changed, 73 insertions(+) diff --git a/crates/doc/src/props/para_props.rs b/crates/doc/src/props/para_props.rs index ff3bea7181..c34d527192 100644 --- a/crates/doc/src/props/para_props.rs +++ b/crates/doc/src/props/para_props.rs @@ -305,6 +305,9 @@ pub struct TextFrame { pub v_anchor: String, /// `around`, `auto`, `notBeside`, `none`, `tight` or `through`. pub wrap: String, + /// Whether the frame stays fixed to its page anchor when its paragraph moves. + #[serde(skip_serializing_if = "Option::is_none")] + pub anchor_lock: Option, /// Distance from the text left and right, and above and below. pub h_space: f32, pub v_space: f32, diff --git a/crates/docx/src/read/props.rs b/crates/docx/src/read/props.rs index 2dd401e18b..4709916b7d 100644 --- a/crates/docx/src/read/props.rs +++ b/crates/docx/src/read/props.rs @@ -33,6 +33,7 @@ fn text_frame(k: &El) -> wordcraft_doc::props::TextFrame { h_anchor: word("w:hAnchor", &["text", "margin", "page"], "page"), v_anchor: word("w:vAnchor", &["text", "margin", "page"], "page"), wrap: word("w:wrap", &["around", "auto", "notBeside", "none", "tight", "through"], "auto"), + anchor_lock: k.attr("w:anchorLock").map(|v| !matches!(v.trim(), "0" | "false" | "off" | "False" | "FALSE")), h_space: len("w:hSpace").unwrap_or(0.0).max(0.0), v_space: len("w:vSpace").unwrap_or(0.0).max(0.0), } diff --git a/crates/docx/src/write/props.rs b/crates/docx/src/write/props.rs index f78a2878cd..3968377df4 100644 --- a/crates/docx/src/write/props.rs +++ b/crates/docx/src/write/props.rs @@ -326,6 +326,9 @@ fn write_frame(w: &mut W, p: &ParaProps, framed: bool) { a.push(("w:vSpace", &vs)); } a.push(("w:wrap", &f.wrap)); + if let Some(anchor_lock) = f.anchor_lock { + a.push(("w:anchorLock", if anchor_lock { "1" } else { "0" })); + } a.push(("w:vAnchor", &f.v_anchor)); a.push(("w:hAnchor", &f.h_anchor)); match &f.x_align { diff --git a/crates/docx/tests/it/fixtures.rs b/crates/docx/tests/it/fixtures.rs index fa119db4a7..1df2e96f9a 100644 --- a/crates/docx/tests/it/fixtures.rs +++ b/crates/docx/tests/it/fixtures.rs @@ -247,6 +247,46 @@ fn frame_zero_side_is_automatic() { } } +#[test] +fn locked_frame_anchor_survives_two_saves() { + for (lock, expected) in [("", None), (" w:anchorLock=\"1\"", Some(true)), (" w:anchorLock=\"0\"", Some(false))] { + let mut document = read_body(&format!(r#"Locked"#)); + let paragraph = paras(&document)[0]; + assert_eq!(paragraph.props.frame.as_ref().and_then(|f| f.anchor_lock), expected); + if expected == Some(true) { + let Some(wordcraft_doc::Block::Para(paragraph)) = document.body.get_mut(0).map(std::sync::Arc::make_mut) else { + panic!("first block is the framed paragraph"); + }; + let frame = paragraph.props.frame.as_mut().unwrap(); + frame.x = 36.0; + frame.y = 72.0; + frame.w = Some(108.0); + } + let json = serde_json::to_vec(&document).unwrap(); + document = serde_json::from_slice(&json).unwrap(); + if expected == Some(true) { + let frame = paras(&document)[0].props.frame.as_ref().unwrap(); + assert_eq!((frame.x, frame.y, frame.w), (36.0, 72.0, Some(108.0))); + } + for pass in 0..2 { + let bytes = wordcraft_docx::write(&document).unwrap(); + let xml = text(&unzip(&bytes), "word/document.xml"); + let expected_xml = match expected { + Some(true) => "w:anchorLock=\"1\"", + Some(false) => "w:anchorLock=\"0\"", + None => "w:anchorLock=", + }; + assert_eq!(xml.contains(expected_xml), expected.is_some(), "lock changed on save {}: {xml}", pass + 1); + document = wordcraft_docx::read(&bytes).unwrap(); + assert_eq!(paras(&document)[0].props.frame.as_ref().and_then(|f| f.anchor_lock), expected); + if expected == Some(true) { + let frame = paras(&document)[0].props.frame.as_ref().unwrap(); + assert_eq!((frame.x, frame.y, frame.w), (36.0, 72.0, Some(108.0))); + } + } + } +} + #[test] fn omitted_paragraph_defaults_can_be_edited() { let styles = diff --git a/crates/docx/tests/it/roundtrip.rs b/crates/docx/tests/it/roundtrip.rs index f427cdbd96..9738fd3724 100644 --- a/crates/docx/tests/it/roundtrip.rs +++ b/crates/docx/tests/it/roundtrip.rs @@ -210,6 +210,7 @@ fn every_para_prop_round_trips() { h_anchor: "margin".into(), v_anchor: "text".into(), wrap: "around".into(), + anchor_lock: Some(true), h_space: 9.0, v_space: 0.0, }), diff --git a/crates/engine/src/cmd/para/format.rs b/crates/engine/src/cmd/para/format.rs index 7d83e1f79b..bddf6bf73c 100644 --- a/crates/engine/src/cmd/para/format.rs +++ b/crates/engine/src/cmd/para/format.rs @@ -123,8 +123,16 @@ pub(super) fn set_props(s: &mut Session, v: &Value) -> CmdResult { for path in crate::cmd::selected_paths(s) { let Some(para) = s.doc.para(story, &path) else { continue }; let rtl = s.doc.styles.resolve_para(¶.props.clone().overlaid(&props)).bidi; + let anchor_lock = props + .frame + .as_ref() + .filter(|frame| frame.anchor_lock.is_none()) + .and_then(|_| para.props.frame.as_ref().and_then(|frame| frame.anchor_lock)); let para = s.doc.para_mut(story, &path)?; para.props.overlay(&props); + if let (Some(anchor_lock), Some(frame)) = (anchor_lock, para.props.frame.as_mut()) { + frame.anchor_lock = Some(anchor_lock); + } if let Some(a) = align { para.props.align = Some(a.visual(rtl)); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 348845e023..3111f1aaae 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6045,6 +6045,22 @@ fn props_colours_take_hex_strings() { } } +#[test] +fn para_set_frame_geometry_keeps_anchor_lock_unless_replaced() { + use wordcraft_doc::props::TextFrame; + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Framed text"})); + let paragraph = s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); + paragraph.props.frame = Some(TextFrame { anchor_lock: Some(true), ..TextFrame::default() }); + + run(&mut s, "para.set", json!({"props": {"frame": {"x": 36.0, "y": 72.0, "w": 108.0}}})); + let frame = s.doc.para_at(&Pos::body(0, 0)).unwrap().props.frame.as_ref().unwrap(); + assert_eq!((frame.x, frame.y, frame.w, frame.anchor_lock), (36.0, 72.0, Some(108.0), Some(true))); + + run(&mut s, "para.set", json!({"props": {"frame": {"x": 54.0, "anchorLock": false}}})); + assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.frame.as_ref().unwrap().anchor_lock, Some(false)); +} + /// #555: Update Heading 2 to Match Selection (the styles pane's context menu) makes Heading 2 /// look like the selected text — its paragraph style and direct formatting — so every Heading 2 /// paragraph changes; the selected paragraph takes Heading 2, and it is one undo step. diff --git a/docs/roadmap.md b/docs/roadmap.md index f53c06bae0..0cf0d34197 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -74,6 +74,7 @@ languages, screen-reader access, ecosystem decisions (VBA, add-ins, cloud), an i | Date | Change | Summary | |---|---|---| +| 2026-11-03 | trivial | DOCX text-frame anchor locks survive JSON and two save/reopen cycles, including position and size edits | | 2026-11-03 | minor | M15: Arabic mirrored interface (#1195); 28 UI languages from craft-i18n; Hebrew UI, native-speaker review and native IME acceptance (#155–#164) remain open | | 2026-10-29 | trivial | HTML import keeps preformatted lines with CR, CRLF or LF input newlines (#4810); numeric character references retain their separate meaning; readiness estimates unchanged. | | 2026-10-25 | trivial | egui 0.37 migration: 27 interface languages remain pinned by `craft-i18n.rev` (9205108…); input-method surrounding-delete and commit handling has synthetic regressions only, with managed CI passing; native acceptance (#155–#164) and native-speaker review remain open |