Fixes: footnotes in table cells, NUMPAGES, bookmark link pages, bookmark around final table, INCLUDEPICTURE results (#450, #451, #455, #464, #465) (#483)
- layout: footnotes referenced in table-cell lines join the page's note area (#450) - layout: NUMPAGES/SECTIONPAGES in the body use the real totals; the body is laid out again with the totals found (at most three extra passes); SECTION in the body counts sections (#451) - pdf: internal link destinations use the page and line of the bookmark's start (#455) - docx: markers left after a final table go to its last cell's last paragraph (#464) - docx: a field whose cached result holds a picture is kept as a range field around it, so the picture survives save; the linked path is never read (#465) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
parent
968c66a13e
commit
35e22e2cef
|
|
@ -85,6 +85,16 @@ fn is_range_instr(instr: &str) -> bool {
|
|||
instr.trim_start().get(..5).is_some_and(|k| k.eq_ignore_ascii_case("ADDIN"))
|
||||
}
|
||||
|
||||
/// The last paragraph of a block list: its last block, or inside a final table the last
|
||||
/// paragraph of its last cell (nested tables to [`MAX_TABLE_DEPTH`]).
|
||||
fn last_para_mut(out: &mut Blocks, depth: usize) -> Option<&mut Paragraph> {
|
||||
match Arc::make_mut(out.last_mut()?) {
|
||||
Block::Para(p) => Some(p),
|
||||
Block::Table(t) if depth < MAX_TABLE_DEPTH => last_para_mut(&mut t.rows.last_mut()?.cells.last_mut()?.blocks, depth + 1),
|
||||
Block::Table(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The field that buffers content now: the innermost one that isn't a range field, unless it
|
||||
/// has spilled (then content goes straight into the paragraph).
|
||||
fn sink(sc: &mut StoryCtx) -> Option<&mut FieldState> {
|
||||
|
|
@ -250,12 +260,13 @@ impl Reader<'_> {
|
|||
out.extend(inner);
|
||||
}
|
||||
|
||||
/// Markers left over at the end of a story go to its last paragraph.
|
||||
/// Markers left over at the end of a story go to its last paragraph (after a final table:
|
||||
/// the last paragraph of its last cell, so a bookmark around the table keeps its end).
|
||||
pub fn flush_pending(&mut self, sc: &mut StoryCtx, out: &mut Blocks) {
|
||||
if sc.pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(Block::Para(p)) = out.last_mut().map(Arc::make_mut) {
|
||||
if let Some(p) = last_para_mut(out, 0) {
|
||||
for o in sc.pending.drain(..) {
|
||||
let props = p.runs.last().map(|r| r.props.clone()).unwrap_or_default();
|
||||
p.text.push(wordcraft_doc::para::OBJ);
|
||||
|
|
@ -768,6 +779,20 @@ impl Reader<'_> {
|
|||
self.emit_obj(sc, pb, InlineObject::FieldEnd, &f.props);
|
||||
return;
|
||||
}
|
||||
// A cached result holding a picture, chart or shape (`INCLUDEPICTURE`'s embedded
|
||||
// picture) can't be a text result: keep the field as a range around it so the picture
|
||||
// stays in the document and is saved again. The linked source is never read.
|
||||
if !nested_in_instr && f.buf.iter().any(|(it, _)| matches!(it, Item::Obj(o) if o.frame().is_some())) {
|
||||
self.emit_obj(sc, pb, InlineObject::FieldStart { instr: f.instr.trim().to_string(), locked: f.locked }, &f.props);
|
||||
for (it, p) in f.buf {
|
||||
match it {
|
||||
Item::Text(t) => self.emit_text(sc, pb, &t, &p),
|
||||
Item::Obj(o) => self.emit_obj(sc, pb, o, &p),
|
||||
}
|
||||
}
|
||||
self.emit_obj(sc, pb, InlineObject::FieldEnd, &f.props);
|
||||
return;
|
||||
}
|
||||
let mut result = String::new();
|
||||
for (it, _) in &f.buf {
|
||||
match it {
|
||||
|
|
|
|||
|
|
@ -1174,3 +1174,66 @@ fn note_separators_and_continuation_notice_round_trip() {
|
|||
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());
|
||||
}
|
||||
|
||||
/// #464: a bookmark whose end follows the document's final table keeps its end (it goes into
|
||||
/// the table's last cell) and is saved as a matched pair.
|
||||
#[test]
|
||||
fn bookmark_around_final_table_keeps_its_end() {
|
||||
let body = r#"<w:bookmarkStart w:id="7" w:name="TableRange"/><w:tbl><w:tblPr/><w:tblGrid><w:gridCol w:w="1440"/></w:tblGrid><w:tr><w:tc><w:tcPr/><w:p><w:r><w:t>Cell</w:t></w:r></w:p></w:tc></w:tr></w:tbl><w:bookmarkEnd w:id="7"/><w:sectPr/>"#;
|
||||
let d = read_body(body);
|
||||
let marks = |d: &Document| {
|
||||
let Block::Table(t) = &*d.body[0] else { panic!("{:?}", d.body[0]) };
|
||||
t.rows[0].cells[0].blocks[0].as_para().unwrap().objects.clone()
|
||||
};
|
||||
let want = vec![InlineObject::BookmarkStart { name: "TableRange".into() }, InlineObject::BookmarkEnd { name: "TableRange".into() }];
|
||||
assert_eq!(marks(&d), want);
|
||||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert_eq!(marks(&again), want);
|
||||
assert_eq!(again.bookmarks().len(), 1);
|
||||
}
|
||||
|
||||
/// #465: a field whose cached result is an embedded picture (`INCLUDEPICTURE`, simple, complex or
|
||||
/// locked) keeps the picture through reading and saving; the linked file is never read.
|
||||
#[test]
|
||||
fn includepicture_keeps_its_embedded_picture() {
|
||||
let png: &[u8] = b"\x89PNG\r\n\x1a\nstand-in";
|
||||
let pic = r#"<w:r><w:drawing><wp:inline><wp:extent cx="914400" cy="914400"/><wp:docPr id="1" name="P"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:blipFill><a:blip r:embed="rIdPic"/></pic:blipFill></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#;
|
||||
let cases = [
|
||||
(format!(r#"<w:fldSimple w:instr="INCLUDEPICTURE "picture.png"">{pic}</w:fldSimple>"#), false),
|
||||
(format!(r#"<w:fldSimple w:instr="INCLUDEPICTURE "picture.png"" w:fldLock="1">{pic}</w:fldSimple>"#), true),
|
||||
(
|
||||
format!(
|
||||
r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> INCLUDEPICTURE "picture.png" </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>{pic}<w:r><w:fldChar w:fldCharType="end"/></w:r>"#
|
||||
),
|
||||
false,
|
||||
),
|
||||
];
|
||||
for (field, locked) in cases {
|
||||
let bytes = docx(&format!("<w:p>{field}</w:p><w:sectPr/>"), &[("rIdPic", "image", "media/picture.png")], &[]);
|
||||
let bytes = {
|
||||
// `docx` takes text parts; add the picture bytes as a binary part.
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut entries: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).unwrap();
|
||||
let mut v = Vec::new();
|
||||
std::io::Read::read_to_end(&mut f, &mut v).unwrap();
|
||||
entries.push((f.name().to_string(), v));
|
||||
}
|
||||
entries.push(("word/media/picture.png".into(), png.to_vec()));
|
||||
zip(&entries.iter().map(|(n, v)| (n.as_str(), v.as_slice())).collect::<Vec<_>>())
|
||||
};
|
||||
let check = |d: &Document| {
|
||||
let p = paras(d);
|
||||
let objs = &p[0].objects;
|
||||
assert_eq!(objs.len(), 3, "{objs:?}");
|
||||
assert_eq!(objs[0], InlineObject::FieldStart { instr: "INCLUDEPICTURE \"picture.png\"".into(), locked });
|
||||
let InlineObject::Image { media, .. } = &objs[1] else { panic!("{objs:?}") };
|
||||
assert_eq!(d.media.get(media).unwrap().as_slice(), png);
|
||||
assert_eq!(objs[2], InlineObject::FieldEnd);
|
||||
};
|
||||
let d = wordcraft_docx::read(&bytes).unwrap();
|
||||
check(&d);
|
||||
check(&wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -382,6 +382,55 @@ fn has_page_fields(p: &Paragraph) -> bool {
|
|||
})
|
||||
}
|
||||
|
||||
/// Does `p` show a page total, which depends on how many pages the document has?
|
||||
fn has_total_fields(p: &Paragraph) -> bool {
|
||||
p.objects.iter().any(|o| match o {
|
||||
InlineObject::Field { instr, .. } => matches!(fields::field_name(instr).as_str(), "NUMPAGES" | "SECTIONPAGES"),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Page totals: of the document, and of each section (by index).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct Totals {
|
||||
pages: u32,
|
||||
sections: Vec<u32>,
|
||||
}
|
||||
|
||||
impl Totals {
|
||||
/// What a first pass assumes: one page.
|
||||
fn first_guess(sections: usize) -> Totals {
|
||||
Totals { pages: 1, sections: vec![1; sections] }
|
||||
}
|
||||
/// The totals of laid-out `pages` (a section without a page of its own counts one, as in
|
||||
/// headers and footers).
|
||||
fn of(pages: &[Page], sections: usize) -> Totals {
|
||||
let mut per = vec![0u32; sections];
|
||||
for p in pages {
|
||||
if let Some(n) = per.get_mut(p.section) {
|
||||
*n = n.saturating_add(1);
|
||||
}
|
||||
}
|
||||
for n in &mut per {
|
||||
*n = (*n).max(1);
|
||||
}
|
||||
Totals { pages: u32::try_from(pages.len()).unwrap_or(u32::MAX).max(1), sections: per }
|
||||
}
|
||||
fn section(&self, si: usize) -> u32 {
|
||||
self.sections.get(si).copied().unwrap_or(1)
|
||||
}
|
||||
}
|
||||
|
||||
/// The most extra layout passes for page totals in the body: a total that changes the page
|
||||
/// count (its digits pushing text to another page) is tried again, this many times at most.
|
||||
const MAX_TOTAL_PASSES: usize = 3;
|
||||
|
||||
/// A layout pass: finished, or to be done again with the page totals it found.
|
||||
enum Pass {
|
||||
Done(DocLayout),
|
||||
Again(Totals),
|
||||
}
|
||||
|
||||
/// Walk state while paginating.
|
||||
struct Ctx<'a> {
|
||||
doc: &'a Document,
|
||||
|
|
@ -399,6 +448,11 @@ struct Ctx<'a> {
|
|||
chains: textbox::Chains,
|
||||
/// The current section's document grid (body text only: none for headers and footers).
|
||||
grid: para::Grid,
|
||||
/// Whether body text (or its notes) laid out so far shows a page total (`NUMPAGES`,
|
||||
/// `SECTIONPAGES`), which is only known once the whole body is paginated.
|
||||
uses_totals: bool,
|
||||
/// Whether to note `uses_totals` now (off while laying out headers and footers).
|
||||
track_totals: bool,
|
||||
}
|
||||
|
||||
impl Ctx<'_> {
|
||||
|
|
@ -469,6 +523,9 @@ impl Ctx<'_> {
|
|||
label: Option<(String, Level)>,
|
||||
) -> Arc<ParaLayout> {
|
||||
let page = if has_page_fields(p) { Some(self.fields.page_key()) } else { None };
|
||||
if self.track_totals && !self.uses_totals && has_total_fields(p) {
|
||||
self.uses_totals = true;
|
||||
}
|
||||
// Automatic equation numbers count through the document.
|
||||
let eq_here: u32 = p
|
||||
.objects
|
||||
|
|
@ -1385,28 +1442,50 @@ const MAX_NOTE_PASSES: usize = 3;
|
|||
/// Lay out the whole document.
|
||||
pub fn layout(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions) -> DocLayout {
|
||||
let t0 = now_ms();
|
||||
let mut pages = HashMap::new();
|
||||
let mut l = layout_pass(doc, cache, opts, &pages);
|
||||
// Page totals shown in the body (`NUMPAGES`, `SECTIONPAGES`) are known only after
|
||||
// paginating: lay out again with the totals found until they hold (bounded). Footnotes
|
||||
// numbered again on each page likewise settle over a few more passes.
|
||||
let per_page = opts.view == ViewMode::Print
|
||||
&& doc.sections().iter().any(|(_, s)| doc.note_options(s, false).num_restart == wordcraft_doc::section::NoteRestart::EachPage);
|
||||
if per_page {
|
||||
// Number each page's footnotes from where the pass before placed them, until they stay.
|
||||
for _ in 1..MAX_NOTE_PASSES {
|
||||
let placed = footnote_pages(doc, &l.pages);
|
||||
if placed == pages {
|
||||
break;
|
||||
let mut note_pages = HashMap::new();
|
||||
let mut note_passes = 1;
|
||||
let mut totals = Totals::first_guess(doc.sections().len());
|
||||
let mut passes = 0;
|
||||
let mut l = loop {
|
||||
match layout_pass(doc, cache, opts, ¬e_pages, &totals, passes < MAX_TOTAL_PASSES) {
|
||||
Pass::Again(t) => {
|
||||
totals = t;
|
||||
passes += 1;
|
||||
}
|
||||
Pass::Done(l) => {
|
||||
if per_page && note_passes < MAX_NOTE_PASSES {
|
||||
// Number each page's footnotes from where the pass before placed them, until they stay.
|
||||
let placed = footnote_pages(doc, &l.pages);
|
||||
if placed != note_pages {
|
||||
note_pages = placed;
|
||||
note_passes += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break l;
|
||||
}
|
||||
pages = placed;
|
||||
l = layout_pass(doc, cache, opts, &pages);
|
||||
}
|
||||
}
|
||||
};
|
||||
l.ms = now_ms() - t0;
|
||||
l
|
||||
}
|
||||
|
||||
/// One layout pass, numbering footnotes restarted on each page by `note_pages` ([`note_info`]).
|
||||
fn layout_pass(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions, note_pages: &HashMap<u32, usize>) -> DocLayout {
|
||||
let t0 = now_ms();
|
||||
/// One layout pass, numbering footnotes restarted on each page by `note_pages` ([`note_info`]),
|
||||
/// with `totals` assumed for the page totals in the body. With `may_repeat`, a pass whose body
|
||||
/// shows totals that turn out different asks to be done again.
|
||||
fn layout_pass(
|
||||
doc: &Document,
|
||||
cache: &mut LayoutCache,
|
||||
opts: &LayoutOptions,
|
||||
note_pages: &HashMap<u32, usize>,
|
||||
totals: &Totals,
|
||||
may_repeat: bool,
|
||||
) -> Pass {
|
||||
let env = env_hash(doc, opts);
|
||||
if env != cache.env {
|
||||
cache.paras.clear();
|
||||
|
|
@ -1426,8 +1505,8 @@ fn layout_pass(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions, no
|
|||
counters: Counters::default(),
|
||||
fields: FieldCtx {
|
||||
page: 1,
|
||||
pages: 1,
|
||||
section_pages: 1,
|
||||
pages: totals.pages,
|
||||
section_pages: totals.section(0),
|
||||
section: 1,
|
||||
notes: Arc::new(notes),
|
||||
title: doc.core.title.as_str().into(),
|
||||
|
|
@ -1440,6 +1519,8 @@ fn layout_pass(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions, no
|
|||
boxes: wordcraft_doc::BoxBudget::default(),
|
||||
chains: Default::default(),
|
||||
grid: para::Grid::default(),
|
||||
uses_totals: false,
|
||||
track_totals: true,
|
||||
};
|
||||
let sections = doc.sections();
|
||||
let web = opts.view != ViewMode::Print;
|
||||
|
|
@ -1516,6 +1597,8 @@ fn layout_pass(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions, no
|
|||
if !web && !doc.footnote_separators.is_empty() {
|
||||
pb.seps = NoteSeps::new(&mut ctx, &doc.footnote_separators, sect.text_width());
|
||||
}
|
||||
ctx.fields.section = u32::try_from(si).unwrap_or(u32::MAX).saturating_add(1);
|
||||
ctx.fields.section_pages = totals.section(si);
|
||||
// Headers and footers left out are linked to the previous section's, and take its room.
|
||||
let (heads, feet) = (linked(§ions, si, |s| s.headers), linked(§ions, si, |s| s.footers));
|
||||
let body_top = if web { sect.margin_top } else { body_top_for(&mut ctx, sect, heads.default) };
|
||||
|
|
@ -1619,7 +1702,14 @@ fn layout_pass(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions, no
|
|||
p.h = bottom + 36.0;
|
||||
}
|
||||
ctx.grid = para::Grid::default();
|
||||
if !web && ctx.uses_totals && may_repeat {
|
||||
let found = Totals::of(&pages, sections.len());
|
||||
if found != *totals {
|
||||
return Pass::Again(found);
|
||||
}
|
||||
}
|
||||
if !web {
|
||||
ctx.track_totals = false;
|
||||
headers_footers(&mut ctx, &mut pages, §ions);
|
||||
if doc.settings.mirror_margins {
|
||||
mirror_even_pages(&mut pages, §ions);
|
||||
|
|
@ -1639,7 +1729,7 @@ fn layout_pass(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions, no
|
|||
}
|
||||
}
|
||||
}
|
||||
DocLayout { pages, index, ms: now_ms() - t0, text_boxes: ctx.boxes.total().saturating_sub(boxes_discarded) }
|
||||
Pass::Done(DocLayout { pages, index, ms: 0.0, text_boxes: ctx.boxes.total().saturating_sub(boxes_discarded) })
|
||||
}
|
||||
|
||||
/// Pagination state saved before a block, to lay the blocks from there out again (balancing
|
||||
|
|
@ -1879,7 +1969,9 @@ fn body_top_for(ctx: &mut Ctx, sect: &SectionProps, header: Option<u32>) -> f32
|
|||
let blocks = part.blocks.clone();
|
||||
// Placed as `headers_footers` draws it, so page-relative floats wrap the same way.
|
||||
let frame = PageFrame { sect, origin: (sect.margin_left + sect.gutter, sect.header) };
|
||||
let track = std::mem::replace(&mut ctx.track_totals, false);
|
||||
let h = layout_box(ctx, StoryRef::Part(id), &blocks, &[], sect.text_width(), None, 0, Some(frame)).height;
|
||||
ctx.track_totals = track;
|
||||
// Word starts the body right below a header that reaches past the top margin, no gap.
|
||||
sect.margin_top.max(sect.header + h)
|
||||
}
|
||||
|
|
@ -2511,6 +2603,7 @@ fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, bl
|
|||
match split {
|
||||
Some((a, b)) => {
|
||||
place(pb, &a);
|
||||
table_notes(ctx, pb, &a.items);
|
||||
rest = Some(b);
|
||||
}
|
||||
// Like Word, a row that does not fit on an empty page, or under the header rows
|
||||
|
|
@ -2529,10 +2622,39 @@ fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, bl
|
|||
}
|
||||
fresh = true;
|
||||
}
|
||||
place(pb, rest.as_ref().unwrap_or(row));
|
||||
let last = rest.as_ref().unwrap_or(row);
|
||||
place(pb, last);
|
||||
table_notes(ctx, pb, &last.items);
|
||||
}
|
||||
}
|
||||
|
||||
/// Footnotes referenced in the cell lines of a placed table row (or part of one) go to the
|
||||
/// bottom of the page, like those of body paragraphs; notes that don't fit there continue on
|
||||
/// the next page (see [`PageBuilder::flush_notes`]).
|
||||
fn table_notes(ctx: &mut Ctx, pb: &mut PageBuilder, items: &[Placed]) {
|
||||
if pb.web {
|
||||
return;
|
||||
}
|
||||
let mut ids: Vec<u32> = Vec::new();
|
||||
for it in items {
|
||||
let Placed::Lines { story: StoryRef::Body, para, l0, l1, .. } = it else { continue };
|
||||
let (Some(a), Some(b)) = (para.lines.get(*l0), para.lines.get(l1.saturating_sub(1))) else { continue };
|
||||
for (ci, id) in ¶.notes {
|
||||
if *ci >= a.c0 && *ci < b.c1 && !ids.contains(id) && !pb.notes.iter().any(|n| n.id == *id) {
|
||||
ids.push(*id);
|
||||
}
|
||||
}
|
||||
}
|
||||
let before = pb.notes_h();
|
||||
for id in ids {
|
||||
let Some(part) = ctx.doc.parts.get(&id).filter(|p| p.kind == wordcraft_doc::PartKind::Footnote) else { continue };
|
||||
let blocks = part.blocks.clone();
|
||||
let BoxLayout { items, height: h, .. } = layout_box(ctx, StoryRef::Part(id), &blocks, &[], pb.sect.text_width(), None, 0, None);
|
||||
pb.notes.push(NoteBox { id, items, h, cont: false });
|
||||
}
|
||||
pb.bottom -= pb.notes_h() - before;
|
||||
}
|
||||
|
||||
/// Place floating table `tl` (`w:tblpPr`): at its own position, which takes no room in the text
|
||||
/// flow; the text after it wraps around it.
|
||||
fn place_floating_table(pb: &mut PageBuilder, tl: &table::TableLayout, f: &TableFloat, legacy: f32, block: usize, body_top: f32) {
|
||||
|
|
|
|||
|
|
@ -400,7 +400,7 @@ fn right_tab_aligns_text_end() {
|
|||
fn lists_get_labels_and_indent() {
|
||||
let mut d = Document::from_text("one\ntwo\nthree");
|
||||
let id = d.numbering.add_list(wordcraft_doc::ListKind::Numbered);
|
||||
d.format_paragraphs(&Pos::body(0, 0), &Pos::body(2, 0), &|p| p.numbering = Some(wordcraft_doc::props::NumRef { num: id, level: 0 })).unwrap();
|
||||
d.format_paragraphs(&Pos::body(0, 0), &Pos::body(2, 5), &|p| p.numbering = Some(wordcraft_doc::props::NumRef { num: id, level: 0 })).unwrap();
|
||||
let l = lay(&d);
|
||||
let labels: Vec<String> = l.pages[0]
|
||||
.items
|
||||
|
|
@ -3542,3 +3542,57 @@ fn endnotes_at_section_end_and_footnotes_below_text() {
|
|||
let text = lay(&d).caret(&Pos::body(1, 0)).unwrap();
|
||||
assert!(below > text.top + text.height && below < bottom - 100.0, "{below} {bottom} {text:?}");
|
||||
}
|
||||
|
||||
/// #451: page totals in the body show the document's page count (the stale cached result is
|
||||
/// ignored), on every page, like the same fields in the footer.
|
||||
#[test]
|
||||
fn body_page_totals_count_every_page() {
|
||||
let mut d = Document::from_text("Body-1\nBody-2\nBody-3");
|
||||
let field = |instr: &str| InlineObject::Field { instr: instr.into(), result: "9".into(), locked: false, code: false };
|
||||
for i in 0..3 {
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(i)).unwrap();
|
||||
p.props.page_break_before = Some(i > 0);
|
||||
let n = p.len();
|
||||
p.insert_object(n, field("NUMPAGES"), &Default::default()).unwrap();
|
||||
let n = p.len();
|
||||
p.insert_object(n, field("SECTIONPAGES"), &Default::default()).unwrap();
|
||||
}
|
||||
let mut fp = wordcraft_doc::Paragraph::new();
|
||||
fp.insert_object(0, field("NUMPAGES"), &Default::default()).unwrap();
|
||||
let id = d.add_part(wordcraft_doc::PartKind::Footer, vec![wordcraft_doc::para_block(fp)]);
|
||||
d.last_section.footers.default = Some(id);
|
||||
let l = lay(&d);
|
||||
assert_eq!(l.pages.len(), 3);
|
||||
let shown = |items: &[Placed]| -> Vec<String> {
|
||||
items
|
||||
.iter()
|
||||
.flat_map(|it| if let Placed::Lines { para, .. } = it { para.shown.iter().map(|s| s.1.clone()).collect() } else { vec![] })
|
||||
.collect()
|
||||
};
|
||||
for (i, p) in l.pages.iter().enumerate() {
|
||||
assert_eq!(shown(&p.items), ["3", "3"], "body of page {}", i + 1);
|
||||
assert_eq!(shown(&p.footer), ["3"], "footer of page {}", i + 1);
|
||||
}
|
||||
// One page: one pass, total 1.
|
||||
let mut d1 = Document::from_text("Only");
|
||||
let p = d1.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.insert_object(4, field("NUMPAGES"), &Default::default()).unwrap();
|
||||
assert_eq!(shown(&lay(&d1).pages[0].items), ["1"]);
|
||||
}
|
||||
|
||||
/// #450: a footnote referenced in a table cell is placed at the bottom of its page, like one in
|
||||
/// a body paragraph.
|
||||
#[test]
|
||||
fn table_cell_footnotes_are_placed() {
|
||||
let mut d = Document::from_text("After the table.");
|
||||
d.insert_block(StoryRef::Body, &Path::top(0), wordcraft_doc::Block::Table(wordcraft_doc::Table::new(1, 1, 300.0))).unwrap();
|
||||
let cell = Pos { story: StoryRef::Body, path: Path(vec![0, 0, 0, 0]), off: 0 };
|
||||
d.para_mut(StoryRef::Body, &cell.path).unwrap().insert_text(0, "Table reference", &Default::default()).unwrap();
|
||||
let id = footnote(&mut d, &Pos { off: "Table reference".len(), ..cell }, "Table note payload.");
|
||||
let l = lay(&d);
|
||||
assert_eq!(l.pages.len(), 1);
|
||||
let c = l.caret(&d.start_of(StoryRef::Part(id))).expect("the cell's footnote is placed");
|
||||
assert_eq!(c.page, 0);
|
||||
let body = l.caret(&Pos::body(1, 0)).unwrap();
|
||||
assert!(c.top > body.top + 100.0, "at the page bottom: {c:?} {body:?}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1165,9 +1165,28 @@ impl Exporter<'_> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Where a bookmark is: (output page, x, y).
|
||||
/// Where a bookmark is: (output page, x, y) — the page and line its start lands on, which
|
||||
/// in a paragraph split across pages need not be the paragraph's first page.
|
||||
fn bookmark(&self, name: &str) -> Option<(usize, f32, f32)> {
|
||||
let (_, pos) = self.doc.bookmarks().into_iter().find(|(n, _)| n == name)?;
|
||||
if let Some(c) = self.lay.caret(&pos)
|
||||
&& let Some(out) = self.out_index.get(&c.page)
|
||||
{
|
||||
// The left edge of the paragraph's piece on that page, at the top of the line.
|
||||
let x = self
|
||||
.lay
|
||||
.index
|
||||
.get(&(pos.story, pos.path.clone()))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|(pi, _)| *pi == c.page)
|
||||
.find_map(|(pi, ii)| match self.lay.pages.get(*pi).and_then(|p| p.items.get(*ii)) {
|
||||
Some(Placed::Lines { x, .. }) => Some(*x),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(c.x);
|
||||
return Some((*out, x, c.top));
|
||||
}
|
||||
self.locate(pos.story, &pos.path)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -764,3 +764,37 @@ fn cosmetic_metafile_pens_are_thin_but_visible() {
|
|||
let raw = String::from_utf8_lossy(&bytes);
|
||||
assert!(raw.contains("0.25 w"), "the hairline is a quarter point wide");
|
||||
}
|
||||
|
||||
/// #455: a link to a bookmark after a page break inside its paragraph goes to the page the
|
||||
/// bookmark is on, not the paragraph's first page.
|
||||
#[test]
|
||||
fn bookmark_destination_is_the_page_of_its_start() {
|
||||
let mut d = Document::new();
|
||||
let mut p = Paragraph::with_text("Paragraph start\u{c}Bookmark target", CharProps::default());
|
||||
let at = "Paragraph start\u{c}".len();
|
||||
p.insert_object(at, InlineObject::BookmarkStart { name: "target".into() }, &CharProps::default()).unwrap();
|
||||
p.insert_object(0, InlineObject::BookmarkStart { name: "first".into() }, &CharProps::default()).unwrap();
|
||||
d.body = vec![para_block(p)];
|
||||
let lay = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
|
||||
assert_eq!(lay.pages.len(), 2);
|
||||
let pages: Vec<usize> = (0..lay.pages.len()).collect();
|
||||
let outlined = HashSet::new();
|
||||
let ex = Exporter {
|
||||
doc: &d,
|
||||
lay: &lay,
|
||||
opts: &PdfOptions::default(),
|
||||
out_index: pages.iter().enumerate().map(|(o, p)| (*p, o)).collect(),
|
||||
fonts: HashMap::new(),
|
||||
outlined: &outlined,
|
||||
cmaps: HashMap::new(),
|
||||
images: HashMap::new(),
|
||||
vectors: HashMap::new(),
|
||||
tags: Vec::new(),
|
||||
tag_index: HashMap::new(),
|
||||
links: Vec::new(),
|
||||
};
|
||||
let (page, x, y) = ex.bookmark("target").unwrap();
|
||||
assert_eq!(page, 1, "the bookmark is on the second page");
|
||||
assert!((x - 72.0).abs() < 1.0 && y < 150.0, "{x} {y}");
|
||||
assert_eq!(ex.bookmark("first").map(|b| b.0), Some(0));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# File-format parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Restrict Editing: Word's legacy protection hash verified, unsupported forms refused, #412; previously trivial: Restrict Editing protection, locked styles and exception ranges read and written, #412; import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (bookmark around a final table and `INCLUDEPICTURE` pictures kept, #464 #465; formatting revisions landed, #41; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
|
||||
|
||||
Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the
|
||||
share of a typical real-world file's content and formatting that survives (read: shows correctly;
|
||||
|
|
@ -54,9 +54,9 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
|
|||
| 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`, `w:moveFromRangeStart/End`, `w:moveToRangeStart/End`), moved paragraph marks | ✅ | ✅ | #400: both ends linked by the range name; ranges rewritten around each moved stretch (ids renumbered); a move missing an end or a range end opens as a plain deletion/insertion. RTF/ODT/PDF export the result (moved-away text hidden like deletions) |
|
||||
| Restrict Editing (`w:documentProtection` edit/formatting/enforcement and password hash, `w:locked` styles, `w:permStart`/`w:permEnd` exceptions) | ✅ | ✅ | #412: SHA-512 hash with salt and spin count written; Word's legacy `w:cryptAlgorithmSid`/`w:hash` form (SHA-1/-256/-384/-512, ECMA-376 Part 4 §14.8.1) is kept and verified; MD2/MD4/MD5, `w:algIdExt` and other unsupported forms are refused, never unlocked; `w:colFirst`/`w:colLast` table-column ranges read as plain ranges |
|
||||
| Fields (`w:fldChar`, `w:fldSimple`), TOC, cross-references, `ADDIN` citations | ✅ | ✅ | Field codes Word supports but we don't evaluate keep their cached result. `EQ \o` (overstrike, enclosed characters) is drawn; enclosed characters are written as Word stores them, their style in the code's run sizes (#297) |
|
||||
| Fields (`w:fldChar`, `w:fldSimple`), TOC, cross-references, `ADDIN` citations | ✅ | ✅ | Field codes Word supports but we don't evaluate keep their cached result. `EQ \o` (overstrike, enclosed characters) is drawn; enclosed characters are written as Word stores them, their style in the code's run sizes (#297); a cached picture (`INCLUDEPICTURE`) stays as the content of a range field (#465) |
|
||||
| Content controls (`w:sdt`) | ✅ | ✅ | Block, inline, row and cell level, nested; title, tag, id, lock, placeholder, temporary, rich/plain text, `w14:checkbox`, combo box, drop-down list, date, picture, building block gallery, `w15` repeating sections; other `w:sdtPr` children (data binding, appearance) kept verbatim (#381). A TOC's control is rebuilt by the TOC |
|
||||
| Restrict Editing (`w:documentProtection` edit/formatting/enforcement and password hash, `w:locked` styles, `w:permStart`/`w:permEnd` exceptions) | ✅ | ✅ | #412: SHA-512 hash with salt and spin count written; Word's legacy `w:cryptAlgorithmSid`/`w:hash` form (SHA-1/-256/-384/-512, ECMA-376 Part 4 §14.8.1) is kept and verified; MD2/MD4/MD5, `w:algIdExt` and other unsupported forms are refused, never unlocked; `w:colFirst`/`w:colLast` table-column ranges read as plain ranges |
|
||||
| Legacy form fields (`w:ffData`), check boxes, drop-downs | ❌ | ❌ | |
|
||||
| DrawingML pictures inline and anchored (`wp:anchor`), wrap square/tight/through/top-bottom | ✅ | ✅ | Tight/through read and written with their `wp:wrapPolygon` (and `edited`); without one, a shape's outline is written (a picture's rectangle). `wrapText` (both sides, left, right, largest) read and written for square, tight and through |
|
||||
| WordArt text effects (`w14:textFill`, `w14:textOutline`, `w14:shadow`, `w14:glow`, `w14:reflection` in `w:rPr`) | ✅ | ✅ | Solid, gradient (linear) and no fill, outline colour/width, shadow, glow, reflection (#416); `w14:scene3d`/`w14:props3d` dropped |
|
||||
|
|
@ -90,6 +90,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | trivial | A bookmark (or comment range) ending after the document's last table keeps its end, in the table's last cell (#464); a field whose cached result is a picture (`INCLUDEPICTURE`) is kept as a range field around the picture, so the picture survives save; the linked file is never read (#465) |
|
||||
| 2026-10-11 | trivial | Restrict Editing: Word's legacy password hash (`w:cryptAlgorithmSid`, `w:hash`, `w:salt`, `w:cryptSpinCount`) verified per ECMA-376 Part 4 §14.8.1; unsupported hash forms refused (#412) |
|
||||
| 2026-10-11 | trivial | Restrict Editing: `w:documentProtection` (edit, formatting, password hash), `w:locked` styles and `w:permStart`/`w:permEnd` exceptions read and written (#412) |
|
||||
| 2026-10-11 | trivial | WordArt text effects (`w14:` run properties), text warps (`a:prstTxWarp`) and connectors with glued ends read and written (#416) |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Layout and pagination parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (hyphenation zone, capitals and consecutive-hyphen limit from the document, #407; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
A document only "opens correctly" if its pages break where Word breaks them: the same lines, the
|
||||
same page count, footnotes and pictures on the same pages. This checklist covers line breaking,
|
||||
|
|
@ -55,7 +55,7 @@ font can't match Word's metrics.
|
|||
| Bidirectional paragraphs, mixed-direction lines; RTL tables and sections | 🟡 | #207; RTL tables (#362): grid columns from the right edge, start alignment and indent from the right margin, cell margins and left/right borders mirrored, merged and turned cells and nested tables included; RTL sections (#362): newspaper columns right to left with the line between mirrored. Right-hand page gutter (`w:rtlGutter`) missing | 1 |
|
||||
| Equations: display layout, numbering, wrapping long display equations | ✅ | #191; long display equations break at top-level operators (`m:brkBin`, `m:brkBinSub`), manual breaks and `m:alnAt`, continuation lines indented by `m:wrapIndent` or set right (`m:wrapRight`) (#326) | — |
|
||||
| Hidden text excluded from breaking and hyphenation | ✅ | #173 | — |
|
||||
| Fields: PAGE, NUMPAGES, SECTIONPAGES, TOC page numbers | ✅ | #52 | — |
|
||||
| Fields: PAGE, NUMPAGES, SECTIONPAGES, TOC page numbers | ✅ | #52; totals in the body need a second pass once the page count is known (#451) | — |
|
||||
| Compatibility modes (Word 2003/2007/2010 layout for old files: `w:compat` options) | 🟡 | mode 15 behaviour; the ~60 legacy compat options are mostly ignored | 10–20 |
|
||||
| Page-by-page comparison harness against Word | ❌ | Run Word locally on our own synthetic documents, export PDF, compare line breaks; Word output stays under `plan/` (never committed) | 8–12 |
|
||||
|
||||
|
|
@ -72,6 +72,7 @@ font can't match Word's metrics.
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | trivial | NUMPAGES/SECTIONPAGES in the body show the real totals (the body is laid out again once the page count is known, at most three extra passes, #451); footnotes referenced in table cells go to the page bottom (#450); PDF links to a bookmark go to the page its start is on, not its paragraph's first page (#455) |
|
||||
| 2026-10-11 | trivial | Mirrored margins on even pages, tall and linked footers/headers push the body, long endnotes continue, caret in continued notes (#452, #453, #454, #456, #457) |
|
||||
| 2026-10-11 | trivial | Hyphenation zone, words in capitals and consecutive-hyphen limit follow the document settings; Manual Hyphenation (#407) |
|
||||
| 2026-10-11 | trivial | Document grid: line and character pitch (#391) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue