Documents keep their own footnote separators and continuation notice (#361)

* Keep and draw a document's own footnote separators and continuation notice (#352)

The docx reader used to drop the special footnotes and endnotes (w:type
separator, continuationSeparator, continuationNotice), so a document's own
separator lines and continuation notice were lost on screen, in PDF and on
save. They are now kept on the document as paragraphs, with the separator
lines themselves as run objects.

Layout draws the document's separator above a page's footnotes and its
continuation separator above notes continued from the page before: the
separator objects become a short line or one across the column, and any
other text or formatting is shown as given. The continuation notice goes
under the part of a note that continues on the next page. The endnote
separator is used above the endnotes. Documents without their own keep the
default lines as before. The separators and notice can't be clicked into.

On save the special notes are written back with Word's ids -1, 0 and 1 (the
notes follow them, starting at 2 when there is a notice) and listed in the
settings' footnotePr/endnotePr. Documents without them keep the default
output.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Merge fix: NoteSeps is Clone (the layout context that holds it is cloned)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Valentin Klein 2026-10-11 19:53:08 +02:00 committed by GitHub
parent 72e59f9f0b
commit c010b73428
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 392 additions and 46 deletions

View File

@ -221,6 +221,29 @@ pub enum PartKind {
TextBox,
}
/// A document's own separators and continuation notice for its footnotes or its endnotes
/// (ECMA-376 §17.11: the `w:footnote`/`w:endnote` entries of type `separator`,
/// `continuationSeparator` and `continuationNotice`). `None` = the default: a short line above
/// the notes, a line across the column above notes continued from the page before, no notice.
/// The lines themselves are [`InlineObject::note_separator`] objects in the paragraphs.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct NoteSeparators {
/// Above the notes on a page.
pub separator: Option<Blocks>,
/// Above the rest of a note continued from the page before.
pub continuation_separator: Option<Blocks>,
/// Below the part of a note that continues on the next page.
pub continuation_notice: Option<Blocks>,
}
impl NoteSeparators {
/// No separator of the document's own: the defaults apply.
pub fn is_empty(&self) -> bool {
self.separator.is_none() && self.continuation_separator.is_none() && self.continuation_notice.is_none()
}
}
/// A secondary story.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
pub struct Part {
@ -423,6 +446,12 @@ pub struct Document {
pub sources: Vec<Source>,
/// Custom document properties, in file order.
pub custom_props: Vec<CustomProp>,
/// The document's own footnote separators and continuation notice.
#[serde(skip_serializing_if = "NoteSeparators::is_empty")]
pub footnote_separators: NoteSeparators,
/// The document's own endnote separators and continuation notice.
#[serde(skip_serializing_if = "NoteSeparators::is_empty")]
pub endnote_separators: NoteSeparators,
/// Embedded media (images) by key.
#[serde(skip)]
pub media: BTreeMap<String, Arc<Vec<u8>>>,
@ -452,6 +481,8 @@ impl Document {
core: CoreProps::default(),
sources: Vec::new(),
custom_props: Vec::new(),
footnote_separators: NoteSeparators::default(),
endnote_separators: NoteSeparators::default(),
media: BTreeMap::new(),
passthrough: BTreeMap::new(),
}

View File

@ -623,6 +623,24 @@ impl InlineObject {
_ => None,
}
}
/// The line a footnote or endnote separator draws (ECMA-376 §17.11: the `w:separator` and
/// `w:continuationSeparator` run elements): a short line, or with `continuation` one across
/// the column. Kept as the run element itself.
pub fn note_separator(continuation: bool) -> InlineObject {
let el = if continuation { "w:continuationSeparator" } else { "w:separator" };
InlineObject::Opaque { format: "docx".into(), xml: format!("<w:r><{el}/></w:r>"), text: String::new() }
}
/// For a [`InlineObject::note_separator`]: whether it is the continuation separator.
pub fn as_note_separator(&self) -> Option<bool> {
match self {
InlineObject::Opaque { format, xml, .. } if format == "docx" => match xml.as_str() {
"<w:r><w:separator/></w:r>" => Some(false),
"<w:r><w:continuationSeparator/></w:r>" => Some(true),
_ => None,
},
_ => None,
}
}
/// The text this object contributes to plain-text extraction.
pub fn plain_text(&self) -> &str {
match self {

View File

@ -19,7 +19,7 @@ use wordcraft_doc::para::OBJ;
use wordcraft_doc::props::Rgb;
use wordcraft_doc::section::NumFormat;
use wordcraft_doc::styles::{Style, StyleKind, StyleSheet, TableStyleParts};
use wordcraft_doc::{Block, Blocks, Comment, Document, InlineObject, PartKind, Revision, RevisionKind};
use wordcraft_doc::{Block, Blocks, Comment, Document, InlineObject, Paragraph, PartKind, Revision, RevisionKind, para_block};
use crate::DocxError;
use crate::package::{ContentTypes, EMBEDDED_PARTS, EmbeddedManifest, MAX_VBA_RELATED, Package, Rels, VBA_PROJECT_PART, VBA_RELATED, rel_is, rt};
@ -46,6 +46,9 @@ pub(crate) struct Reader<'p> {
/// The note being read (kind, part id): its `w:footnoteRef` / `w:endnoteRef` mark is a
/// reference to itself.
current_note: Option<(NoteKind, u32)>,
/// Reading a footnote or endnote separator or continuation notice (where the `w:separator`
/// and `w:continuationSeparator` run elements belong).
in_note_sep: bool,
pub comment_map: HashMap<String, u32>,
pub comments_ended: HashSet<u32>,
pub bookmarks: HashMap<String, String>,
@ -103,6 +106,7 @@ pub(crate) fn read_package(bytes: &[u8]) -> Result<Document, DocxError> {
footnotes: HashMap::new(),
endnotes: HashMap::new(),
current_note: None,
in_note_sep: false,
comment_map: HashMap::new(),
comments_ended: HashSet::new(),
bookmarks: HashMap::new(),
@ -717,8 +721,13 @@ impl Reader<'_> {
let rels = self.pkg.rels(path);
let tag = if foot { "w:footnote" } else { "w:endnote" };
for n in root.children(tag) {
if n.attr("w:type").is_some_and(|t| t != "normal") {
continue;
match n.attr("w:type") {
None | Some("normal") => {}
Some(t @ ("separator" | "continuationSeparator" | "continuationNotice")) => {
self.read_note_sep(n, &rels, foot, t);
continue;
}
Some(_) => continue,
}
let Some(fid) = n.attr("w:id").and_then(int) else { continue };
if self.doc.parts.len() >= MAX_PARTS {
@ -747,6 +756,36 @@ impl Reader<'_> {
Ok(())
}
/// A document's own separator, continuation separator or continuation notice (`kind`, the
/// note's `w:type`) for its footnotes or endnotes. The first of each kind counts.
fn read_note_sep(&mut self, n: &El, rels: &Rels, foot: bool, kind: &str) {
let seps = if foot { &self.doc.footnote_separators } else { &self.doc.endnote_separators };
let taken = match kind {
"separator" => seps.separator.is_some(),
"continuationSeparator" => seps.continuation_separator.is_some(),
_ => seps.continuation_notice.is_some(),
};
if taken {
return;
}
let mut sc = StoryCtx::default();
let mut blocks = Blocks::new();
self.in_note_sep = true;
self.read_blocks(&mut sc, n, rels, &mut blocks, 0);
self.flush_pending(&mut sc, &mut blocks);
self.in_note_sep = false;
if blocks.is_empty() {
blocks.push(para_block(Paragraph::new()));
}
let seps = if foot { &mut self.doc.footnote_separators } else { &mut self.doc.endnote_separators };
let slot = match kind {
"separator" => &mut seps.separator,
"continuationSeparator" => &mut seps.continuation_separator,
_ => &mut seps.continuation_notice,
};
*slot = Some(blocks);
}
fn read_comments(&mut self, path: &str, ext: Option<&str>) -> Result<(), DocxError> {
let Some(root) = self.xml(path)? else { return Ok(()) };
let rels = self.pkg.rels(path);

View File

@ -546,6 +546,10 @@ impl Reader<'_> {
*note = Some((kind, id, None));
}
}
// The lines of a note separator (only there).
"w:separator" | "w:continuationSeparator" if self.in_note_sep => {
self.emit_obj(sc, pb, InlineObject::note_separator(k.name == "w:continuationSeparator"), props);
}
"w:commentReference" => {
if let Some(cid) = k.attr("w:id").and_then(|i| self.comment_id(i))
&& !self.comments_ended.contains(&cid)

View File

@ -195,30 +195,51 @@ pub fn write_as(doc: &Document, flavor: Flavor) -> Result<Vec<u8>, DocxError> {
// Notes (references were collected from the body and headers).
for foot in [true, false] {
let ids = if foot { wr.footnotes.clone() } else { wr.endnotes.clone() };
if ids.is_empty() {
let seps = if foot { &doc.footnote_separators } else { &doc.endnote_separators };
if !notes_part(doc, &wr, foot) {
continue;
}
let (root, tag, file) = if foot { ("w:footnotes", "w:footnote", "footnotes.xml") } else { ("w:endnotes", "w:endnote", "endnotes.xml") };
let mut prels = PartRels::default();
let mut w = W::new();
w.open(root, &ns_refs);
for (id, kind) in [("-1", "separator"), ("0", "continuationSeparator")] {
w.open(tag, &[("w:type", kind), ("w:id", id)]);
w.open("w:p", &[]);
w.open("w:pPr", &[]);
w.empty("w:spacing", &[("w:after", "0"), ("w:line", "240"), ("w:lineRule", "auto")]);
w.close("w:pPr");
w.open("w:r", &[]);
w.empty(if kind == "separator" { "w:separator" } else { "w:continuationSeparator" }, &[]);
w.close("w:r");
w.close("w:p");
w.close(tag);
// The separators and continuation notice: the document's own, else the defaults (no
// notice). Word numbers them -1, 0 and 1.
for (id, kind, own) in [
("-1", "separator", &seps.separator),
("0", "continuationSeparator", &seps.continuation_separator),
("1", "continuationNotice", &seps.continuation_notice),
] {
match own {
Some(blocks) => {
w.open(tag, &[("w:type", kind), ("w:id", id)]);
if blocks.is_empty() {
w.empty("w:p", &[]);
} else {
wr.blocks(&mut w, blocks, &mut prels, false, 0);
}
w.close(tag);
}
None if kind != "continuationNotice" => {
w.open(tag, &[("w:type", kind), ("w:id", id)]);
w.open("w:p", &[]);
w.open("w:pPr", &[]);
w.empty("w:spacing", &[("w:after", "0"), ("w:line", "240"), ("w:lineRule", "auto")]);
w.close("w:pPr");
w.open("w:r", &[]);
w.empty(if kind == "separator" { "w:separator" } else { "w:continuationSeparator" }, &[]);
w.close("w:r");
w.close("w:p");
w.close(tag);
}
None => {}
}
}
let base = note_id_base(doc, foot);
let mut k = 0;
while let Some(pid) = (if foot { &wr.footnotes } else { &wr.endnotes }).get(k).copied() {
let nid = (k + base).to_string();
k += 1;
let nid = k.to_string();
w.open(tag, &[("w:id", &nid)]);
let blocks: Blocks = doc.parts.get(&pid).map(|p| p.blocks.clone()).unwrap_or_default();
wr.note_blocks(&mut w, &blocks, &mut prels, foot, pid);
@ -301,7 +322,7 @@ pub fn write_as(doc: &Document, flavor: Flavor) -> Result<Vec<u8>, DocxError> {
&mut entries,
&mut overrides,
"word/settings.xml",
settings_xml(doc, !wr.footnotes.is_empty(), !wr.endnotes.is_empty()),
settings_xml(doc, notes_part(doc, &wr, true), notes_part(doc, &wr, false)),
&format!("{CT_WML}settings+xml"),
PartRels::default(),
);
@ -495,6 +516,7 @@ impl Writer<'_> {
}
fn note_id(&mut self, part: u32, foot: bool) -> String {
let base = note_id_base(self.doc, foot);
let list = if foot { &mut self.footnotes } else { &mut self.endnotes };
let idx = match list.iter().position(|p| *p == part) {
Some(i) => i,
@ -503,7 +525,7 @@ impl Writer<'_> {
list.len() - 1
}
};
(idx + 1).to_string()
(idx + base).to_string()
}
fn hf_rel(&mut self, part: u32, footer: bool, rels: &mut PartRels) -> Option<String> {
@ -780,8 +802,23 @@ fn write_level(w: &mut W, i: usize, l: &Level) {
w.close("w:lvl");
}
/// The file id of the first footnote or endnote: after the separators (-1, 0) and the
/// continuation notice (1), when the document has one.
fn note_id_base(doc: &Document, foot: bool) -> usize {
let seps = if foot { &doc.footnote_separators } else { &doc.endnote_separators };
if seps.continuation_notice.is_some() { 2 } else { 1 }
}
/// Whether the footnotes (or endnotes) part is written: there are notes, or separators of the
/// document's own to keep.
fn notes_part(doc: &Document, wr: &Writer<'_>, foot: bool) -> bool {
let (ids, seps) = if foot { (&wr.footnotes, &doc.footnote_separators) } else { (&wr.endnotes, &doc.endnote_separators) };
!ids.is_empty() || !seps.is_empty()
}
fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool) -> Vec<u8> {
let s = &doc.settings;
let s_notes = (&doc.footnote_separators, &doc.endnote_separators);
let mut w = W::new();
let ns = xml::body_ns();
let ns_refs: Vec<(&str, &str)> = ns.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
@ -820,6 +857,10 @@ fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool) -> Vec<u8> {
if used {
w.empty(el, &[("w:id", "-1")]);
w.empty(el, &[("w:id", "0")]);
let seps = if el == "w:footnote" { &s_notes.0 } else { &s_notes.1 };
if seps.continuation_notice.is_some() {
w.empty(el, &[("w:id", "1")]);
}
}
w.close(tag);
}

View File

@ -1078,3 +1078,51 @@ fn formatting_revisions_round_trip() {
assert_eq!(again.last_section, d.last_section);
assert_eq!(again.revisions, d.revisions);
}
/// A document's own footnote separators and continuation notice are kept (ECMA-376 §17.11), and
/// written back as Word numbers them: -1, 0 and 1, the notes after them.
#[test]
fn note_separators_and_continuation_notice_round_trip() {
let footnotes = format!(
r#"<w:footnotes {W_NS}><w:footnote w:type="separator" w:id="-1"><w:p><w:pPr><w:spacing w:after="0"/></w:pPr><w:r><w:separator/></w:r></w:p></w:footnote><w:footnote w:type="continuationSeparator" w:id="0"><w:p><w:r><w:t xml:space="preserve">More: </w:t></w:r><w:r><w:continuationSeparator/></w:r></w:p></w:footnote><w:footnote w:type="continuationNotice" w:id="1"><w:p><w:pPr><w:jc w:val="right"/></w:pPr><w:r><w:t>(continued)</w:t></w:r></w:p></w:footnote><w:footnote w:id="2"><w:p><w:r><w:footnoteRef/></w:r><w:r><w:t xml:space="preserve"> The note.</w:t></w:r></w:p></w:footnote></w:footnotes>"#
);
let body = r#"<w:p><w:r><w:t>Text</w:t></w:r><w:r><w:footnoteReference w:id="2"/></w:r></w:p>"#;
let bytes = docx(body, &[("rId1", "footnotes", "footnotes.xml")], &[("word/footnotes.xml", &footnotes)]);
let check = |d: &Document| {
let s = &d.footnote_separators;
let first = |b: &Option<wordcraft_doc::Blocks>| b.as_ref().unwrap()[0].as_para().unwrap().clone();
let sep = first(&s.separator);
assert_eq!(sep.objects.iter().map(InlineObject::as_note_separator).collect::<Vec<_>>(), vec![Some(false)]);
assert_eq!(sep.props.space_after, Some(0.0));
let cont = first(&s.continuation_separator);
assert_eq!(cont.plain_text(), "More: ");
assert_eq!(cont.objects.iter().map(InlineObject::as_note_separator).collect::<Vec<_>>(), vec![Some(true)]);
let notice = first(&s.continuation_notice);
assert_eq!(notice.plain_text(), "(continued)");
assert_eq!(notice.props.align, Some(Align::Right));
assert!(d.endnote_separators.is_empty());
// The note itself is read as before.
let InlineObject::NoteRef { id, .. } = paras(d)[0].objects[0] else { panic!() };
assert_eq!(d.parts[&id].blocks[0].as_para().unwrap().plain_text(), " The note.");
assert_eq!(d.parts.len(), 1, "special notes aren't notes");
};
let d = wordcraft_docx::read(&bytes).unwrap();
check(&d);
let out = wordcraft_docx::write(&d).unwrap();
let part = |name: &str| {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&out)).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut z.by_name(name).unwrap(), &mut xml).unwrap();
xml
};
let notes = part("word/footnotes.xml");
for (kind, id) in [("separator", "-1"), ("continuationSeparator", "0"), ("continuationNotice", "1")] {
assert!(notes.contains(&format!(r#"<w:footnote w:type="{kind}" w:id="{id}">"#)), "{kind}: {notes}");
}
assert!(notes.contains(r#"<w:footnote w:id="2">"#), "the note follows the notice: {notes}");
assert!(part("word/document.xml").contains(r#"<w:footnoteReference w:id="2"/>"#));
let settings = part("word/settings.xml");
assert!(settings.contains(r#"<w:footnote w:id="-1"/><w:footnote w:id="0"/><w:footnote w:id="1"/>"#), "{settings}");
check(&wordcraft_docx::read(&out).unwrap());
}

View File

@ -114,7 +114,7 @@ fn objects_in(items: &[Placed], index: usize) -> impl Iterator<Item = ObjectHit>
fn text_at(p: &Page, index: usize, x: f32, y: f32) -> Option<StoryRef> {
let boxes: Vec<StoryRef> = objects(p, index).filter_map(|o| o.text_box.map(StoryRef::Part)).collect();
p.items.iter().find_map(|it| match it {
Placed::Lines { story, .. } if boxes.contains(story) => None,
Placed::Lines { story, .. } if boxes.contains(story) || *story == crate::DECOR_STORY => None,
Placed::Lines { story, .. } if it.turned_bounds().is_some() => {
it.turned_bounds().filter(|r| r.expand(2.0).contains(Point::new(x, y))).map(|_| *story)
}

View File

@ -872,6 +872,8 @@ struct PageBuilder<'a> {
line_no: u32,
/// Index of the first body item of the current page (vertical alignment shifts from here).
page_items_start: usize,
/// The document's own footnote separators, laid out for the section's text width.
seps: NoteSeps,
/// Per page: how many items were inserted at the front of its list (behind the text), so a
/// [`Snap`] can take them out again.
behind: Vec<usize>,
@ -889,6 +891,74 @@ struct PageBuilder<'a> {
/// Gap above the footnote separator and its length.
const NOTE_SEP: f32 = 12.0;
/// Length of the default (short) note separator line.
pub(crate) const NOTE_SEP_W: f32 = 144.0;
/// The story of text drawn on a page that belongs to no story of the document (line numbers,
/// note separators and continuation notices): not hit by clicks.
pub const DECOR_STORY: StoryRef = StoryRef::Part(u32::MAX);
/// A note separator or continuation notice laid out from y = 0 (its items and height).
type SepBox = (Vec<Placed>, f32);
/// The document's own footnote or endnote separators and continuation notice, laid out (`None`:
/// the defaults).
#[derive(Clone, Default)]
struct NoteSeps {
sep: Option<SepBox>,
cont: Option<SepBox>,
notice: Option<SepBox>,
}
impl NoteSeps {
/// Lay out `seps` `width` wide.
fn new(ctx: &mut Ctx, seps: &wordcraft_doc::NoteSeparators, width: f32) -> NoteSeps {
let mut lay = |blocks: &Option<Blocks>| blocks.as_ref().map(|b| sep_box(ctx, b, width));
NoteSeps { sep: lay(&seps.separator), cont: lay(&seps.continuation_separator), notice: lay(&seps.continuation_notice) }
}
/// Height of the separator above a page's notes (`cont`: the continuation separator, when the
/// first is continued from the page before).
fn sep_h(&self, cont: bool) -> f32 {
let own = if cont { &self.cont } else { &self.sep };
own.as_ref().map_or(NOTE_SEP, |s| s.1)
}
/// Height of the notice below a note that continues on the next page.
fn notice_h(&self) -> f32 {
self.notice.as_ref().map_or(0.0, |s| s.1)
}
}
/// A note separator or continuation notice story (`blocks`) laid out `width` wide.
fn sep_box(ctx: &mut Ctx, blocks: &Blocks, width: f32) -> SepBox {
let bl = layout_box(ctx, DECOR_STORY, blocks, &[], width, None, 0, None);
let mut items = bl.items;
items.extend(separator_rules(blocks, &items));
(items, bl.height)
}
/// The lines drawn by the note separators (see [`InlineObject::note_separator`]) in a separator
/// story's laid-out `items` (`blocks` its paragraphs): across each one's place in its line, half
/// way down the line.
fn separator_rules(blocks: &Blocks, items: &[Placed]) -> Vec<Placed> {
let mut rules = Vec::new();
for it in items {
let Placed::Lines { para, path, l0, l1, x, y, turn: TextDirection::Horizontal, .. } = it else { continue };
let [i] = path.0.as_slice() else { continue };
let Some(Block::Para(p)) = blocks.get(*i as usize).map(|b| &**b) else { continue };
let Some(first) = para.lines.get(*l0) else { continue };
for line in para.lines.get(*l0..*l1).unwrap_or(&[]) {
for k in line.c0..line.c1 {
let Some(para::ClKind::Object(oi)) = para.clusters.get(k).map(|c| c.kind) else { continue };
if p.objects.get(oi).and_then(InlineObject::as_note_separator).is_none() {
continue;
}
let (Some(a), Some(b)) = (line.cl_left(k), line.cl_right(k)) else { continue };
let ry = y + line.top - first.top + line.height / 2.0;
rules.push(Placed::Rule { x0: x + a.min(b), y0: ry, x1: x + a.max(b), y1: ry, border: Border::single(0.5) });
}
}
}
rules
}
/// The most pages added after the body for footnotes still continuing.
const MAX_NOTE_PAGES: usize = 10_000;
@ -1019,7 +1089,10 @@ impl NoteBox {
impl PageBuilder<'_> {
/// Space footnotes take at the bottom of the page.
fn notes_h(&self) -> f32 {
if self.notes.is_empty() { 0.0 } else { NOTE_SEP + self.notes.iter().map(|n| n.h).sum::<f32>() }
match self.notes.first() {
None => 0.0,
Some(first) => self.seps.sep_h(first.cont) + self.notes.iter().map(|n| n.h).sum::<f32>(),
}
}
/// The lowest bottom of the body text and tables on the current page.
fn body_floor(&self) -> f32 {
@ -1044,11 +1117,13 @@ impl PageBuilder<'_> {
let area = self.bottom.max(self.body_floor());
let fits = total <= self.orig_bottom - area + 0.01;
let top = if fits { self.orig_bottom - total } else { area };
let mut y = top + NOTE_SEP;
let notes = std::mem::take(&mut self.notes);
// A page that starts with a continued note gets the full-width continuation separator.
// A page that starts with a continued note gets the continuation separator.
let cont = notes.first().is_some_and(|n| n.cont);
let mut y = top + self.seps.sep_h(cont);
let mut placed = Vec::new();
// Where the continuation notice goes, below a note that continues on the next page.
let mut notice_y = None;
let mut rest = notes.into_iter();
while let Some(n) = rest.next() {
let room = self.orig_bottom - y;
@ -1058,13 +1133,16 @@ impl PageBuilder<'_> {
placed.push((n, ny));
continue;
}
// Split at the last line that fits. The page's first note always leaves something
// here (its first line, or all of it if it can't be split), so continuing ends.
// Split at the last line that fits with the notice below it. The page's first note
// always leaves something here (its first line, or all of it if it can't be split),
// so continuing ends.
let first = placed.is_empty();
let cuts = n.cuts();
let room = room - self.seps.notice_h();
match cuts.iter().rev().find(|&&c| c <= room + 0.01).or(cuts.first().filter(|_| first)).copied() {
Some(c) => {
let (head, tail) = n.split(c);
notice_y = Some(y + head.h);
placed.push((head, y));
self.carry.push(tail);
}
@ -1075,15 +1153,29 @@ impl PageBuilder<'_> {
break;
}
if let Some(pg) = self.pages.last_mut() {
let len = if cont { self.sect.text_width() } else { 144.0 };
let sy = top + NOTE_SEP / 2.0;
pg.items.push(Placed::Rule { x0: x, y0: sy, x1: x + len, y1: sy, border: Border::single(0.5) });
match if cont { &self.seps.cont } else { &self.seps.sep } {
Some((items, _)) => pg.items.extend(items.iter().cloned().map(|mut it| {
it.translate(x, top);
it
})),
None => {
let len = if cont { self.sect.text_width() } else { NOTE_SEP_W };
let sy = top + NOTE_SEP / 2.0;
pg.items.push(Placed::Rule { x0: x, y0: sy, x1: x + len, y1: sy, border: Border::single(0.5) });
}
}
for (n, ny) in placed {
for mut it in n.items {
it.translate(x, ny);
pg.items.push(it);
}
}
if let (Some(ny), Some((items, _))) = (notice_y, &self.seps.notice) {
pg.items.extend(items.iter().cloned().map(|mut it| {
it.translate(x, ny);
it
}));
}
}
}
/// Section vertical alignment: move the page's body down (centre/bottom).
@ -1276,6 +1368,7 @@ pub fn layout(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions) ->
float_tables: Vec::new(),
line_no: 0,
page_items_start: 0,
seps: NoteSeps::default(),
behind: Vec::new(),
max_y: 0.0,
content_bottom: 0.0,
@ -1289,6 +1382,9 @@ pub fn layout(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions) ->
let sect: &SectionProps = if web { sect_ref } else { sect };
pb.sect = sect;
pb.sect_idx = si;
if !web && !doc.footnote_separators.is_empty() {
pb.seps = NoteSeps::new(&mut ctx, &doc.footnote_separators, sect.text_width());
}
let body_top = if web { sect.margin_top } else { body_top_for(&mut ctx, sect, sect.headers.default) };
let first_top = if web || !sect.title_page { body_top } else { body_top_for(&mut ctx, sect, sect.headers.first) };
let restart = sect.page_num_start;
@ -1361,18 +1457,25 @@ pub fn layout(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions) ->
ids.into_iter().map(|(_, id)| id).collect()
};
if !endnotes.is_empty() && !web {
pb.y += 12.0;
let (x, ry) = (pb.col_x(), pb.y);
if let Some(pg) = pb.page() {
pg.items.push(Placed::Rule {
x0: x,
y0: ry,
x1: x + 144.0,
y1: ry,
border: Border { style: wordcraft_doc::props::BorderStyle::Single, width: 0.5, color: None, space: 0.0 },
});
// The document's own endnote separator, else a short line.
let width = pb.col_w();
if let Some((items, h)) = doc.endnote_separators.separator.as_ref().map(|b| sep_box(&mut ctx, b, width)) {
let (x, y) = (pb.col_x(), pb.y);
if let Some(pg) = pb.page() {
pg.items.extend(items.into_iter().map(|mut it| {
it.translate(x, y);
it
}));
}
pb.y += h;
} else {
pb.y += 12.0;
let (x, ry) = (pb.col_x(), pb.y);
if let Some(pg) = pb.page() {
pg.items.push(Placed::Rule { x0: x, y0: ry, x1: x + NOTE_SEP_W, y1: ry, border: Border::single(0.5) });
}
pb.y += 8.0;
}
pb.y += 8.0;
for id in endnotes {
let Some(part) = doc.parts.get(&id) else { continue };
let blocks = part.blocks.clone();
@ -2003,10 +2106,10 @@ fn place_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: usize,
}
}
let extra: f32 = line_notes.iter().map(|n| n.h).sum::<f32>()
+ if pb.notes.is_empty() && new_notes.is_empty() && !line_notes.is_empty() { NOTE_SEP } else { 0.0 };
+ if pb.notes.is_empty() && new_notes.is_empty() && !line_notes.is_empty() { pb.seps.sep_h(false) } else { 0.0 };
let limit = pb.bottom
- new_notes.iter().map(|n| n.h).sum::<f32>()
- if pb.notes.is_empty() && !new_notes.is_empty() { NOTE_SEP } else { 0.0 }
- if pb.notes.is_empty() && !new_notes.is_empty() { pb.seps.sep_h(false) } else { 0.0 }
- extra;
let bottom = pb.y + lead + (l.top + l.height - first.top);
// Notes too long for the rest of the page continue on the next one: the line still
@ -2015,7 +2118,8 @@ fn place_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: usize,
let split = bottom > limit + 0.01
&& line_notes.split_last().is_some_and(|(last, others)| {
let before: f32 = pb.notes.iter().chain(&new_notes).chain(others).map(|n| n.h).sum();
pb.body_floor().max(bottom) + NOTE_SEP + before + last.first_piece() <= pb.orig_bottom + 0.01
let sep = pb.seps.sep_h(pb.notes.first().is_some_and(|n| n.cont));
pb.body_floor().max(bottom) + sep + before + last.first_piece() + pb.seps.notice_h() <= pb.orig_bottom + 0.01
});
if bottom > limit + 0.01 && l1 > l0 && !split {
break;
@ -2109,7 +2213,7 @@ fn place_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: usize,
let dist = if ln.distance > 0.0 { ln.distance } else { 18.0 };
let ly = y + (line.top - first.top) + (line.baseline - line.top) - num.lines.first().map(|l| l.baseline).unwrap_or(10.0);
items.push(Placed::Lines {
story: StoryRef::Part(u32::MAX),
story: DECOR_STORY,
path: Path(vec![]),
para: num.clone(),
l0: 0,

View File

@ -1047,6 +1047,12 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
}
maths.push((k, Arc::new(ml)));
}
// A note separator's line: a short one, or one across the column.
Some(o) if o.as_note_separator().is_some() => {
let cont = o.as_note_separator() == Some(true);
let maxw = (env.width - rp.indent_left.max(0.0) - rp.indent_right.max(0.0)).max(1.0);
push(&mut b, ClKind::Object(k), if cont { maxw } else { crate::NOTE_SEP_W.min(maxw) }, 0.0);
}
Some(InlineObject::Ruby { base, ruby, align, size, raise, props: ruby_props, .. }) => {
let mut rr = (*resolve(&props.clone().overlaid(ruby_props))).clone();
let size = if size.is_finite() { size.clamp(1.0, 1584.0) } else { rc.size * 0.5 };

View File

@ -3208,6 +3208,58 @@ fn wrap_text_keeps_text_on_one_side() {
assert!(!near_right.is_empty() && near_right.iter().all(|l| l.2 < 1.0 && l.3 <= 259.0 + 0.01), "{near_right:?}");
}
#[test]
fn document_note_separators_and_continuation_notice() {
let mut d = Document::from_text(&"Body text line.\n".repeat(16));
let long = footnote(&mut d, &Pos::body(4, 4), &"A long footnote that runs on and on. ".repeat(100));
let sep = |cont: bool| {
let mut p = wordcraft_doc::Paragraph::new();
p.props.space_after = Some(0.0);
p.insert_object(0, InlineObject::note_separator(cont), &Default::default()).unwrap();
Some(vec![wordcraft_doc::para_block(p)])
};
let mut notice = wordcraft_doc::Paragraph::with_text("(continued)", Default::default());
notice.props.space_after = Some(0.0);
d.footnote_separators = wordcraft_doc::NoteSeparators {
separator: sep(false),
continuation_separator: sep(true),
continuation_notice: Some(vec![wordcraft_doc::para_block(notice)]),
};
let l = lay(&d);
let pieces = note_pieces(&l, long);
assert!(pieces.len() >= 2 && (pieces[0].0, pieces[1].0) == (0, 1), "{pieces:?}");
// The document's separators are paragraphs: their lines are drawn across the separator's
// place, short on page 1 and across the text on page 2.
let decor = |pi: usize| -> Vec<(f32, f32)> {
l.pages[pi]
.items
.iter()
.filter_map(|it| match it {
Placed::Lines { story, y, para, l0, l1, .. } if *story == DECOR_STORY => Some((*y, item_bottom(*y, para, *l0, *l1).unwrap())),
_ => None,
})
.collect()
};
let (d0, d1) = (decor(0), decor(1));
assert_eq!((d0.len(), d1.len()), (2, 1), "separator and notice on page 1, continuation separator on page 2");
assert_eq!(rules_below(&l, 0, 0.0), vec![144.0]);
assert_eq!(rules_below(&l, 1, 0.0), vec![468.0]);
// The notice sits right under the part of the note on page 1, inside the page.
let note_bottom = l.pages[0]
.items
.iter()
.filter_map(|it| match it {
Placed::Lines { story: StoryRef::Part(s), y, para, l0, l1, .. } if *s == long => item_bottom(*y, para, *l0, *l1),
_ => None,
})
.fold(0.0f32, f32::max);
let (ny, nb) = d0[1];
assert!((ny - note_bottom).abs() < 0.01, "notice at {ny}, note ends at {note_bottom}");
assert!(nb <= 720.01, "notice inside the page: {nb}");
// The separators and notice aren't text anyone clicks into.
assert_ne!(l.story_at(0, 80.0, ny + 2.0), Some(DECOR_STORY));
}
/// #344 with #332: a Drawing Canvas turns as a whole, like a group: its background and outline,
/// then its members clipped to its frame, all drawn inside its turn (the clip in the unturned
/// frame, so the clip turns too); floating or inline. Hit testing follows the turned canvas.

View File

@ -49,7 +49,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
| Text, runs, paragraph and character properties, styles, numbering, sections | ✅ | ✅ | Including complex-script props (`w:rtl`, `w:cs`, `w:szCs`, `w:bCs`…), East Asian fonts kept apart from Latin (#111) |
| Tables incl. floating (`w:tblpPr`), table styles and conditional formatting | ✅ | ✅ | Custom table styles round-trip (#256) |
| Headers/footers (first, even/odd), page borders, line numbers, gutter, mirror margins | ✅ | ✅ | |
| Footnotes, endnotes, comments | ✅ | ✅ | `commentsExtended` written; `commentsIds`, modern threaded comments (`w16cex`) partly |
| Footnotes, endnotes, comments | ✅ | ✅ | The document's own note separators and continuation notice (`w:type` `separator`, `continuationSeparator`, `continuationNotice`) round-trip as ids -1, 0 and 1 (#352); `commentsExtended` written; `commentsIds`, modern threaded comments (`w16cex`) partly |
| Tracked insertions and deletions (`w:ins`/`w:del`), paragraph-mark revisions | ✅ | ✅ | #125, #244 |
| Formatting revisions (`w:rPrChange`, `w:pPrChange`, `w:sectPrChange`, `w:tblPrChange`, `w:trPrChange`, `w:tcPrChange`, `w:numberingChange`) | ✅ | ✅ | #41: kept on open and save, schema order (change element last); changes inside `styles.xml` are dropped. RTF/ODT ignore them |
| Move tracking (`w:moveFrom`/`w:moveTo`) | 🟡 | ❌ | Read as plain insert/delete |
@ -92,6 +92,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
| 2026-10-11 | trivial | Formatting revisions (`w:rPrChange`, `w:pPrChange`, table, row, cell, section and numbering changes) read and written (#41) |
| 2026-10-10 | trivial | OLE objects turned or flipped in WordCraft keep the turn on save (VML `rotation`/`flip`, DrawingML `a:xfrm` with the rotated effect extent); test counts refreshed |
| 2026-10-10 | trivial | Charts, SmartArt and OLE objects written back on save with their parts (#319); chart/SmartArt reading (#292) recorded |
| 2026-10-10 | trivial | Footnote and endnote separators and continuation notices read and written (#352) |
| 2026-10-11 | trivial | Enclosed characters (`EQ \o\ac`) read, drawn and written (#297) |
| 2026-10-11 | trivial | Ruby (`w:ruby`) read and written (#288) |
| 2026-10-10 | trivial | `wrapText` on square, tight and through wraps read and written (#354) |

View File

@ -14,7 +14,7 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes.
| 1 | **DOCX never tested on real-world files.** No corpus of files from Word, Google Docs, LibreOffice and Pages has been opened, rendered and round-tripped and compared with Word page by page | B | No corpus in the repo; `crates/docx/tests` are synthetic fixtures; "DOCX fidelity corpus: not started" since 2026-10-06 | Everyone: the files people receive are the files that break | 30–50 | [file-format-parity.md](file-format-parity.md) |
| 2 | **Ink and drawing canvases are dropped on open and lost on save, silently**; OLE objects show only their picture | B | No ink or drawing-canvas (`wpc`) handling in `crates/docx`; group shapes (`wpg`) read and written (#267). Charts and SmartArt are drawn (#292) and, with OLE objects, written back with all their parts on save (#319) | Documents with diagrams drawn in Word or pen notes lose content without warning | 4–8 to preserve ink and drawing canvases; 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) |
| 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170) (lag on GPUs without a DirectX 12 driver fixed, #316), freezes (#77, #59 Thai fonts, #31 font selector), no writes without admin on Windows 11 (#218), broken shape tool (#142), images can't be moved (#82) | B | 62 open issues, ~15 of them crashes, hangs or data-path failures | Anyone hit loses work or trust; the never-crash standard covers panics, not hangs or GPU driver faults | 20–30 | [target-app-parity.md](target-app-parity.md) (Stability) |
| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; custom footnote continuation separators and notices aren't read; legacy compatibility options ignored; no page-by-page comparison harness | B | [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) |
| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options ignored; no page-by-page comparison harness | B | [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) |
| 5 | **Move tracking and content controls** are lost (`w:moveFrom`/`w:moveTo` read as insert/delete; `w:sdt` unwrapped). Formatting revisions landed (#41): `w:rPrChange`/`w:pPrChange`/`w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange`/`w:numberingChange` round-trip, are recorded while tracking, accepted/rejected and shown in balloons; still missing: tracked style-definition changes and table/section changes made in WordCraft | B | Issue #41; source grep | Legal and editorial review workflows; templates with form controls | 8–12 | [file-format-parity.md](file-format-parity.md) |
| 6 | **No native printing** on desktop: File › Print opens the PDF in the system viewer, which prints (#286); no print dialog of our own (printer, copies, ranges); web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) |
| 7 | **Dialog depth**: 35 modal dialogs vs Word's ~100 (Caption, Index, TOC options, Citation, Compare, Protect, Language, Hyphenation, Line Numbers, Envelopes, Labels, AutoCorrect, Options panes…) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs` | Power users reach for dialogs the ribbon alone doesn't cover | 20–30 | [ui-parity.md](ui-parity.md) |
@ -78,6 +78,7 @@ The same gaps grouped the way the parity documents are, for agents working in on
| 2026-10-10 | trivial | Tabs and Borders and Shading dialogs landed (#320); removed from the dialog-depth list |
| 2026-10-10 | trivial | Columns, Symbol and Field dialogs landed (#321); removed from the dialog-depth list |
| 2026-10-10 | trivial | #2 narrowed: charts and SmartArt drawn (#292), and with OLE objects kept on save (#319); group shapes landed (#267); ink and drawing canvases remain |
| 2026-10-10 | trivial | #4: a document's own footnote separators and continuation notice are drawn and kept on save (#352) |
| 2026-10-10 | trivial | #4: long footnotes continue onto the next page (#352) |
| 2026-10-10 | trivial | Define New Multilevel List and Track Changes Options dialogs landed (#328); removed from the dialog-depth list |
| 2026-10-11 | trivial | Enclose Characters landed (#297); removed from the East Asian typography gap |

View File

@ -31,7 +31,7 @@ font can't match Word's metrics.
| Columns, column breaks, separators, balancing | ✅ | Columns balance on a section's last page when the next section starts continuous (also at the document end, which Word only balances when the document ends with a continuous break); not after next-page/even/odd breaks or on a page with a column break; keep-together, keep-with-next, widow/orphan control and unsplittable rows can leave them slightly uneven (#350) | — |
| Section breaks (next page, continuous, even, odd), different first page, odd/even headers | ✅ | Section breaks don't show in Draft view (#42) | 1–2 |
| Body top below a tall header, footer pushes body up | ✅ | #138 | — |
| Footnotes at the page bottom, endnotes at the end, note numbering | 🟡 | A long note continues onto the next page at a line boundary (its first line stays with the reference), first in that page's note area under a full-width continuation separator (#352); the document's own `w:continuationSeparator`/`w:continuationNotice` aren't read (default line, no notice); per-section restart partial | 2–4 |
| Footnotes at the page bottom, endnotes at the end, note numbering | 🟡 | A long note continues onto the next page at a line boundary (its first line stays with the reference), first in that page's note area under a full-width continuation separator (#352); the document's own separator, continuation separator and continuation notice are drawn (the notice under a note that continues; endnotes use the document's separator but don't continue onto a page of their own); per-section restart partial | 1–3 |
| Tables: row heights (at least/exact), rows split across pages, header rows repeat, Can't Split | ✅ | 2026-10-06, #138 | — |
| Tables: autofit to contents/window, fixed widths, Word 2013 edge | 🟡 | #137; #44: AutoFit Contents measures each column's narrowest and widest text and shares the width like Word, once (not live as you type); cell preferred widths and spans still differ | 3–5 |
| Floating tables (`w:tblpPr`) | 🟡 | #137; overlap rules untested | 2–3 |
@ -68,6 +68,7 @@ font can't match Word's metrics.
| Date | Change | Summary |
|---|---|---|
| 2026-10-10 | trivial | The document's own note separators and continuation notice are drawn (#352) |
| 2026-10-11 | trivial | Newspaper columns balance before a continuous section break (#350) |
| 2026-10-11 | trivial | Group and Ungroup keep turned members' orientation and placement (#441, #443); Size resizes a group (#442); the eraser hits turned ink where it is drawn (#445) |
| 2026-10-10 | trivial | Long footnotes continue onto the next page with a continuation separator (#352) |