Compare commits

...
Author SHA1 Message Date
Mex 060768f1ed Preserve header/footer content when adding page numbers (#2201)
Fixes #2201. Top/bottom page numbering preserves neighboring header/footer text, formatting and tables, recognizes existing PAGE fields through nested tables/text boxes, and updates gallery-generated numbering in place.

The latest review follow-up lets refreshed PAGE/NUMPAGES result caches vary during generated-number recognition. It retains those caches and paragraph marks during format/alignment changes; identical requests remain clean and add no undo step. Field instructions, flags and paragraph/run formatting remain conservatively checked. Original history, incoming progress notes and readiness estimates are preserved.

Local validation before the integration refresh:

- Actual RED: 13 existing tests passed; the new reopened cached-PAGE fixture failed because right alignment stayed centered.
- GREEN: all **14 preservation tests** pass, with all 13 originals an exact byte prefix. The fixture checks DOCX-reopened caches 7/12, no-op cleanliness/undo, later changes, content/marks/selection and undo/redo.
- Full engine package: **1,350 passed, zero failed, one existing ignored measurement**, including the focused 14. Engine formatting and strict all-target Clippy pass.
- Synthetic DOCX coverage; native Word interoperability was not exercised. No new whole-workspace local run is claimed.

Current head: `5eb4c8cb8b205ebcfcb5b1a48d55c5e21b1683e6`, a normal preserving merge of upstream `d40d1b27181b61990c84c75f77d5fd23ef81d452`. The new roadmap and format-parity documentation conflicts retain all incoming progress and revision history plus this PR's existing page-number notes. All **1,087 published files** match incoming upstream plus the five reviewed changes; both owned Rust files remain byte-for-byte unchanged from `ca615f3` and `447dec8`, and manifests/lock match incoming upstream. No new local Rust run is claimed for this documentation-only integration. The [preceding ca615f3 full hosted gate](https://github.com/storytold/wordcraft/actions/runs/42679017335/job/129368197175?pr=2251) passed in 13m38s. The [required current-head full gate](https://github.com/storytold/wordcraft/actions/runs/42693258780/job/129418282235?pr=2251) passed in **13m06s**; SonarCloud planning/scanning also passed. Required validation is complete. Maintainer edits are enabled; FredApps re-review remains requested.

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

## Summary

* **Bug Fixes**
  * Adding page numbers to headers and footers now preserves existing content and formatting, including content in tables and text boxes.
  * Existing user-owned page fields remain unchanged, while generated page numbers can be updated without disturbing surrounding content.
  * Repeating an identical page-number request no longer changes the document’s state or undo history.
* **Documentation**
  * Updated page-numbering guidance to reflect these preservation behaviors.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-11 09:06:18 +01:00
FredApps 45db128c15 fix(wordcraft): satisfy clippy on clipboard.rs after #5441
#5441 (92e0e67f) landed a Wayland clipboard size check in `apps/wordcraft/src/clipboard.rs` that left two clippy `-D warnings` failures on main:

- an unused `self` import in the Unix `platform` module (`use wordcraft_ui_egui::paste_picture::{self, ClipboardPicture}`)
- `items_after_test_module`: `platform::picture()` was defined after `mod tests`

This removes the unused import and moves `picture()` above the test module. Behaviour is unchanged.

Local checks: `cargo fmt -p wordcraft` and `cargo clippy -p wordcraft --all-targets --locked -- -D warnings` on Linux.

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Refactor**
  * No user-visible changes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-11 08:51:03 +01:00
3 changed files with 533 additions and 32 deletions
+17 -17
View File
@@ -164,7 +164,7 @@ mod platform {
use std::io::Read;
use wl_clipboard_rs::paste::{ClipboardType, MimeType, Seat, get_contents, get_mime_types_ordered};
use wordcraft_ui_egui::paste_picture::{self, ClipboardPicture};
use wordcraft_ui_egui::paste_picture::ClipboardPicture;
fn wayland() -> bool {
std::env::var_os("WAYLAND_DISPLAY").is_some()
@@ -348,6 +348,22 @@ mod platform {
}
}
/// The Wayland clipboard's picture, read encoded under the paste's byte cap and refused from
/// its header when a paste can't take its size, before anything decodes it. arboard decodes
/// and converts the PNG to RGBA with no size limits. `None` when Wayland offers no picture
/// (or isn't reachable): arboard is asked then, which also covers X11.
pub fn picture() -> Option<ClipboardPicture> {
if !wayland() {
return None;
}
let offered = formats();
let mime = super::PICTURE_MIMES.into_iter().find(|m| offered.iter().any(|o| o == m))?;
Some(match read(mime, wordcraft_ui_egui::paste_picture::MAX_FILE_BYTES) {
Some(bytes) => wordcraft_ui_egui::paste_picture::encoded_picture(bytes),
None => ClipboardPicture::Unreadable("the clipboard picture is larger than 200 MB, or couldn't be read".into()),
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -369,22 +385,6 @@ mod platform {
assert!(complete(0, 0, 0));
}
}
/// The Wayland clipboard's picture, read encoded under the paste's byte cap and refused from
/// its header when a paste can't take its size, before anything decodes it. arboard decodes
/// and converts the PNG to RGBA with no size limits. `None` when Wayland offers no picture
/// (or isn't reachable): arboard is asked then, which also covers X11.
pub fn picture() -> Option<ClipboardPicture> {
if !wayland() {
return None;
}
let offered = formats();
let mime = super::PICTURE_MIMES.into_iter().find(|m| offered.iter().any(|o| o == m))?;
Some(match read(mime, wordcraft_ui_egui::paste_picture::MAX_FILE_BYTES) {
Some(bytes) => wordcraft_ui_egui::paste_picture::encoded_picture(bytes),
None => ClipboardPicture::Unreadable("the clipboard picture is larger than 200 MB, or couldn't be read".into()),
})
}
}
#[cfg(target_os = "windows")]
+118 -15
View File
@@ -1,8 +1,72 @@
use super::PartKind;
use super::header_footer::hf_part;
use super::{
Align, Block, CharProps, CmdError, CmdResult, InlineObject, Paragraph, Rgb, Selection, Session, StoryRef, TabAlign, TabStop, TextColor, Value,
delete_selection, json, p, para_block, sel_result, type_text,
};
use std::collections::BTreeSet;
fn story_has_page_field(doc: &wordcraft_doc::Document, story: StoryRef, except: Option<usize>) -> bool {
let Some(blocks) = doc.story(story) else { return false };
let mut seen = BTreeSet::from([story]);
let mut pending: Vec<&Block> = blocks.iter().enumerate().filter(|(i, _)| Some(*i) != except).map(|(_, b)| &**b).collect();
while let Some(block) = pending.pop() {
let para = match block {
Block::Para(para) => para,
Block::Table(table) => {
pending.extend(table.rows.iter().flat_map(|r| &r.cells).flat_map(|c| &c.blocks).map(|b| &**b));
continue;
}
};
for object in &para.objects {
if matches!(object, InlineObject::Field { instr, .. } | InlineObject::FieldStart { instr, .. } if instr.split_whitespace().next().is_some_and(|name| name.eq_ignore_ascii_case("PAGE")))
{
return true;
}
for id in object.text_boxes() {
if let Some(part) = doc.parts.get(&id).filter(|part| part.kind == PartKind::TextBox)
&& seen.insert(StoryRef::Part(id))
{
pending.extend(part.blocks.iter().map(|b| &**b));
}
}
}
}
false
}
fn page_number_paragraph(style: &str, align: Align, x_of_y: bool) -> Result<Paragraph, CmdError> {
let field = InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() };
let mut para = Paragraph::new().styled(style);
para.props.align = Some(align);
para.props.tabs = Some(vec![TabStop { pos: 0.0, align: TabAlign::Clear, leader: Default::default() }]);
if x_of_y {
para.insert_text(0, "Page of ", &CharProps::default())?;
para.insert_object(5, field, &CharProps { bold: Some(true), ..Default::default() })?;
let end = para.len();
para.insert_object(
end,
InlineObject::Field { instr: "NUMPAGES".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() },
&CharProps { bold: Some(true), ..Default::default() },
)?;
} else {
para.insert_object(0, field, &CharProps::default())?;
}
Ok(para)
}
// Only cached display text is transferable; instructions, flags and formatting remain part of ownership.
fn copy_page_number_results(to: &mut Paragraph, from: &Paragraph) {
for object in &mut to.objects {
let InlineObject::Field { instr, result, .. } = object else { continue };
if let Some(cached) = from.objects.iter().find_map(|object| match object {
InlineObject::Field { instr: source, result, .. } if source.as_str() == instr.as_str() => Some(result),
_ => None,
}) {
result.clone_from(cached);
}
}
}
pub(super) fn page_number(s: &mut Session, v: &Value) -> CmdResult {
let position = p::str(v, "position").unwrap_or("bottom");
@@ -22,22 +86,61 @@ pub(super) fn page_number(s: &mut Session, v: &Value) -> CmdResult {
}
let header = position == "top";
let id = hf_part(s, header);
let mut para = Paragraph::new().styled(if header { "Header" } else { "Footer" });
para.props.align = Some(align);
para.props.tabs = Some(vec![TabStop { pos: 0.0, align: TabAlign::Clear, leader: Default::default() }]);
if x_of_y {
para.insert_text(0, "Page of ", &CharProps::default())?;
para.insert_object(5, field, &CharProps { bold: Some(true), ..Default::default() })?;
let end = para.len();
para.insert_object(
end,
InlineObject::Field { instr: "NUMPAGES".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() },
&CharProps { bold: Some(true), ..Default::default() },
)?;
} else {
para.insert_object(0, field, &CharProps::default())?;
let story = StoryRef::Part(id);
let style = if header { "Header" } else { "Footer" };
let mut para = page_number_paragraph(style, align, x_of_y)?;
// Replace only a top-level paragraph matching our generated structure. User text,
// field switches/metadata and paragraph/run formatting must not be mistaken for a gallery number.
// Cached field results and paragraph-mark formatting survive gallery changes and identical requests.
if story_has_page_field(&s.doc, story, None) {
let generated = s.doc.story(story).and_then(|blocks| {
blocks.iter().enumerate().find_map(|(i, block)| {
let Block::Para(existing) = &**block else { return None };
let Some(align @ (Align::Left | Align::Center | Align::Right)) = existing.props.align else { return None };
[false, true]
.into_iter()
.any(|x_of_y| {
page_number_paragraph(style, align, x_of_y).is_ok_and(|mut p| {
if p.objects.len() != existing.objects.len() {
return false;
}
p.mark = existing.mark.clone();
copy_page_number_results(&mut p, existing);
*existing == p
})
})
.then_some((i, existing))
})
});
// More than one PAGE makes ownership ambiguous, including in tables or anchored text
// boxes. The traversal visits each story once so cycles cannot hang insertion.
if let Some((i, existing)) = generated
&& !story_has_page_field(&s.doc, story, Some(i))
{
para.mark = existing.mark.clone();
copy_page_number_results(&mut para, existing);
let replacement = para_block(para);
if s.doc.story(story).and_then(|blocks| blocks.get(i)) != Some(&replacement) {
let block = s.doc.story_mut(story)?.get_mut(i).ok_or_else(|| CmdError::Failed("page-number paragraph is missing".into()))?;
*block = replacement;
}
}
return Ok(json!({"story": id}));
}
s.doc.set_story(StoryRef::Part(id), vec![para_block(para)])?;
let blocks = s.doc.story_mut(story)?;
// A DOCX round trip can add paragraph-mark formatting to the otherwise untouched
// Header/Footer placeholder. Reuse it without losing that formatting or adding a blank line.
if let [block] = blocks.as_slice()
&& let Block::Para(existing) = &**block
{
let mut placeholder = Paragraph::new().styled(style);
placeholder.mark = existing.mark.clone();
if *existing == placeholder {
para.mark = existing.mark.clone();
blocks.clear();
}
}
blocks.push(para_block(para));
Ok(json!({"story": id}))
}
@@ -0,0 +1,398 @@
//! #2201: inserting page numbers must preserve the header/footer story.
use std::sync::Arc;
use serde_json::json;
use wordcraft_doc::para::{GroupChild, InlineObject};
use wordcraft_doc::props::{Align, CharProps};
use wordcraft_doc::{Block, Document, Paragraph, PartKind, Path, StoryRef, Table, para_block};
use wordcraft_engine::{Selection, Session};
fn fields(doc: &Document, story: StoryRef, name: &str) -> usize {
doc.para_paths(story)
.iter()
.filter_map(|path| doc.para(story, path))
.flat_map(|p| &p.objects)
.filter(|o| matches!(o, InlineObject::Field { instr, .. } | InlineObject::FieldStart { instr, .. } if instr.split_whitespace().next().is_some_and(|token| token.eq_ignore_ascii_case(name))))
.count()
}
fn field(instr: &str) -> InlineObject {
InlineObject::Field { instr: instr.into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() }
}
#[test]
fn numbering_preserves_content_formatting_selection_and_undo_redo() {
for (command, position) in [("insert.header", "top"), ("insert.footer", "bottom")] {
for format in ["plain", "x of y"] {
let mut s = Session::new(Document::from_text("Body text"));
let id = s.run(command, &json!({"text": "Keep this\nSecond line"})).unwrap()["story"].as_u64().unwrap() as u32;
let story = StoryRef::Part(id);
s.doc.para_mut(story, &Path::top(0)).unwrap().props.align = Some(Align::Right);
let mut table = Table::new(1, 2, 300.0);
table.rows[0].cells[0].blocks =
vec![para_block(Paragraph::with_text("Keep table", CharProps { bold: Some(true), ..Default::default() }))];
s.doc.story_mut(story).unwrap().push(Arc::new(Block::Table(table)));
let before = s.doc.clone();
let selection = s.sel.clone();
let blocks = s.doc.story(story).unwrap().clone();
let depth = s.undo_depth();
s.run("insert.pageNumber", &json!({"position": position, "format": format})).unwrap();
assert_eq!(&s.doc.story(story).unwrap()[..blocks.len()], blocks.as_slice());
assert_eq!(s.sel, selection);
assert_eq!(fields(&s.doc, story, "PAGE"), 1);
assert_eq!(fields(&s.doc, story, "NUMPAGES"), usize::from(format == "x of y"));
assert_eq!(s.undo_depth(), depth + 1);
let after = s.doc.clone();
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc, before);
assert_eq!(s.sel, selection);
s.run("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc, after);
s.run("insert.pageNumber", &json!({"position": position, "format": format})).unwrap();
assert_eq!(s.doc, after, "repeated numbering must not duplicate or replace content");
let mut back = after.clone();
for _ in 0..2 {
back = wordcraft_docx::read(&wordcraft_docx::write(&back).unwrap()).unwrap();
let id = if position == "top" { back.last_section.headers.default } else { back.last_section.footers.default }.unwrap();
let reopened = StoryRef::Part(id);
assert_eq!(back.plain_text(StoryRef::Body), "Body text");
assert!(back.plain_text(reopened).starts_with("Keep this\nSecond line\nKeep table"));
assert_eq!(back.para(reopened, &Path::top(0)).unwrap().props.align, Some(Align::Right));
let Block::Table(table) = &*back.story(reopened).unwrap()[2] else { panic!("lost table") };
let Block::Para(p) = &*table.rows[0].cells[0].blocks[0] else { panic!("lost cell") };
assert_eq!(p.props_at(0).bold, Some(true));
assert_eq!(fields(&back, reopened, "PAGE"), 1);
assert_eq!(fields(&back, reopened, "NUMPAGES"), usize::from(format == "x of y"));
}
}
}
}
#[test]
fn existing_page_field_in_a_table_is_preserved_with_its_switches() {
for depth in [1, 17] {
let mut s = Session::new(Document::from_text("Body"));
let id = s.run("insert.footer", &json!({"text": "Keep footer"})).unwrap()["story"].as_u64().unwrap() as u32;
let mut para = Paragraph::with_text("Existing page: ", CharProps::default());
para.insert_object(para.len(), field(" page \\* MERGEFORMAT "), &CharProps::default()).unwrap();
let mut blocks = vec![para_block(para)];
for _ in 0..depth {
let mut table = Table::new(1, 1, 300.0);
table.rows[0].cells[0].blocks = blocks;
blocks = vec![Arc::new(Block::Table(table))];
}
s.doc.story_mut(StoryRef::Part(id)).unwrap().extend(blocks);
let before = s.doc.clone();
s.run("insert.pageNumber", &json!({"position": "bottom", "format": "x of y"})).unwrap();
assert!(s.doc == before, "PAGE in a table nested {depth} deep must not be duplicated");
}
}
#[test]
fn existing_range_page_field_is_not_duplicated() {
let mut s = Session::new(Document::new());
let id = s.run("insert.header", &json!({"text": "Keep header"})).unwrap()["story"].as_u64().unwrap() as u32;
let story = StoryRef::Part(id);
let para = s.doc.para_mut(story, &Path::top(0)).unwrap();
para.insert_object(para.len(), InlineObject::FieldStart { instr: "PAGE".into(), locked: true, dirty: true }, &CharProps::default()).unwrap();
para.insert_text(para.len(), "7", &CharProps::default()).unwrap();
para.insert_object(para.len(), InlineObject::FieldEnd, &CharProps::default()).unwrap();
let before = s.doc.clone();
s.run("insert.pageNumber", &json!({"position": "top"})).unwrap();
assert_eq!(s.doc, before);
}
#[test]
fn unrelated_fields_do_not_prevent_numbering_and_formatted_empty_paragraphs_survive() {
let mut s = Session::new(Document::new());
let id = s.run("insert.header", &json!({"text": ""})).unwrap()["story"].as_u64().unwrap() as u32;
let story = StoryRef::Part(id);
let para = s.doc.para_mut(story, &Path::top(0)).unwrap();
para.props.align = Some(Align::Right);
let empty = para.clone();
let mut para = Paragraph::new();
para.insert_object(0, field("PAGEREF bookmark"), &CharProps::default()).unwrap();
s.doc.story_mut(story).unwrap().push(para_block(para));
s.run("insert.pageNumber", &json!({"position": "top"})).unwrap();
assert_eq!(s.doc.para(story, &Path::top(0)).unwrap(), &empty);
assert_eq!(fields(&s.doc, story, "PAGEREF"), 1);
assert_eq!(fields(&s.doc, story, "PAGE"), 1);
}
#[test]
fn fresh_numbering_uses_one_paragraph_and_current_position_stays_inline() {
for position in ["top", "bottom"] {
let mut s = Session::new(Document::new());
let id = s.run("insert.pageNumber", &json!({"position": position})).unwrap()["story"].as_u64().unwrap() as u32;
assert_eq!(s.doc.story(StoryRef::Part(id)).unwrap().len(), 1);
}
let mut s = Session::new(Document::from_text("Before selected after"));
s.run("select.text", &json!({"text": "selected"})).unwrap();
let before = s.doc.clone();
s.run("insert.pageNumber", &json!({"position": "current"})).unwrap();
assert!(s.doc.parts.is_empty());
assert_eq!(fields(&s.doc, StoryRef::Body, "PAGE"), 1);
assert!(!s.doc.plain_text(StoryRef::Body).contains("selected"));
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc, before);
}
#[test]
fn existing_page_field_in_nested_text_boxes_is_preserved() {
for (command, position) in [("insert.header", "top"), ("insert.footer", "bottom")] {
for levels in [1, 2] {
let mut s = Session::new(Document::from_text("Body"));
let id = s.run(command, &json!({"text": "Keep this"})).unwrap()["story"].as_u64().unwrap() as u32;
let story = StoryRef::Part(id);
let box_id = s.run("insert.textBox", &json!({"text": "Existing page: "})).unwrap()["story"].as_u64().unwrap() as u32;
if levels == 2 {
// Imported stories can contain a nested box, even though the insert command
// does not offer one while the caret is already in a text box.
let inner = s.doc.add_part(PartKind::TextBox, vec![para_block(Paragraph::new())]);
let mut anchor = s.doc.para(story, &Path::top(0)).unwrap().objects.iter().find(|o| o.text_box() == Some(box_id)).unwrap().clone();
if let InlineObject::Shape { story, .. } = &mut anchor {
*story = Some(inner);
}
s.doc.para_mut(StoryRef::Part(box_id), &Path::top(0)).unwrap().insert_object(0, anchor, &CharProps::default()).unwrap();
s.sel = Selection::caret(s.doc.end_of(StoryRef::Part(inner)));
}
s.run("insert.pageNumber", &json!({"position": "current"})).unwrap();
let before = s.doc.clone();
let selection = s.sel.clone();
s.run("insert.pageNumber", &json!({"position": position, "format": "x of y"})).unwrap();
assert_eq!(fields(&s.doc, story, "PAGE"), 0, "the text box already contains the page number");
assert_eq!(s.doc, before);
assert_eq!(s.sel, selection);
let mut back = s.doc.clone();
for _ in 0..2 {
back = wordcraft_docx::read(&wordcraft_docx::write(&back).unwrap()).unwrap();
assert_eq!(back.parts.keys().map(|id| fields(&back, StoryRef::Part(*id), "PAGE")).sum::<usize>(), 1);
assert_eq!(back.parts.keys().map(|id| fields(&back, StoryRef::Part(*id), "NUMPAGES")).sum::<usize>(), 0);
}
}
}
}
#[test]
fn existing_page_field_in_grouped_text_boxes_is_preserved() {
for (command, position) in [("insert.header", "top"), ("insert.footer", "bottom")] {
let mut s = Session::new(Document::from_text("Body"));
let id = s.run(command, &json!({"text": "Keep this"})).unwrap()["story"].as_u64().unwrap() as u32;
let story = StoryRef::Part(id);
let box_id = s.run("insert.textBox", &json!({"text": "Existing page: "})).unwrap()["story"].as_u64().unwrap() as u32;
s.run("insert.pageNumber", &json!({"position": "current"})).unwrap();
let anchor = s.doc.para_mut(story, &Path::top(0)).unwrap().objects.iter_mut().find(|o| o.text_box() == Some(box_id)).unwrap();
*anchor = InlineObject::Group {
w: 144.0,
h: 72.0,
float: Default::default(),
ch_w: 144.0,
ch_h: 72.0,
children: vec![GroupChild { x: 0.0, y: 0.0, obj: anchor.clone() }],
canvas: None,
};
let before = s.doc.clone();
let selection = s.sel.clone();
s.run("insert.pageNumber", &json!({"position": position})).unwrap();
assert_eq!(fields(&s.doc, story, "PAGE"), 0, "the grouped text box already contains the page number");
assert_eq!(s.doc, before);
assert_eq!(s.sel, selection);
let mut back = s.doc.clone();
for _ in 0..2 {
back = wordcraft_docx::read(&wordcraft_docx::write(&back).unwrap()).unwrap();
assert_eq!(back.parts.keys().map(|id| fields(&back, StoryRef::Part(*id), "PAGE")).sum::<usize>(), 1);
}
}
}
#[test]
fn page_fields_in_unrelated_text_boxes_do_not_prevent_numbering() {
let mut s = Session::new(Document::from_text("Body"));
let box_id = s.run("insert.textBox", &json!({"text": "Body page: "})).unwrap()["story"].as_u64().unwrap() as u32;
s.run("insert.pageNumber", &json!({"position": "current"})).unwrap();
let footer = s.run("insert.footer", &json!({"text": "Keep footer"})).unwrap()["story"].as_u64().unwrap() as u32;
s.run("insert.pageNumber", &json!({"position": "bottom"})).unwrap();
assert_eq!(fields(&s.doc, StoryRef::Part(box_id), "PAGE"), 1);
assert_eq!(fields(&s.doc, StoryRef::Part(footer), "PAGE"), 1);
}
#[test]
fn cyclic_text_box_references_do_not_block_numbering() {
let mut s = Session::new(Document::from_text("Body"));
let footer = s.run("insert.footer", &json!({"text": "Keep footer"})).unwrap()["story"].as_u64().unwrap() as u32;
let box_id = s.run("insert.textBox", &json!({"text": "A box"})).unwrap()["story"].as_u64().unwrap() as u32;
let anchor = s.doc.para(StoryRef::Part(footer), &Path::top(0)).unwrap().objects.iter().find(|o| o.text_box() == Some(box_id)).unwrap().clone();
s.doc.para_mut(StoryRef::Part(box_id), &Path::top(0)).unwrap().insert_object(0, anchor, &CharProps::default()).unwrap();
s.run("insert.pageNumber", &json!({"position": "bottom"})).unwrap();
assert_eq!(fields(&s.doc, StoryRef::Part(footer), "PAGE"), 1);
}
#[test]
fn generated_numbers_switch_format_without_losing_neighboring_content() {
for (command, position) in [("insert.header", "top"), ("insert.footer", "bottom")] {
let mut s = Session::new(Document::from_text("Body"));
let id = s.run(command, &json!({"text": "Keep this"})).unwrap()["story"].as_u64().unwrap() as u32;
let story = StoryRef::Part(id);
let mut table = Table::new(1, 1, 300.0);
table.rows[0].cells[0].blocks = vec![para_block(Paragraph::with_text("Keep table", CharProps::default()))];
s.doc.story_mut(story).unwrap().push(Arc::new(Block::Table(table)));
let blocks = s.doc.story(story).unwrap().clone();
let selection = s.sel.clone();
for (format, align) in [("plain", "left"), ("x of y", "right"), ("plain", "center")] {
let before = s.doc.clone();
s.run("insert.pageNumber", &json!({"position": position, "format": format, "align": align})).unwrap();
assert_eq!(&s.doc.story(story).unwrap()[..blocks.len()], blocks.as_slice());
assert_eq!(s.sel, selection);
assert_eq!(fields(&s.doc, story, "PAGE"), 1);
assert_eq!(fields(&s.doc, story, "NUMPAGES"), usize::from(format == "x of y"));
let expected = match align {
"left" => Align::Left,
"right" => Align::Right,
_ => Align::Center,
};
assert_eq!(s.doc.para(story, &Path::top(blocks.len())).unwrap().props.align, Some(expected));
let after = s.doc.clone();
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc, before);
s.run("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc, after);
}
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
let id = if position == "top" { back.last_section.headers.default } else { back.last_section.footers.default }.unwrap();
assert!(back.plain_text(StoryRef::Part(id)).starts_with("Keep this\nKeep table"));
assert_eq!(fields(&back, StoryRef::Part(id), "PAGE"), 1);
assert_eq!(fields(&back, StoryRef::Part(id), "NUMPAGES"), 0);
}
}
#[test]
fn repeated_numbering_keeps_clean_state_and_undo_depth() {
for position in ["top", "bottom"] {
for format in ["plain", "x of y"] {
let mut s = Session::new(Document::new());
s.run("insert.pageNumber", &json!({"position": position, "format": format})).unwrap();
s.dirty = false;
let doc = s.doc.clone();
let depth = s.undo_depth();
s.run("insert.pageNumber", &json!({"position": position, "format": format})).unwrap();
assert_eq!(s.doc, doc);
assert_eq!(s.undo_depth(), depth, "an identical request must not add an undo step");
assert!(!s.dirty, "an identical request must not dirty the document");
}
}
}
#[test]
fn reopened_empty_header_footer_mark_is_reused_without_a_blank_line() {
for (command, position) in [("insert.header", "top"), ("insert.footer", "bottom")] {
let mut s = Session::new(Document::from_text("Body"));
let id = s.run(command, &json!({"text": ""})).unwrap()["story"].as_u64().unwrap() as u32;
s.doc.para_mut(StoryRef::Part(id), &Path::top(0)).unwrap().mark = CharProps { bold: Some(true), italic: Some(true), ..Default::default() };
let doc = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
let id = if position == "top" { doc.last_section.headers.default } else { doc.last_section.footers.default }.unwrap();
let story = StoryRef::Part(id);
let mark = doc.para(story, &Path::top(0)).unwrap().mark.clone();
assert_eq!(mark.bold, Some(true));
let mut s = Session::new(doc);
let before = s.doc.clone();
s.run("insert.pageNumber", &json!({"position": position})).unwrap();
assert_eq!(s.doc.story(story).unwrap().len(), 1, "reuse the imported empty paragraph rather than add a blank line");
assert_eq!(s.doc.para(story, &Path::top(0)).unwrap().mark, mark);
assert_eq!(fields(&s.doc, story, "PAGE"), 1);
let inserted = s.doc.clone();
s.run("insert.pageNumber", &json!({"position": position, "format": "x of y", "align": "right"})).unwrap();
assert_eq!(s.doc.story(story).unwrap().len(), 1);
assert_eq!(s.doc.para(story, &Path::top(0)).unwrap().mark, mark);
assert_eq!(s.doc.para(story, &Path::top(0)).unwrap().props.align, Some(Align::Right));
assert_eq!(fields(&s.doc, story, "PAGE"), 1);
assert_eq!(fields(&s.doc, story, "NUMPAGES"), 1);
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc, inserted);
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc, before);
}
}
#[test]
fn user_owned_page_number_lookalikes_are_preserved() {
for kind in ["formatting", "metadata", "another field"] {
let mut s = Session::new(Document::from_text("Body"));
let id = s.run("insert.pageNumber", &json!({"position": "bottom"})).unwrap()["story"].as_u64().unwrap() as u32;
let story = StoryRef::Part(id);
match kind {
"formatting" => s.doc.para_mut(story, &Path::top(0)).unwrap().props.indent_left = Some(12.0),
"metadata" => {
let InlineObject::Field { locked, .. } = &mut s.doc.para_mut(story, &Path::top(0)).unwrap().objects[0] else {
panic!("no page field")
};
*locked = true;
}
_ => {
let mut para = Paragraph::with_text("User page: ", CharProps::default());
para.insert_object(para.len(), field("PAGE"), &CharProps::default()).unwrap();
s.doc.story_mut(story).unwrap().push(para_block(para));
}
}
let before = s.doc.clone();
s.run("insert.pageNumber", &json!({"position": "bottom", "format": "x of y"})).unwrap();
assert_eq!(s.doc, before, "preserve {kind} rather than claim the PAGE as generated");
}
}
#[test]
fn reopened_cached_page_numbers_keep_noops_and_gallery_changes() {
for (command, position) in [("insert.header", "top"), ("insert.footer", "bottom")] {
for format in ["plain", "x of y"] {
let mut s = Session::new(Document::from_text("Body"));
let id = s.run(command, &json!({"text": "Keep this"})).unwrap()["story"].as_u64().unwrap() as u32;
let story = StoryRef::Part(id);
s.run("insert.pageNumber", &json!({"position": position, "format": format})).unwrap();
for object in &mut s.doc.para_mut(story, &Path::top(1)).unwrap().objects {
if let InlineObject::Field { instr, result, .. } = object {
*result = if instr == "PAGE" { "7" } else { "12" }.into();
}
}
let doc = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
let id = if position == "top" { doc.last_section.headers.default } else { doc.last_section.footers.default }.unwrap();
let story = StoryRef::Part(id);
let mut s = Session::new(doc);
let cached = |s: &Session, name: &str| {
s.doc.para(story, &Path::top(1)).unwrap().objects.iter().find_map(|object| match object {
InlineObject::Field { instr, result, .. } if instr == name => Some(result.clone()),
_ => None,
})
};
assert_eq!(cached(&s, "PAGE").as_deref(), Some("7"), "the fixture must retain the refreshed PAGE cache after reopen");
assert_eq!(cached(&s, "NUMPAGES").as_deref(), (format == "x of y").then_some("12"));
let kept = s.doc.story(story).unwrap()[0].clone();
let mark = s.doc.para(story, &Path::top(1)).unwrap().mark.clone();
let selection = s.sel.clone();
s.dirty = false;
let before = s.doc.clone();
let depth = s.undo_depth();
s.run("insert.pageNumber", &json!({"position": position, "format": format})).unwrap();
assert_eq!(s.doc, before, "identical requests must retain refreshed results");
assert_eq!(s.undo_depth(), depth);
assert!(!s.dirty);
for (next_format, align) in [("x of y", "right"), ("plain", "left")] {
let before = s.doc.clone();
s.run("insert.pageNumber", &json!({"position": position, "format": next_format, "align": align})).unwrap();
assert_eq!(s.doc.story(story).unwrap()[0], kept);
assert_eq!(s.sel, selection);
assert_eq!(s.doc.para(story, &Path::top(1)).unwrap().mark, mark);
assert_eq!(s.doc.para(story, &Path::top(1)).unwrap().props.align, Some(if align == "right" { Align::Right } else { Align::Left }));
assert_eq!(fields(&s.doc, story, "PAGE"), 1);
assert_eq!(fields(&s.doc, story, "NUMPAGES"), usize::from(next_format == "x of y"));
assert_eq!(cached(&s, "PAGE").as_deref(), Some("7"));
if format == "x of y" && next_format == "x of y" {
assert_eq!(cached(&s, "NUMPAGES").as_deref(), Some("12"));
}
let after = s.doc.clone();
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc, before);
s.run("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc, after);
}
}
}
}