diff --git a/package.json b/package.json index 814a18e..7c610d1 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "node scripts/build.mjs", "check": "node scripts/check-extension.mjs", - "test": "node --test tests/enhancer-setup.test.mjs tests/document-title.test.mjs tests/metadata-doi.test.mjs tests/local-file-favicon.test.mjs tests/page-tab-labels.test.mjs tests/minimap.test.mjs tests/clickable-reference-links.test.mjs tests/printed-page-jump.test.mjs tests/markdown-copy.test.mjs tests/terminal-punctuation-selection.test.mjs tests/footnote-click-copy.test.mjs tests/chatgpt-summary-popup.test.mjs tests/chatgpt-pdf-relay.test.mjs tests/selection-gaps.test.mjs" + "test": "node --test tests/enhancer-setup.test.mjs tests/document-title.test.mjs tests/metadata-doi.test.mjs tests/local-file-favicon.test.mjs tests/page-tab-labels.test.mjs tests/minimap.test.mjs tests/clickable-reference-links.test.mjs tests/printed-page-jump.test.mjs tests/markdown-copy.test.mjs tests/terminal-punctuation-selection.test.mjs tests/footnote-click-copy.test.mjs tests/footnote-fallback-wrap.test.mjs tests/chatgpt-summary-popup.test.mjs tests/chatgpt-pdf-relay.test.mjs tests/selection-gaps.test.mjs" }, "dependencies": { "pdfjs-dist": "6.3.289", diff --git a/src/viewer/navigation/footnote-index.js b/src/viewer/navigation/footnote-index.js index 4df33a0..8a06ec9 100644 --- a/src/viewer/navigation/footnote-index.js +++ b/src/viewer/navigation/footnote-index.js @@ -90,13 +90,18 @@ export function candidateForPage(items, viewport, number, pageNumber, originPage continue; } + const paragraph = fallbackFootnoteParagraph(items, viewport, item, + adjacentText ? next : item, number, pageNumber); const candidate = { pageNumber, score, xRatio: coordinates.xRatio, yRatio: coordinates.yRatio, label: text, - highlightRegions: [{ pageNumber, ...textRegion({ ...coordinates, height: itemHeight, item }, viewport) }], + text: paragraph?.text || remainder, + highlightRegions: paragraph?.highlightRegions?.length + ? paragraph.highlightRegions + : [{ pageNumber, ...textRegion({ ...coordinates, height: itemHeight, item }, viewport) }], }; if (!best || candidate.score > best.score) { @@ -154,6 +159,49 @@ function highlightRegions(entries, viewport, pageNumber) { return lines.map(({ baseline, ...region }) => ({ pageNumber, ...region })); } +// Fallback candidates can contain only the first PDF.js text item of a note. +// Follow nearby, same-size lines conservatively so wrapped notes are highlighted +// and copied in full, without crossing a new note or the printed page footer. +function fallbackFootnoteParagraph(items, viewport, markerItem, bodyItem, number, pageNumber) { + const entries = positionedItems(items, viewport).sort(readingOrder); + const marker = entries.find((entry) => entry.item === markerItem); + const body = entries.find((entry) => entry.item === bodyItem); + if (!marker || !body) return null; + const height = body.height || marker.height; + if (!height) return null; + const firstBaseline = readingBaseline(body); + const minX = Math.max(0, Math.min(marker.x, body.x) - height * 4); + const maxX = Math.min(viewport.width * 0.96, + Math.max(viewport.width * 0.5, body.x + Math.abs(body.item.width || 0) + height * 3)); + const selected = []; + let lastBaseline = firstBaseline; + + for (const entry of entries) { + const baseline = readingBaseline(entry); + if (baseline < firstBaseline - Math.max(2, height * 0.4)) continue; + if (entry.yRatio >= 0.94) break; + if (entry.x < minX || entry.x > maxX) continue; + const laterLine = baseline > lastBaseline + Math.max(2, height * 0.25); + if (laterLine && (baseline - lastBaseline > height * 1.9 || + (entry.x <= marker.x + height && + /^\d{1,3}(?:[.)]|\s|$)/.test(entry.text)))) break; + if (entry !== marker && Math.abs(entry.height - height) > height * 0.35) { + if (laterLine) break; + continue; + } + if (baseline > firstBaseline + 2 && /^\d+$/.test(entry.text) && + entry.yRatio >= 0.9) break; + if (baseline <= firstBaseline + 2 && entry.x < marker.x - 2) continue; + selected.push(entry); + lastBaseline = Math.max(lastBaseline, baseline); + } + + if (!selected.includes(marker)) selected.unshift(marker); + const rawText = joinNoteEntries(selected.filter((entry) => entry !== marker || marker === body)); + const text = (marker === body ? rawText.replace(markerPattern(number), "") : rawText).trim(); + return { text, highlightRegions: highlightRegions(selected, viewport, pageNumber) }; +} + function readingBaseline(entry) { // OCR words on a tilted scan share a sloped baseline. Compare their // deskewed positions while retaining original coordinates for highlights. @@ -455,10 +503,29 @@ export function footnotesForPage(items, viewport, pageNumber, rules = [], refere (note.hangingMargin === undefined ? entry.yRatio === note.yRatio : entry.text === note.inferredMarker && Math.abs(entry.y - note.baseline) <= note.textHeight * 0.8)); const highlightedEntries = marker && !textEntries.includes(marker) ? [marker, ...textEntries] : textEntries; - if (text.length >= 3) result.push({ ...note, markerItemIndex: marker ? items.indexOf(marker.item) : -1, text, endPageNumber: pageNumber, endColumnIndex: column.index, - highlightRegions: highlightRegions(highlightedEntries, viewport, pageNumber), - continues: !next && textEntries.at(-1)?.yRatio >= 0.88 && - ((note.hangingMargin === undefined && !note.scannedSmallBlock) || !/[.!?]["')\]]?$/.test(text)) }); + if (text.length >= 3) { + let fullText = text; + let regions = highlightRegions(highlightedEntries, viewport, pageNumber); + // PDF.js can assign larger text heights to wrapped lines. Repair a + // truncated final note without crossing the page counter or next note. + if (!next && layout.length === 1 && note.yRatio >= 0.65 && textEntries.length <= 2 && marker) { + const body = textEntries.find((entry) => entry !== marker && /[A-Za-z]/.test(entry.text)); + const recovered = body && fallbackFootnoteParagraph(items, viewport, + marker.item, body.item, note.number, pageNumber); + if (recovered && recovered.text.length > fullText.length && + recovered.highlightRegions.length > regions.length) { + fullText = recovered.text; + regions = recovered.highlightRegions; + } + } + const reachedBottom = textEntries.at(-1)?.yRatio >= 0.88 || + (fullText !== text && regions.some((region) => region.top + region.height >= 0.88)); + result.push({ ...note, markerItemIndex: marker ? items.indexOf(marker.item) : -1, + text: fullText, endPageNumber: pageNumber, endColumnIndex: column.index, + highlightRegions: regions, + continues: !next && reachedBottom && + ((note.hangingMargin === undefined && !note.scannedSmallBlock) || !/[.!?]["')\\]]?$/.test(fullText)) }); + } } } return result; diff --git a/tests/footnote-fallback-wrap.test.mjs b/tests/footnote-fallback-wrap.test.mjs new file mode 100644 index 0000000..d8ccce0 --- /dev/null +++ b/tests/footnote-fallback-wrap.test.mjs @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { candidateForPage, footnotesForPage } from "../src/viewer/navigation/footnote-index.js"; + +// A condensed text-layer reproduction of NBER w9126, printed page 19: +// footnote 19 wraps to two hanging lines and a printed page counter follows. +const viewport = { + width: 612, height: 792, + convertToViewportPoint(x, y) { return [x, 792 - y]; }, +}; + +function line(text, x, y, width, height = 11) { + return { str: text, width, height, transform: [1, 0, 0, height, x, 792 - y] }; +} + +test("fallback footnote highlights every wrapped line, excluding the prior note and page number", () => { + const items = [ + line("18 The miscellaneous category is omitted.", 90, 615, 275), + line("from the regressions.", 71, 629, 140), + line("19 Two firms reached agreement before the annual", 90, 652, 420), + line("meeting; the statistical analysis is unchanged.", 71, 666, 410), + line("The indicator remains one or zero.", 71, 680, 295), + line("19", 301, 733, 12, 12), + ]; + const note = candidateForPage(items, viewport, 19, 19, 19); + assert.ok(note); + assert.equal(note.highlightRegions.length, 3); + assert.match(note.text, /statistical analysis is unchanged/); + assert.match(note.text, /remains one or zero/); + assert.doesNotMatch(note.text, /miscellaneous|regressions/); + assert.ok(note.highlightRegions.every((region) => region.top > 0.8 && region.top < 0.9)); +}); + +test("fallback footnote stops before an adjacent numbered note", () => { + const items = [ + line("19 First note spans", 90, 652, 250), + line("another line of text.", 71, 666, 210), + line("20 Next footnote starts here.", 90, 681, 320), + ]; + const note = candidateForPage(items, viewport, 19, 19, 19); + assert.ok(note); + assert.equal(note.highlightRegions.length, 2); + assert.doesNotMatch(note.text, /Next footnote/); +}); + +test("indexed footnote recovers wrapped lines with different PDF text heights", () => { + const items = [ + line("A representative paragraph above the notes.", 71, 220, 400, 16), + line("19", 91, 647, 12, 8), + line("Two firms agreed before the annual meeting;", 104, 652, 415, 11), + line("the analysis remains unchanged for the sample.", 71, 667, 432, 14), + line("The proxy-fight indicator stays one or zero.", 71, 683, 395, 14), + line("19", 301, 733, 12, 14), + ]; + const note = footnotesForPage(items, viewport, 19).find((entry) => entry.number === 19); + assert.ok(note); + assert.match(note.text, /analysis remains unchanged/); + assert.match(note.text, /indicator stays one or zero/); + assert.ok(note.highlightRegions.length >= 3); + assert.doesNotMatch(note.text, /representative paragraph/); +});