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
This commit is contained in:
Harrison Booth authored and GitHub committed 2026-11-05 11:39:30 +01:00
1 parent 9c5b54a1a1
commit 737bab404f
8 files changed
+73

No files matched your search

+3
View File
@@ -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<bool>,
/// Distance from the text left and right, and above and below.
pub h_space: f32,
pub v_space: f32,
+1
View File
@@ -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),
}
+3
View File
@@ -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 {
+40
View File
@@ -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#"<w:p><w:pPr><w:framePr w:w="1440" w:h="720"{lock}/></w:pPr><w:r><w:t>Locked</w:t></w:r></w:p>"#));
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 =
+1
View File
@@ -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,
}),
+8
View File
@@ -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(&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));
}
+16
View File
@@ -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.
+1
View File
@@ -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 |