diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index eab7959..5820575 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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, + /// Above the rest of a note continued from the page before. + pub continuation_separator: Option, + /// Below the part of a note that continues on the next page. + pub continuation_notice: Option, +} + +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, /// Custom document properties, in file order. pub custom_props: Vec, + /// 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>>, @@ -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(), } diff --git a/crates/doc/src/para.rs b/crates/doc/src/para.rs index 663714d..9fd05d4 100644 --- a/crates/doc/src/para.rs +++ b/crates/doc/src/para.rs @@ -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!("<{el}/>"), text: String::new() } + } + /// For a [`InlineObject::note_separator`]: whether it is the continuation separator. + pub fn as_note_separator(&self) -> Option { + match self { + InlineObject::Opaque { format, xml, .. } if format == "docx" => match xml.as_str() { + "" => Some(false), + "" => Some(true), + _ => None, + }, + _ => None, + } + } /// The text this object contributes to plain-text extraction. pub fn plain_text(&self) -> &str { match self { diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index 7ab95c7..01d8443 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -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, pub comments_ended: HashSet, pub bookmarks: HashMap, @@ -103,6 +106,7 @@ pub(crate) fn read_package(bytes: &[u8]) -> Result { 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); diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index 193a36b..335030c 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -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) diff --git a/crates/docx/src/write/mod.rs b/crates/docx/src/write/mod.rs index a48ddee..270518b 100644 --- a/crates/docx/src/write/mod.rs +++ b/crates/docx/src/write/mod.rs @@ -195,30 +195,51 @@ pub fn write_as(doc: &Document, flavor: Flavor) -> Result, 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, 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 { @@ -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 { 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 { 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); } diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index 389c8f3..1cc418d 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -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#"More: (continued) The note."# + ); + let body = r#"Text"#; + 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| 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![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![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#""#)), "{kind}: {notes}"); + } + assert!(notes.contains(r#""#), "the note follows the notice: {notes}"); + assert!(part("word/document.xml").contains(r#""#)); + let settings = part("word/settings.xml"); + assert!(settings.contains(r#""#), "{settings}"); + check(&wordcraft_docx::read(&out).unwrap()); +} diff --git a/crates/layout/src/hit.rs b/crates/layout/src/hit.rs index adae67d..db8fab9 100644 --- a/crates/layout/src/hit.rs +++ b/crates/layout/src/hit.rs @@ -114,7 +114,7 @@ fn objects_in(items: &[Placed], index: usize) -> impl Iterator fn text_at(p: &Page, index: usize, x: f32, y: f32) -> Option { let boxes: Vec = 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) } diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index b557aa0..d7dbe47 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -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, @@ -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, f32); + +/// The document's own footnote or endnote separators and continuation notice, laid out (`None`: +/// the defaults). +#[derive(Clone, Default)] +struct NoteSeps { + sep: Option, + cont: Option, + notice: Option, +} + +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.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 { + 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::() } + match self.notes.first() { + None => 0.0, + Some(first) => self.seps.sep_h(first.cont) + self.notes.iter().map(|n| n.h).sum::(), + } } /// 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::() - + 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::() - - 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, diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 316c0da..90fc02b 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -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 }; diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 9429935..f6f386c 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -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. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 3b83be0..76cf07a 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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) | diff --git a/docs/gaps.md b/docs/gaps.md index 2579f54..179a78a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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 | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 1d853fd..af1bca3 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -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) |