Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
77 changes: 72 additions & 5 deletions src/viewer/navigation/footnote-index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down
61 changes: 61 additions & 0 deletions tests/footnote-fallback-wrap.test.mjs
Original file line number Diff line number Diff line change
@@ -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/);
});
Loading