fix: count visual lines, list and column lines in the line numbering

Rects merged by vertical overlap are the lines: a super/subscript,
formula or rotated run no longer opens a phantom line (5 was followed
by 10 one line later). List items and column paragraphs now count, as
both word processors number them; a line pushed past the page surface
is skipped so no number prints into the page gap.
This commit is contained in:
Steffen Becker committed 2026-08-30 10:06:42 +00:00
1 parent 9578dea57e
commit ee984ae8b0
2 files changed
+33 -16

No files matched your search

+6 -4
View File
@@ -221,10 +221,12 @@ Layout tab's toggle flips the flag.
## Line numbering (`LineNumberLayer.svelte`, `storage/lineNumbering.ts`)
One number per rendered line in the left margin. There is no CSS line box to read, so
each block's contents are Range-selected and the distinct tops of its client rects are
its lines; the count runs from the document start (or restarts per page). Re-measured on
each edit and each pagination settle — the whole document each time, which is the
ceiling noted in the file.
each block's contents are Range-selected and its client rects merged by vertical overlap
are its lines (a super/subscript or formula run must not open a phantom line); the count
runs from the document start (or restarts per page), covering list and column lines like
both word processors but not tables, frames or indexes. A line past the page surface is
skipped, or its number would print into the page gap. Re-measured on each edit and each
pagination settle — the whole document each time, which is the ceiling noted in the file.
## Debug tooling (dev only)
+27 -12
View File
@@ -5,7 +5,7 @@
// Numbers in the left margin, one per rendered line. CSS exposes no line boxes, so
// every line has to be measured: a Range over each block yields one client rect per
// run per line, and the distinct tops are the lines.
// run per line, and rects merged by vertical overlap are the lines.
//
// ponytail: measures every block in the document on each settle. Fine while numbering
// is on and the document is ordinary; a 400-page one would want a windowed pass keyed
@@ -65,14 +65,21 @@
const r = block.getBoundingClientRect();
return r.height > 0 ? [{ top: r.top - origin, height: r.height }] : [];
}
// One rect per run per line; the distinct tops are the lines.
const byTop = new Map<number, { top: number; height: number }>();
for (const r of rects) {
const key = Math.round(r.top);
const seen = byTop.get(key);
if (!seen || r.height > seen.height) byTop.set(key, { top: r.top - origin, height: r.height });
// One rect per run per line. A run shifted off the baseline (super/subscript, a
// formula, a rotated char) still overlaps its line's span, so rects merge into one
// line while a rect mostly below the span (even at squeezed spacing) opens the next.
const lines: { top: number; bottom: number }[] = [];
for (const r of rects.slice().sort((a, b) => a.top - b.top)) {
const cur = lines[lines.length - 1];
const overlap = cur ? Math.min(cur.bottom, r.bottom) - Math.max(cur.top, r.top) : 0;
if (cur && overlap > r.height / 2) {
cur.top = Math.min(cur.top, r.top);
cur.bottom = Math.max(cur.bottom, r.bottom);
} else {
lines.push({ top: r.top, bottom: r.bottom });
}
}
return [...byTop.values()].sort((a, b) => a.top - b.top);
return lines.map((l) => ({ top: l.top - origin, height: l.bottom - l.top }));
}
function measure(): void {
@@ -82,14 +89,22 @@
const out: { top: number; label: string }[] = [];
let count = 0;
let page = 1;
for (const block of Array.from(view.dom.children)) {
// A table, an image frame or an index is not a numbered line in either word
// processor; only text blocks count.
if (!/^(P|H1|H2|H3|H4|H5|H6|LI|BLOCKQUOTE)$/.test(block.tagName)) continue;
// Text blocks count — list items and column lines too, as in both word processors;
// a table, an image frame or an index is not a numbered line in either.
const blocks = view.dom.querySelectorAll(
':scope > :is(p, h1, h2, h3, h4, h5, h6, h7, h8, h9, h10, blockquote),'
+ ' :scope > :is(ul, ol) li > p,'
+ ' :scope > .columns-node :is(p, h1, h2, h3, h4, h5, h6, h7, h8, h9, h10)');
for (const block of Array.from(blocks)) {
if (block.closest('table')) continue;
const empty = !block.textContent?.trim();
for (const line of lineTops(block, origin)) {
const linePage = pageAt(line.top);
if (lineNumbering.restart === 'page' && linePage !== page) { page = linePage; count = 0; }
// A trailing block pushed past the page surface (into the gap) is no line of
// either page; numbering it would print into the gap.
const box = pageBoxes[linePage - 1];
if (box && line.top > box.top + box.height) continue;
if (empty && !lineNumbering.countEmpty) continue;
count += 1;
if (count % lineNumbering.interval === 0) {