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) <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-12 09:52:26 -04:00 committed by GitHub
parent 74548cff57
commit 5aa8151a53
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 231 additions and 1 deletions

View File

@ -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) |
|---|---|

View File

@ -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<f32>,
/// Height, with `h_exact` exact rather than at least; `None` fits the text.
pub h: Option<f32>,
pub h_exact: bool,
pub x: f32,
pub y: f32,
/// `left`, `center`, `right`, `inside`, `outside`.
pub x_align: Option<String>,
/// `top`, `center`, `bottom`, `inside`, `outside` (`inline` is no alignment).
pub y_align: Option<String>,
/// `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<u8>,
/// 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<TextFrame>,
/// 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 {

View File

@ -153,6 +153,9 @@ pub struct ResolvedPara {
pub borders: Option<Borders>,
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<crate::props::TextFrame>,
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),

View File

@ -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" => {

View File

@ -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 {

View File

@ -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#"<w:p><w:pPr><w:framePr w:wrap="around" w:vAnchor="text" w:hAnchor="margin" w:xAlign="center" w:y="1"/></w:pPr><w:r><w:t>3</w:t></w:r></w:p>
<w:p><w:pPr><w:framePr w:w="2880" w:h="720" w:hRule="exact" w:hSpace="180" w:wrap="notBeside" w:hAnchor="page" w:vAnchor="page" w:x="1440" w:y="2880"/></w:pPr><w:r><w:t>boxed</w:t></w:r></w:p>
<w:p><w:pPr><w:framePr w:wrap="sideways" w:hAnchor="nowhere" w:w="-5" w:x="NaN"/></w:pPr><w:r><w:t>junk</w:t></w:r></w:p>
<w:p><w:pPr><w:framePr w:dropCap="drop" w:lines="2" w:wrap="around" w:vAnchor="text" w:hAnchor="text"/></w:pPr><w:r><w:t>D</w:t></w:r></w:p>
<w:p><w:r><w:t>rop cap text</w:t></w:r></w:p>"#;
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()));
}

View File

@ -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),

View File

@ -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).

View File

@ -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);
}

View File

@ -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 |

View File

@ -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) |