Long footnotes continue onto the next page (#352) (#357)

When the footnotes referenced on a page don't fit below its text, the note
that doesn't fit is split at a line boundary: its first line stays on the
page of its reference (if even that doesn't fit, the reference line moves
to the next page, as before), and the rest continues at the top of the
next page's note area, before that page's own notes, under a full-width
continuation separator. Notes still continuing after the last page get
pages of their own. Endnotes are unchanged.

The docx reader doesn't keep a document's own continuation separator or
continuation notice, so the default line is drawn and no notice.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-10 17:43:38 -04:00
1 parent 9ddaff2dae
commit 79b6f33b03
4 files changed
+321 -26

No files matched your search

+228 -24
View File
@@ -792,8 +792,11 @@ struct PageBuilder<'a> {
bottom: f32,
number: u32,
web: bool,
/// Footnotes waiting for the bottom of the current page: (part id, items, height).
notes: Vec<(u32, Vec<Placed>, f32)>,
/// Footnotes waiting for the bottom of the current page (first the rest of notes continued
/// from the page before).
notes: Vec<NoteBox>,
/// The parts of footnotes that didn't fit on their page, continued on the next one.
carry: Vec<NoteBox>,
/// Page bottom before footnotes took space.
orig_bottom: f32,
/// The previous paragraph: (style, contextual spacing, space after) for contextual spacing.
@@ -810,35 +813,198 @@ struct PageBuilder<'a> {
/// Gap above the footnote separator and its length.
const NOTE_SEP: f32 = 12.0;
/// The most pages added after the body for footnotes still continuing.
const MAX_NOTE_PAGES: usize = 10_000;
/// A footnote waiting for the bottom of a page: its items laid out from y = 0, and its height.
struct NoteBox {
id: u32,
items: Vec<Placed>,
h: f32,
/// The rest of a note that didn't fit on an earlier page (drawn below the continuation
/// separator).
cont: bool,
}
/// The vertical extent of an item laid out in a horizontal frame.
fn v_span(it: &Placed) -> (f32, f32) {
if let Some(r) = it.turned_bounds() {
return (r.y, r.y + r.h);
}
match it {
Placed::Lines { para, l0, l1, y, .. } => (*y, item_bottom(*y, para, *l0, *l1).unwrap_or(*y)),
Placed::Fill { rect, .. }
| Placed::Image { rect, .. }
| Placed::Shape { rect, .. }
| Placed::Graphic { rect, .. }
| Placed::Cell { rect, .. }
| Placed::Object { rect, .. } => (rect.y, rect.y + rect.h),
Placed::Rule { y0, y1, .. } => (y0.min(*y1), y0.max(*y1)),
}
}
impl NoteBox {
/// Lines of the note's own paragraphs, which may be split between pages (not those of its
/// tables or text boxes).
fn splittable(&self, it: &Placed) -> bool {
matches!(it, Placed::Lines { story: StoryRef::Part(s), path, turn: TextDirection::Horizontal, .. } if *s == self.id && path.0.len() == 1)
}
/// Where the note may be split, ascending: the bottoms of its lines that have more lines
/// below them and don't cut through a picture, table or other unsplittable item.
fn cuts(&self) -> Vec<f32> {
let blocked: Vec<(f32, f32)> =
self.items.iter().filter(|it| !self.splittable(it) && !matches!(it, Placed::Fill { .. } | Placed::Rule { .. })).map(v_span).collect();
let mut last_top = f32::MIN;
let mut cuts = Vec::new();
for it in &self.items {
let Placed::Lines { para, l0, l1, y, .. } = it else { continue };
if !self.splittable(it) {
continue;
}
let Some(first) = para.lines.get(*l0) else { continue };
for l in para.lines.get(*l0..*l1).unwrap_or(&[]) {
last_top = last_top.max(y + l.top - first.top);
cuts.push(y + l.top - first.top + l.height);
}
}
cuts.retain(|&b| b > 0.01 && b <= last_top + 0.01 && !blocked.iter().any(|&(t, e)| t < b - 0.01 && e > b + 0.01));
cuts.sort_by(f32::total_cmp);
cuts.dedup_by(|a, b| (*a - *b).abs() < 0.01);
cuts
}
/// The least of the note that must stay with its reference: its first line (all of it if it
/// can't be split).
fn first_piece(&self) -> f32 {
self.cuts().first().copied().unwrap_or(self.h).min(self.h)
}
/// Split the note at line bottom `cut`: the part above (`cut` tall) and the rest, moved up to
/// start at 0 and marked as a continuation.
fn split(self, cut: f32) -> (NoteBox, NoteBox) {
let (mut head, mut tail) = (Vec::new(), Vec::new());
for it in self.items {
let (top, bottom) = v_span(&it);
if bottom <= cut + 0.01 {
head.push(it);
continue;
}
if top >= cut - 0.01 {
tail.push(it);
continue;
}
match it {
Placed::Lines { story, path, para, l0, l1, x, y, turn } => {
let Some(first) = para.lines.get(l0) else { continue };
let ft = first.top;
// The first line that ends below the cut starts the tail.
let k = (l0..l1).find(|&k| para.lines.get(k).is_none_or(|l| y + l.top - ft + l.height > cut + 0.01)).unwrap_or(l1);
let ky = para.lines.get(k).map_or(y, |l| y + l.top - ft);
if k > l0 {
head.push(Placed::Lines { story, path: path.clone(), para: para.clone(), l0, l1: k, x, y, turn });
}
if k < l1 {
tail.push(Placed::Lines { story, path, para, l0: k, l1, x, y: ky, turn });
}
}
Placed::Fill { rect, color } => {
head.push(Placed::Fill { rect: Rect::new(rect.x, rect.y, rect.w, cut - rect.y), color });
tail.push(Placed::Fill { rect: Rect::new(rect.x, cut, rect.w, rect.y + rect.h - cut), color });
}
Placed::Rule { x0, y0, x1, y1, border } => {
head.push(Placed::Rule { x0, y0: y0.min(cut), x1, y1: y1.min(cut), border });
tail.push(Placed::Rule { x0, y0: y0.max(cut), x1, y1: y1.max(cut), border });
}
// Not split (cuts avoid them): stays above.
other => head.push(other),
}
}
// The rest starts at its first line (the space between paragraphs goes with the break).
let start = tail.iter().filter(|it| matches!(it, Placed::Lines { .. })).map(|it| v_span(it).0).fold(f32::MAX, f32::min);
let start = if start == f32::MAX { cut } else { start.max(cut) };
for it in &mut tail {
it.translate(0.0, -start);
// Shading cut at the break starts at the rest's top.
if let Placed::Fill { rect, .. } = it
&& rect.y < 0.0
{
rect.h = (rect.h + rect.y).max(0.0);
rect.y = 0.0;
}
}
let rest = (self.h - start).max(0.0);
(NoteBox { id: self.id, items: head, h: cut, cont: self.cont }, NoteBox { id: self.id, items: tail, h: rest, cont: true })
}
}
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.2).sum::<f32>() }
if self.notes.is_empty() { 0.0 } else { NOTE_SEP + self.notes.iter().map(|n| n.h).sum::<f32>() }
}
/// Place the collected footnotes at the bottom of the current page.
/// The lowest bottom of the body text and tables on the current page.
fn body_floor(&self) -> f32 {
let Some(pg) = self.pages.last() else { return self.top };
pg.items
.iter()
.skip(self.page_items_start)
.filter(|it| matches!(it, Placed::Lines { story: StoryRef::Body, .. } | Placed::Cell { story: StoryRef::Body, .. }))
.map(|it| v_span(it).1)
.fold(self.top, f32::max)
}
/// Place the collected footnotes at the bottom of the current page. When they don't all fit
/// below the body (`bottom`), the note that doesn't is split at a line and its rest, with
/// the notes after it, continues on the next page.
fn flush_notes(&mut self) {
if self.notes.is_empty() {
return;
}
let total = self.notes_h();
let x = self.sect.margin_left + self.sect.gutter;
let mut y = self.orig_bottom - total + NOTE_SEP;
// The note area starts below the text (a page of continued notes alone: at its top).
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.
let cont = notes.first().is_some_and(|n| n.cont);
let mut placed = Vec::new();
let mut rest = notes.into_iter();
while let Some(n) = rest.next() {
let room = self.orig_bottom - y;
if fits || n.h <= room + 0.01 {
let ny = y;
y += n.h;
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.
let first = placed.is_empty();
let cuts = n.cuts();
match cuts.iter().rev().find(|&&c| c <= room + 0.01).or(cuts.first().filter(|_| first)).copied() {
Some(c) => {
let (head, tail) = n.split(c);
placed.push((head, y));
self.carry.push(tail);
}
None if first => placed.push((n, y)),
None => self.carry.push(n),
}
self.carry.extend(rest.by_ref());
break;
}
if let Some(pg) = self.pages.last_mut() {
pg.items.push(Placed::Rule {
x0: x,
y0: y - NOTE_SEP / 2.0,
x1: x + 144.0,
y1: y - NOTE_SEP / 2.0,
border: Border { style: wordcraft_doc::props::BorderStyle::Single, width: 0.5, color: None, space: 0.0 },
});
for (_, items, h) in notes {
for mut it in items {
it.translate(x, y);
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) });
for (n, ny) in placed {
for mut it in n.items {
it.translate(x, ny);
pg.items.push(it);
}
y += h;
}
}
}
@@ -906,6 +1072,11 @@ impl PageBuilder<'_> {
self.bottom = if self.web { f32::MAX / 8.0 } else { s.page_h - s.margin_bottom };
self.orig_bottom = self.bottom;
self.y = self.top;
// Notes continued from the page before come first in this page's note area.
if !self.carry.is_empty() && !self.web {
self.notes = std::mem::take(&mut self.carry);
self.bottom -= self.notes_h();
}
}
fn col_x(&self) -> f32 {
self.sect.margin_left + self.sect.gutter + self.cols.get(self.col).map(|c| c.0).unwrap_or(0.0)
@@ -997,6 +1168,7 @@ pub fn layout(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions) ->
number: 0,
web,
notes: Vec::new(),
carry: Vec::new(),
orig_bottom: 0.0,
prev: None,
excl: Vec::new(),
@@ -1092,6 +1264,18 @@ pub fn layout(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions) ->
}
pb.apply_valign();
pb.flush_notes();
// Footnotes still continuing after the last page get pages of their own. Each page takes at
// least a line of the first note, so this ends; the cap is a backstop.
let last_block = doc.body.len().saturating_sub(1);
for _ in 0..MAX_NOTE_PAGES {
if pb.carry.is_empty() {
break;
}
let top = pb.top;
pb.new_page(last_block, top);
pb.flush_notes();
}
pb.carry.clear();
let mut pages = pb.pages;
if web && let Some(p) = pages.first_mut() {
let bottom = p
@@ -1466,7 +1650,7 @@ fn place_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: usize,
let lead = if l0 == 0 { first.top.max(0.0) } else { 0.0 };
let mut l1 = l0;
let mut forced_break = None;
let mut new_notes: Vec<(u32, Vec<Placed>, f32)> = Vec::new();
let mut new_notes: Vec<NoteBox> = Vec::new();
let notes = para_notes(ctx.doc, p, &pl);
while l1 < n {
let Some(l) = pl.lines.get(l1) else { break };
@@ -1476,27 +1660,35 @@ fn place_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: usize,
for (ci, id) in &notes {
if *ci >= l.c0
&& *ci < l.c1
&& !pb.notes.iter().chain(new_notes.iter()).any(|x| x.0 == *id)
&& !pb.notes.iter().chain(new_notes.iter()).any(|x| x.id == *id)
&& let Some(part) = ctx.doc.parts.get(id).filter(|p| p.kind == wordcraft_doc::PartKind::Footnote)
{
let blocks = part.blocks.clone();
let BoxLayout { items, height: h, .. } =
layout_box(ctx, StoryRef::Part(*id), &blocks, &[], pb.sect.text_width(), None, 0, None);
line_notes.push((*id, items, h));
line_notes.push(NoteBox { id: *id, items, h, cont: false });
}
}
}
let extra: f32 = line_notes.iter().map(|n| n.2).sum::<f32>()
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 };
let limit = pb.bottom
- new_notes.iter().map(|n| n.2).sum::<f32>()
- new_notes.iter().map(|n| n.h).sum::<f32>()
- if pb.notes.is_empty() && !new_notes.is_empty() { NOTE_SEP } else { 0.0 }
- extra;
let bottom = pb.y + lead + (l.top + l.height - first.top);
if bottom > limit + 0.01 && l1 > l0 {
// Notes too long for the rest of the page continue on the next one: the line still
// fits if the notes before its last one, and that one's first line, fit below it.
// The notes then take the rest of the page.
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
});
if bottom > limit + 0.01 && l1 > l0 && !split {
break;
}
if bottom > limit + 0.01 && l1 == l0 && !pb.at_top() {
if bottom > limit + 0.01 && l1 == l0 && !pb.at_top() && !split {
break;
}
new_notes.extend(line_notes);
@@ -1505,6 +1697,9 @@ fn place_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: usize,
forced_break = Some(l.end);
break;
}
if split {
break;
}
}
// Widow/orphan control (2-line minimum at either end).
if forced_break.is_none() && l1 < n && pl.rp.widow_control && n >= 2 {
@@ -1534,12 +1729,21 @@ fn place_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: usize,
}
// Keep only the notes of the lines that are placed.
let placed_end = pl.lines.get(l1.saturating_sub(1)).map(|l| l.c1).unwrap_or(0);
new_notes.retain(|(id, ..)| notes.iter().any(|(ci, nid)| nid == id && *ci < placed_end));
new_notes.retain(|n| notes.iter().any(|(ci, nid)| *nid == n.id && *ci < placed_end));
let before = pb.notes_h();
pb.notes.extend(new_notes);
pb.bottom -= pb.notes_h() - before;
let x = pb.col_x();
let y = pb.y + lead;
// Notes that no longer fit below the text (a long one, or the rest of one continued from
// the page before) get what's left under it and continue on the next page.
if !pb.notes.is_empty()
&& !pb.web
&& let Some(placed_bottom) = item_bottom(y, &pl, l0, l1)
&& placed_bottom > pb.bottom + 0.01
{
pb.bottom = pb.body_floor().max(placed_bottom).min(pb.orig_bottom);
}
let mut items = Vec::new();
push_para(&mut items, StoryRef::Body, &[block as u32], &pl, l0, l1, x, y, width);
// Pictures, shapes and text boxes in these lines (web view: floats placed by this piece).
+89
View File
@@ -2849,3 +2849,92 @@ fn rotated_group_turns_its_members_and_their_shadows() {
assert!(l.object_at(0, g.x + 5.0, cy, 0.0).is_none());
assert!(l.object_at(0, cx, cy - 50.0, 0.0).is_some());
}
/// The pieces of footnote `id` as (page, first line, end line, top).
fn note_pieces(l: &DocLayout, id: u32) -> Vec<(usize, usize, usize, f32)> {
let mut v = Vec::new();
for (pi, p) in l.pages.iter().enumerate() {
for it in &p.items {
if let Placed::Lines { story: StoryRef::Part(s), l0, l1, y, .. } = it
&& *s == id
{
v.push((pi, *l0, *l1, *y));
}
}
}
v
}
/// Lengths of the horizontal rules on page `pi` below `y`.
fn rules_below(l: &DocLayout, pi: usize, y: f32) -> Vec<f32> {
l.pages[pi]
.items
.iter()
.filter_map(|it| if let Placed::Rule { x0, y0, x1, y1, .. } = it { (*y0 == *y1 && *y0 > y).then_some(x1 - x0) } else { None })
.collect()
}
#[test]
fn long_footnotes_continue_on_the_next_page() {
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 own = footnote(&mut d, &Pos::body(14, 4), "The second page's own note.");
let l = lay(&d);
let refs = l.caret(&Pos::body(4, 4)).unwrap();
assert_eq!(refs.page, 0, "the reference stays where it was");
let pieces = note_pieces(&l, long);
assert!(pieces.len() >= 2, "{pieces:?}");
let (p0, p1) = (&pieces[0], &pieces[1]);
assert_eq!((p0.0, p1.0), (0, 1), "split over pages 1 and 2: {pieces:?}");
assert_eq!(p0.1, 0);
assert_eq!(p0.2, p1.1, "continues at the next line");
// Every line of the note is placed once.
let lines = pieces.iter().map(|p| p.2 - p.1).sum::<usize>();
let para = l.pages[0]
.items
.iter()
.find_map(|it| if let Placed::Lines { story: StoryRef::Part(s), para, .. } = it { (*s == long).then(|| para.lines.len()) } else { None })
.unwrap();
assert_eq!(lines, para);
// Page 1: the note fills the space under the text, which ends above it.
let body_bottom = |pi: usize| {
l.pages[pi]
.items
.iter()
.filter_map(
|it| if let Placed::Lines { story: StoryRef::Body, y, para, l0, l1, .. } = it { item_bottom(*y, para, *l0, *l1) } else { None },
)
.fold(0.0f32, f32::max)
};
assert!(body_bottom(0) <= p0.3, "{} {}", body_bottom(0), p0.3);
assert_eq!(rules_below(&l, 0, body_bottom(0)), vec![144.0], "a normal separator on page 1");
// Page 2: the continuation separator spans the text width, the rest of the long note comes
// first and the page's own note follows it.
assert!(body_bottom(1) <= p1.3, "{} {}", body_bottom(1), p1.3);
assert_eq!(rules_below(&l, 1, body_bottom(1)), vec![468.0], "continuation separator");
let own_ref = l.caret(&Pos::body(14, 4)).unwrap();
let own_note = l.caret(&d.start_of(StoryRef::Part(own))).unwrap();
assert_eq!((own_ref.page, own_note.page), (1, 1));
assert!(own_note.top > p1.3, "own note after the continued one: {own_note:?} {p1:?}");
}
#[test]
fn short_footnotes_stay_whole() {
let mut d = Document::from_text(&"Body text line.\n".repeat(60));
let id = footnote(&mut d, &Pos::body(10, 4), "A short note, two lines long. ".repeat(4).as_str());
let l = lay(&d);
let pieces = note_pieces(&l, id);
assert_eq!(pieces.len(), 1, "{pieces:?}");
assert_eq!(pieces[0].0, l.caret(&Pos::body(10, 4)).unwrap().page);
}
#[test]
fn footnote_longer_than_pages_ends() {
// A note several pages long after a one-line body: it continues on pages of its own.
let mut d = Document::from_text("Body");
let id = footnote(&mut d, &Pos::body(0, 4), &"Words in a very long note. ".repeat(1500));
let l = lay(&d);
let pieces = note_pieces(&l, id);
assert!(pieces.len() >= 3 && l.pages.len() == pieces.len(), "{} pages, {pieces:?}", l.pages.len());
assert!(pieces.windows(2).all(|w| w[0].2 == w[1].1 && w[1].0 == w[0].0 + 1), "{pieces:?}");
}
+2 -1
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 | **Charts, SmartArt, OLE objects, ink and group shapes are dropped on open and lost on save, silently** | B | No `c:chart`, `dgm`, `wpg`, `w:object` handling in `crates/docx` | Business and academic documents lose content without warning | 10–15 to preserve and show fallback pictures; 45–70 to render and edit charts and SmartArt | [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; columns don't balance; long footnotes don't continue; 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; columns don't balance; 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) |
| 5 | **Formatting revisions, move tracking and content controls** are lost (`w:rPrChange`/`w:pPrChange` absent; `w:sdt` unwrapped) | B | Issue #41; source grep | Legal and editorial review workflows; templates with form controls | 12–18 | [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**: 18 modal dialogs vs Word's ~100 (Tabs, Borders and Shading, Columns, Symbol, Field, 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) |
@@ -64,6 +64,7 @@ The same gaps grouped the way the parity documents are, for agents working in on
| Date | Change | Summary |
|---|---|---|
| 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-10 | trivial | #10: free rotation and flips for pictures, shapes and text boxes, with a rotation handle (#332); text boxes rotate their frame; their text stays upright |
| 2026-10-10 | trivial | #29: long display equations wrap across lines (#326) |
+2 -1
View File
@@ -31,7 +31,7 @@ font can't match Word's metrics.
| Columns, column breaks, separators | 🟡 | **Columns don't balance** at a continuous section break or document end | 4–6 |
| 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 doesn't continue onto the next page**; no continuation separator/notice; per-section restart partial | 4–6 |
| 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 |
| 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 | Long footnotes continue onto the next page with a continuation separator (#352) |
| 2026-10-10 | trivial | Rotated and flipped objects laid out and drawn (#332) |
| 2026-10-10 | trivial | Long display equations wrap across lines (#326) |
| 2026-10-10 | trivial | AutoFit Contents measures the text (#44) |