From 5aa8151a5394397fb4e16b3286b0e80b8cc13bb4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 09:52:26 -0400 Subject: [PATCH] Text frames in headers, footers and cells, kept on save (#935) Found in NIH's monitoring-plan template compared with Word for Windows: its footer puts the page number in a text frame (w:framePr: centred, text wrapping around it), and Word draws "NCCIH DSMP Template" ending just left of the number on the same line. Before: frames other than drop caps were ignored on import, so the number took a line of its own above the footer text (making every page a line shorter), and the frame was lost on save. After: a paragraph's text frame is read (size, anchors, alignment, offsets, wrap, distances) and written back. In headers, footers, table cells and text boxes it is placed like a floating object, sized to its text unless it has a width, and the text after it wraps around it; the DSMP footer now matches Word's. Frames in the body text still lay out in line. Co-authored-by: Claude Opus 5.5 (1M context) --- ROADMAP.md | 1 + crates/doc/src/props.rs | 33 ++++++++++++++++- crates/doc/src/resolve.rs | 4 +++ crates/docx/src/read/props.rs | 24 +++++++++++++ crates/docx/src/write/props.rs | 29 +++++++++++++++ crates/docx/tests/fixtures.rs | 30 ++++++++++++++++ crates/docx/tests/roundtrip.rs | 14 ++++++++ crates/layout/src/lib.rs | 65 ++++++++++++++++++++++++++++++++++ crates/layout/src/tests.rs | 27 ++++++++++++++ docs/file-format-parity.md | 1 + docs/layout-parity.md | 4 +++ 11 files changed, 231 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 113f464..4463a19 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -148,6 +148,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | DOCX headings numbered like Word when a heading style gives only a list level (6.4.5, not 6.15) | | 2026-10-12 | A list label makes its line taller above the baseline only, as in Word (6 cases measured in Word for Windows): Symbol bullets no longer add their deeper descent, so bulleted lists keep Word's height and page breaks | | 2026-10-12 | Hidden end-of-cell marks (`w:hideMark`) read, written and laid out like Word (9 cases measured in Word for Windows): a cell's empty last paragraph takes no room, so rows of such empty cells shrink to their borders | +| 2026-10-12 | Text frames (`w:framePr`) in headers, footers, cells and text boxes: placed apart with text wrapping around them, and kept on save (a framed footer page number took a line of its own and was lost on save) | | 2026-10-11 | Hide White Space (#645): `view.hideWhiteSpace` (View › Show checkbox, or double-click the gap between pages) collapses margins, headers and footers between pages to a thin line in Print Layout; screen only, page count unchanged | | 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) | |---|---| diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index c711548..220a39b 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -626,6 +626,33 @@ pub struct NumRef { pub level: u8, } +/// A text frame's placement (`w:framePr`, ECMA-376 §17.3.1.11), in points. Offsets apply when +/// the matching alignment is unset; anchors and the wrap keep OOXML's names. +#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct TextFrame { + /// Width; `None` fits the text. + pub w: Option, + /// Height, with `h_exact` exact rather than at least; `None` fits the text. + pub h: Option, + pub h_exact: bool, + pub x: f32, + pub y: f32, + /// `left`, `center`, `right`, `inside`, `outside`. + pub x_align: Option, + /// `top`, `center`, `bottom`, `inside`, `outside` (`inline` is no alignment). + pub y_align: Option, + /// `text` (the column), `margin` or `page`. + pub h_anchor: String, + /// `text` (the paragraph), `margin` or `page`. + pub v_anchor: String, + /// `around`, `auto`, `notBeside`, `none`, `tight` or `through`. + pub wrap: String, + /// Distance from the text left and right, and above and below. + pub h_space: f32, + pub v_space: f32, +} + /// Paragraph formatting. `None` = inherit. #[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -697,6 +724,10 @@ pub struct ParaProps { /// Drop cap: lines to drop (0 = none) — applies to the first character(s). #[serde(skip_serializing_if = "Option::is_none")] pub drop_cap: Option, + /// A text frame (`w:framePr` other than a drop cap): the paragraph is placed apart from the + /// text, which may wrap around it — a footer's page number in a frame, for instance. + #[serde(skip_serializing_if = "Option::is_none")] + pub frame: Option, /// Asian typography (`w:kinsoku`): East Asian line-breaking rules — no line starts with /// closing punctuation such as 、。」 or ends with opening punctuation such as 「(. /// Unset = on. @@ -733,7 +764,7 @@ impl ParaProps { pub fn overlay(&mut self, patch: &ParaProps) { overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, indent_left_chars, indent_right_chars, indent_first_chars, space_before, space_after, line_spacing, contextual_spacing, mirror_indents, keep_next, keep_lines, page_break_before, widow_control, outline_level, numbering, tabs, shading, borders, - suppress_hyphens, suppress_line_numbers, bidi, drop_cap, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn, + suppress_hyphens, suppress_line_numbers, bidi, drop_cap, frame, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn, snap_to_grid); } pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps { diff --git a/crates/doc/src/resolve.rs b/crates/doc/src/resolve.rs index a96f352..a8067ab 100644 --- a/crates/doc/src/resolve.rs +++ b/crates/doc/src/resolve.rs @@ -153,6 +153,9 @@ pub struct ResolvedPara { pub borders: Option, pub bidi: bool, pub drop_cap: u8, + /// A text frame the paragraph sits in (see `ParaProps::frame`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub frame: Option, pub suppress_hyphens: bool, pub suppress_line_numbers: bool, /// Asian typography (see `ParaProps`): East Asian line-breaking rules. @@ -226,6 +229,7 @@ impl ResolvedPara { borders: p.borders.filter(Borders::any_visible), bidi: p.bidi.unwrap_or(false), drop_cap: p.drop_cap.unwrap_or(0).min(10), + frame: p.frame.clone().filter(|_| p.drop_cap.unwrap_or(0) == 0), suppress_hyphens: p.suppress_hyphens.unwrap_or(false), suppress_line_numbers: p.suppress_line_numbers.unwrap_or(false), kinsoku: p.kinsoku.unwrap_or(true), diff --git a/crates/docx/src/read/props.rs b/crates/docx/src/read/props.rs index 2f248b1..850372e 100644 --- a/crates/docx/src/read/props.rs +++ b/crates/docx/src/read/props.rs @@ -14,6 +14,28 @@ use wordcraft_doc::section::{ use super::drawing_color::adjust_lum; use crate::units::{int, measure, on_off, tw, u32_of}; + +/// A text frame (`w:framePr` without a drop cap, ECMA-376 §17.3.1.11), sizes and offsets in +/// points (clamped), names kept as OOXML writes them. +fn text_frame(k: &El) -> wordcraft_doc::props::TextFrame { + let len = |name: &str| tw(k, name).filter(|v| v.is_finite()).map(|v| v.clamp(-1584.0, 1584.0)); + let word = |name: &str, allowed: &[&str], default: &str| k.attr(name).filter(|v| allowed.contains(v)).unwrap_or(default).to_string(); + let align = |name: &str, allowed: &[&str]| k.attr(name).filter(|v| allowed.contains(v)).map(str::to_string); + wordcraft_doc::props::TextFrame { + w: len("w:w").filter(|v| *v > 0.0), + h: len("w:h").map(f32::abs).filter(|v| *v > 0.0), + h_exact: k.attr("w:hRule") == Some("exact"), + x: len("w:x").unwrap_or(0.0), + y: len("w:y").unwrap_or(0.0), + x_align: align("w:xAlign", &["left", "center", "right", "inside", "outside"]), + y_align: align("w:yAlign", &["top", "center", "bottom", "inside", "outside"]), + 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"), + h_space: len("w:hSpace").unwrap_or(0.0).max(0.0), + v_space: len("w:vSpace").unwrap_or(0.0).max(0.0), + } +} use crate::xml::El; /// Context for property reading: theme fonts and colours, and style id aliases. @@ -240,6 +262,8 @@ impl PropCtx { "w:framePr" => { if matches!(k.attr("w:dropCap"), Some("drop") | Some("margin")) { p.drop_cap = Some(k.attr("w:lines").and_then(u32_of).unwrap_or(3).clamp(1, 10) as u8); + } else { + p.frame = Some(text_frame(k)); } } "w:numPr" => { diff --git a/crates/docx/src/write/props.rs b/crates/docx/src/write/props.rs index 8aa70b1..ee32657 100644 --- a/crates/docx/src/write/props.rs +++ b/crates/docx/src/write/props.rs @@ -209,6 +209,35 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha "w:framePr", &[("w:dropCap", "drop"), ("w:lines", &n(lines.min(10) as i64)), ("w:wrap", "around"), ("w:vAnchor", "text"), ("w:hAnchor", "text")], ); + } else if let Some(f) = &p.frame { + let (fw, fh, fx, fy) = (f.w.map(twips), f.h.map(twips), twips(f.x), twips(f.y)); + let (hs, vs) = (twips(f.h_space), twips(f.v_space)); + let mut a: Vec<(&str, &str)> = Vec::new(); + if let Some(v) = &fw { + a.push(("w:w", v)); + } + if let Some(v) = &fh { + a.push(("w:h", v)); + a.push(("w:hRule", if f.h_exact { "exact" } else { "atLeast" })); + } + if f.h_space > 0.0 { + a.push(("w:hSpace", &hs)); + } + if f.v_space > 0.0 { + a.push(("w:vSpace", &vs)); + } + a.push(("w:wrap", &f.wrap)); + a.push(("w:vAnchor", &f.v_anchor)); + a.push(("w:hAnchor", &f.h_anchor)); + match &f.x_align { + Some(x) => a.push(("w:xAlign", x)), + None => a.push(("w:x", &fx)), + } + match &f.y_align { + Some(y) => a.push(("w:yAlign", y)), + None => a.push(("w:y", &fy)), + } + w.empty("w:framePr", &a); } toggle(w, "w:widowControl", p.widow_control); if let Some(nr) = p.numbering { diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index 3da1e6e..3b616b8 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -1901,3 +1901,33 @@ fn cell_hide_mark_reads_and_round_trips() { let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); assert_eq!(hides(&again), [true, false]); } + +/// Text frames (`w:framePr` without a drop cap), as NIH's templates put a footer's page number in +/// one: read with their size, anchors, alignment and wrap, and written back (they were dropped on +/// save). +#[test] +fn text_frames_read_and_round_trip() { + let body = r#"3 + boxed + junk + D + rop cap text"#; + let d = read_body(body); + let p = paras(&d); + let f = p[0].props.frame.clone().expect("a frame"); + assert_eq!( + (f.wrap.as_str(), f.v_anchor.as_str(), f.h_anchor.as_str(), f.x_align.as_deref(), f.y), + ("around", "text", "margin", Some("center"), 0.05) + ); + let g = p[1].props.frame.clone().expect("a frame"); + assert_eq!((g.w, g.h, g.h_exact, g.h_space, g.wrap.as_str(), g.x, g.y), (Some(144.0), Some(36.0), true, 9.0, "notBeside", 72.0, 144.0)); + let cap = p.iter().find(|q| q.props.drop_cap.is_some()).expect("the drop cap"); + assert!(cap.props.frame.is_none(), "a drop cap stays a drop cap"); + let junk = p.iter().find(|q| q.text.contains("junk")).and_then(|q| q.props.frame.clone()).expect("a frame"); + assert_eq!((junk.wrap.as_str(), junk.h_anchor.as_str(), junk.w, junk.x), ("auto", "page", None, 0.0), "unknown values fall back"); + let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); + let q = paras(&again); + assert_eq!(q[0].props.frame, p[0].props.frame); + assert_eq!(q[1].props.frame, p[1].props.frame); + assert!(q.iter().any(|x| x.props.drop_cap == Some(2) && x.props.frame.is_none())); +} diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/roundtrip.rs index 9dc5c35..10bbdc3 100644 --- a/crates/docx/tests/roundtrip.rs +++ b/crates/docx/tests/roundtrip.rs @@ -163,6 +163,20 @@ fn every_para_prop_round_trips() { suppress_line_numbers: Some(true), bidi: Some(false), drop_cap: None, + frame: Some(wordcraft_doc::props::TextFrame { + w: Some(144.0), + h: Some(36.0), + h_exact: true, + x: 0.0, + y: 12.0, + x_align: Some("right".into()), + y_align: None, + h_anchor: "margin".into(), + v_anchor: "text".into(), + wrap: "around".into(), + h_space: 9.0, + v_space: 0.0, + }), kinsoku: Some(false), word_wrap: Some(false), overflow_punct: Some(false), diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 567f99d..799077d 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -882,6 +882,24 @@ fn layout_box( Block::Para(p) => { let label = ctx.next_label(p); let mut pl = ctx.para_labelled(p, width, table, &[], label.clone()); + // A text frame (a footer's page number in a frame, say) is placed apart, like a + // floating object, and the text after it wraps around it; it takes no room in + // the flow. + if let Some(fr) = pl.rp.frame.clone() { + let fw = fr.w.unwrap_or_else(|| natural_width(&pl)).clamp(1.0, 1584.0); + let pf = if (fw - width).abs() > 0.01 { ctx.para_labelled(p, fw, table, &[], label) } else { pl }; + let h = match fr.h { + Some(h) if fr.h_exact => h, + Some(h) => h.max(pf.height), + None => pf.height, + }; + let float = frame_float(&fr); + let r = float_rect(frame, (0.0, width), y, fw, h, &float); + push_para(&mut items, story, &path, &pf, 0, pf.lines.len(), r.x, r.y, fw); + excl.extend(wrap_area(r, &float, None)); + float_tables.push(r); + continue; + } let ctxl = pl.rp.contextual_spacing; let same = prev_style.as_ref().is_some_and(|(s, c)| *s == pl.rp.style && (*c || ctxl)); let before = if same && ctxl { 0.0 } else { pl.rp.space_before }; @@ -963,6 +981,53 @@ fn layout_box( BoxLayout { items, height: y.max(0.0), floats_bottom, last_top: last_top.max(0.0) } } +/// The width a text frame without one takes: its widest line, with the paragraph's indents. +fn natural_width(pl: &ParaLayout) -> f32 { + let widest = (0..pl.lines.len()) + .filter_map(|li| { + let l = pl.lines.get(li)?; + Some((pl.x_of(li, l.stop)? - pl.x_of(li, l.start)?).abs()) + }) + .fold(0.0f32, f32::max); + widest + pl.rp.indent_left.max(0.0) + pl.rp.indent_right.max(0.0) +} + +/// A text frame's placement as a floating object's (`w:framePr` → anchors, alignment, wrap). +fn frame_float(fr: &wordcraft_doc::props::TextFrame) -> Float { + let anchor = |a: &str, text: Anchor| match a { + "page" => Anchor::Page, + "margin" => Anchor::Margin, + _ => text, + }; + let align = |a: Option<&str>| match a { + Some("left" | "top") => Some(FloatAlign::Start), + Some("center") => Some(FloatAlign::Center), + Some("right" | "bottom") => Some(FloatAlign::End), + Some("inside") => Some(FloatAlign::Inside), + Some("outside") => Some(FloatAlign::Outside), + _ => None, + }; + Float { + wrap: match fr.wrap.as_str() { + "notBeside" => Wrap::TopAndBottom, + "none" => Wrap::InFrontOfText, + "tight" => Wrap::Tight, + "through" => Wrap::Through, + _ => Wrap::Square, + }, + h_rel: anchor(&fr.h_anchor, Anchor::Column), + v_rel: anchor(&fr.v_anchor, Anchor::Paragraph), + x: fr.x, + y: fr.y, + h_align: align(fr.x_align.as_deref()), + v_align: align(fr.y_align.as_deref()), + dist: fr.h_space, + dist_top: fr.v_space, + dist_bottom: fr.v_space, + ..Default::default() + } +} + /// A laid-out box: items relative to its top-left, the height of its text flow, where its /// floating tables end (a table cell grows to hold them) and where its last block starts (the /// height without it). diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index e7eab5b..ec34678 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -4262,3 +4262,30 @@ fn a_hidden_cell_mark_takes_no_room() { let text = row2([true, true], Some("Tx")); assert!((text - line).abs() < 0.5, "text stays, the empty mark after it goes: {text} vs {line}"); } + +/// A text frame in a header, footer or cell (NIH's footers put the page number in one, centred, +/// with the footer text wrapping around it) is placed apart: it takes no room in the flow, sits +/// where its alignment says, and the text beside it keeps clear of it. WordCraft laid it out as +/// an ordinary paragraph, a line of its own above the footer text. +#[test] +fn a_text_frame_in_a_cell_floats_beside_the_text() { + use wordcraft_doc::props::TextFrame; + let mut d = Document::from_text("Above"); + let mut t = Table::new(1, 1, 300.0); + let mut framed = wordcraft_doc::Paragraph::with_text("7", Default::default()); + framed.props.frame = Some(TextFrame { + x_align: Some("right".into()), + h_anchor: "text".into(), + v_anchor: "text".into(), + wrap: "around".into(), + ..Default::default() + }); + let text = wordcraft_doc::Paragraph::with_text("Cell text", Default::default()); + t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(framed), wordcraft_doc::para_block(text)]; + d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap(); + let l = lay(&d); + let at = |i: u32| l.caret(&Pos { story: StoryRef::Body, path: Path(vec![1, 0, 0, i]), off: 0 }).unwrap(); + let (frame, text) = (at(0), at(1)); + assert!((frame.top - text.top).abs() < 0.5, "the frame takes no room: {} vs {}", frame.top, text.top); + assert!(frame.x > text.x + 200.0, "right-aligned in the cell: {} vs {}", frame.x, text.x); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 2ddc8ba..9c2369a 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -170,6 +170,7 @@ raise readiness estimates. No Word-derived files are fixtures. | 2026-10-12 | trivial | `.doc`: custom tab stops read from styles and paragraphs (`sprmPChgTabsPapx`, `sprmPChgTabs`, with the alignment and leader of each stop; `sprmPChgTabs`' operand size fixed), and a section mark ends its section's last paragraph instead of becoming a page break, so the sections (continuous breaks, footers) are kept. Found by comparing EPA templates with Word for Windows; two of them now paginate like Word | | 2026-10-12 | trivial | DOCX: a style or paragraph `w:numPr` with a level and no list takes the list of its style chain (Heading 3 based on a numbered Heading 2 numbers 6.4.5, not 6.15), found in NIH's monitoring-plan template against Word for Windows | | 2026-10-12 | trivial | DOCX: `w:hideMark` on table cells read and written | +| 2026-10-12 | trivial | DOCX text frames (`w:framePr` other than drop caps) are read and written back; they were dropped on save | | 2026-10-11 | trivial | `.doc`: inline and floating pictures (BLIP store, PNG/JPEG/DIB/TIFF/EMF/WMF), basic shapes, groups and text boxes read from OfficeArt drawings (#551); fidelity ~55% → ~60% | | 2026-10-11 | trivial | Word XML Document (Flat OPC) read and written, Word 2003 XML read (#548) | | 2026-10-11 | trivial | #534: DOCX indents in characters read and written; `.doc` first-line indents (`sprmPDxaLeft1`, its two-byte operand, and `sprmPDxaLeft1_80`), indents in characters (`sprmPDxc*`) and exact/at-least line spacing (LSPD) read correctly | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 7c4fcb6..fca0490 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -75,9 +75,13 @@ font can't match Word's metrics. | Date | Change | Summary | |---|---|---| +<<<<<<< HEAD | 2026-10-12 | trivial | Spaces, tabs and ideographic spaces don't count toward a line's height, as in Word (a line of only them takes the paragraph mark's) | | 2026-10-12 | trivial | A list label's font raises the first line's ascent but never its descent (Word keeps the text's descent below the baseline) | | 2026-10-12 | trivial | Hidden end-of-cell mark (`w:hideMark`): a cell's empty last paragraph takes no room, spacing and all; a row of only such empty cells is just its borders | +======= +| 2026-10-12 | trivial | Text frames in headers, footers, cells and text boxes are placed like floating objects with the text wrapping around them; body frames still lay out in line | +>>>>>>> 79a65d8 (Text frames in headers, footers and cells, kept on save) | 2026-10-11 | trivial | Chinese documents (#534): indents in characters, a page-anchored floating table after a full page moves to the next page, no blank page from a page break at the bottom of a full page | | 2026-10-11 | trivial | East Asian list number formats (一、二、三, 第一章, ①); list labels whose characters come from two fonts draw each part with its own font (the digits of "1、" no longer show as other letters); a list level without indents leaves the label at the paragraph edge (#534) | | 2026-10-11 | trivial | Vertical sections: `w:textDirection` in `w:sectPr`, upright East Asian glyphs (#491) |