diff --git a/src/lib/components/CLAUDE.md b/src/lib/components/CLAUDE.md index f8c2aae..84a0a36 100644 --- a/src/lib/components/CLAUDE.md +++ b/src/lib/components/CLAUDE.md @@ -224,7 +224,9 @@ One number per rendered line in the left margin. There is no CSS line box to rea 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 +both word processors but not tables, frames or indexes — though a top-level text box +counts as the one empty anchor-paragraph line the file gives it (probed in Word), and a +floating frame's band is no line of its anchor paragraph. 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. diff --git a/src/lib/components/LineNumberLayer.svelte b/src/lib/components/LineNumberLayer.svelte index 5525faf..7971c4e 100644 --- a/src/lib/components/LineNumberLayer.svelte +++ b/src/lib/components/LineNumberLayer.svelte @@ -60,7 +60,15 @@ function lineTops(block: Element, origin: number): { top: number; height: number }[] { const range = document.createRange(); range.selectNodeContents(block); - const rects = Array.from(range.getClientRects()).filter((r) => r.height > 0); + let rects = Array.from(range.getClientRects()).filter((r) => r.height > 0); + // A frame floated out of the flow (wrapped image, text box) is no text line of its + // anchor paragraph — its band would otherwise count as an extra line. + const frames = block.querySelectorAll('.image-node[data-wrap], .textbox-node'); + if (frames.length) { + const fr = Array.from(frames, (f) => f.getBoundingClientRect()); + rects = rects.filter((r) => !fr.some((f) => + r.top >= f.top - 1 && r.bottom <= f.bottom + 1 && r.left >= f.left - 1 && r.right <= f.right + 1)); + } if (!rects.length) { const r = block.getBoundingClientRect(); return r.height > 0 ? [{ top: r.top - origin, height: r.height }] : []; @@ -72,7 +80,7 @@ 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) { + if (cur && overlap > Math.min(cur.bottom - cur.top, r.height) / 2) { cur.top = Math.min(cur.top, r.top); cur.bottom = Math.max(cur.bottom, r.bottom); } else { @@ -90,15 +98,22 @@ let count = 0; let page = 1; // 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. + // a table, an image frame or an index is not a numbered line in either. A top-level + // text box counts as one empty line: in the file it hangs off an empty anchor + // paragraph, and both word processors count that line (probed in Word). const blocks = view.dom.querySelectorAll( ':scope > :is(p, h1, h2, h3, h4, h5, h6, h7, h8, h9, h10, blockquote),' + + ' :scope > .textbox-node,' + ' :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 anchor = block.classList.contains('textbox-node'); + const empty = anchor || !block.textContent?.trim(); + const lines = anchor + ? [{ top: block.getBoundingClientRect().top - origin, height: 18 }] + : lineTops(block, origin); + for (const line of lines) { 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