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/chatgpt-summary-popup.test.mjs tests/chatgpt-pdf-relay.test.mjs tests/selection-gaps.test.mjs tests/paragraph-double-click.test.mjs"
},
"dependencies": {
"pdfjs-dist": "6.3.289",
Expand Down
166 changes: 166 additions & 0 deletions src/viewer/selection/paragraph-double-click.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { markdownFromRuns } from './markdown-copy.js';

// PDFs position font runs rather than expose HTML paragraphs. Prefer the PDF
// semantic blocks; otherwise identify nearby lines in one visual column.
function spanItems(spans) {
return spans.flatMap((span, index) => {
const text = span.textContent || '';
const rect = span.getBoundingClientRect();
return text.trim() && rect.width > 0 && rect.height > 0
? [{ span, index, text, left: rect.left, right: rect.right,
top: rect.top, bottom: rect.bottom, height: rect.height }] : [];
});
}

function sameLine(line, item) {
const overlap = Math.min(line.bottom, item.bottom) - Math.max(line.top, item.top);
const verticalMatch = overlap > Math.min(line.height, item.height) * 0.5;
const gap = Math.max(line.left - item.right, item.left - line.right, 0);
return verticalMatch && gap <= Math.max(12, Math.min(line.height, item.height) * 2.2);
}

function visualLines(items) {
const lines = [];
for (const item of [...items].sort((a, b) => a.top - b.top || a.left - b.left)) {
let line = lines.find(candidate => sameLine(candidate, item));
if (!line) {
line = { items: [], left: item.left, right: item.right, top: item.top,
bottom: item.bottom, height: item.height };
lines.push(line);
}
line.items.push(item);
line.left = Math.min(line.left, item.left);
line.right = Math.max(line.right, item.right);
line.top = Math.min(line.top, item.top);
line.bottom = Math.max(line.bottom, item.bottom);
line.height = line.bottom - line.top;
}
for (const line of lines) line.items.sort((a, b) => a.left - b.left || a.index - b.index);
return lines.sort((a, b) => a.top - b.top || a.left - b.left);
}

function continuesParagraph(previous, next) {
const height = Math.max(previous.height, next.height);
const separation = next.top - previous.top;
if (separation < height * 0.55 || separation > height * 1.95) return false;
if (next.items.some(item => item.span.dataset.markdownParagraphStart === 'true')) return false;
if ([...previous.items, ...next.items].some(item => item.span.dataset.copyBlock !== undefined)) return false;
// Exclude other columns, marginalia, and differently sized headings.
if (Math.min(previous.right, next.right) - Math.max(previous.left, next.left) <= 0) return false;
if (Math.abs(next.left - previous.left) > height * 5) return false;
if (Math.max(previous.height, next.height) / Math.min(previous.height, next.height) > 1.55) return false;
// First-line indentation marks a new paragraph; dedenting a wrapped
// second line is expected.
if (next.left - previous.left > height * 1.7) return false;
// A short sentence-ending line followed by a full line at the same margin
// is often an unindented paragraph boundary.
const previousText = previous.items.map(item => item.text).join('');
if (/[.!?][”"')\]]?\s*$/.test(previousText) &&
previous.right - previous.left > height * 5 &&
Math.abs(next.left - previous.left) < height * 1.2 &&
previous.right < next.right - height * 3 &&
previous.right - previous.left < (next.right - next.left) * 0.75) return false;
return true;
}

export function paragraphAtSpan(spans, clickedSpan) {
const items = spanItems(spans);
const clicked = items.find(item => item.span === clickedSpan);
if (!clicked) return null;
const block = clickedSpan.dataset.copyBlock;
if (block !== undefined) {
const tagged = items.filter(item => item.span.dataset.copyBlock === block);
if (tagged.length) return { lines: visualLines(tagged), spans: tagged.map(item => item.span) };
}

const lines = visualLines(items);
const anchor = lines.find(line => line.items.some(item => item.span === clickedSpan));
if (!anchor) return null;
// Do not let a parallel column interrupt this paragraph's line sequence.
const columnLines = lines.filter(line =>
Math.abs(line.left - anchor.left) < Math.max(line.height, anchor.height) * 6 &&
Math.min(line.right, anchor.right) > Math.max(line.left, anchor.left));
const index = columnLines.indexOf(anchor);
let start = index, end = index;
while (start > 0 && continuesParagraph(columnLines[start - 1], columnLines[start])) start--;
while (end < columnLines.length - 1 && continuesParagraph(columnLines[end], columnLines[end + 1])) end++;
const paragraphLines = columnLines.slice(start, end + 1);
return { lines: paragraphLines, spans: paragraphLines.flatMap(line => line.items.map(item => item.span)) };
}

export function paragraphCopyText(lines, markdown = false) {
const runs = [];
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
const items = lines[lineIndex].items;
for (let index = 0; index < items.length; index++) {
const item = items[index];
const prior = items[index - 1];
const gap = prior ? item.left - prior.right : 0;
runs.push({
text: item.text,
bold: item.span.dataset.markdownBold === 'true',
italic: item.span.dataset.markdownItalic === 'true',
newLine: lineIndex > 0 && index === 0,
// The bounds are already one paragraph: reflow visual lines into
// spaces even when the source PDF is not tagged.
role: 'P', block: 'clicked-paragraph',
spaceBefore: item.span.dataset.markdownSpaceBefore === 'true' ||
(prior && gap > Math.max(1, Math.min(prior.height, item.height) * 0.12)),
});
}
}
return markdownFromRuns(runs, { markdown, reflow: true }).trim();
}

function selectParagraph(doc, layer, spans) {
const ordered = [...layer.querySelectorAll('[data-markdown-item]')];
const indices = spans.map(span => ordered.indexOf(span)).filter(index => index >= 0);
if (!indices.length) return false;
const range = doc.createRange();
range.setStartBefore(ordered[Math.min(...indices)]);
range.setEndAfter(ordered[Math.max(...indices)]);
const selection = doc.getSelection();
if (!selection) return false;
selection.removeAllRanges();
selection.addRange(range);
return true;
}

export function installParagraphDoubleClick(root, markdownToggle, doc = document, view = window) {
if (!root) return;
let pendingCopy = null;
// Installed after the regular copy formatter. Override just this gesture's
// copy with visual paragraph order and optional Markdown formatting.
doc.addEventListener('copy', event => {
if (!pendingCopy || !event.clipboardData) return;
event.clipboardData.setData('text/plain', pendingCopy.text);
if (pendingCopy.markdown) event.clipboardData.setData('text/markdown', pendingCopy.text);
event.preventDefault();
pendingCopy.handled = true;
});
doc.addEventListener('dblclick', async event => {
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey ||
!event.target?.closest || event.target.closest('a, button, input, textarea, [contenteditable]')) return;
const span = event.target.closest('.text-layer [data-markdown-item]');
if (!span || !root.contains(span)) return;
const layer = span.closest('.text-layer');
if (!layer) return;
const paragraph = paragraphAtSpan([...layer.querySelectorAll('[data-markdown-item]')], span);
if (!paragraph || !paragraph.spans.length) return;
const markdown = markdownToggle?.checked === true;
const text = paragraphCopyText(paragraph.lines, markdown);
if (!text || !selectParagraph(doc, layer, paragraph.spans)) return;
event.preventDefault();
pendingCopy = { text, markdown, handled: false };
try {
let copied = false;
try { copied = doc.execCommand?.('copy') === true && pendingCopy.handled; } catch { /* blocked */ }
if (!copied) await view.navigator.clipboard.writeText(text);
view.dispatchEvent(new view.CustomEvent('pdf-viewer-toast', { detail: { message: 'Copied paragraph' } }));
} catch {
view.dispatchEvent(new view.CustomEvent('pdf-viewer-toast', { detail: { message: 'Could not copy paragraph' } }));
} finally {
pendingCopy = null;
}
});
}
2 changes: 2 additions & 0 deletions src/viewer/viewer.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { resolvePdfSource } from "./pdf-source.js";
import { readResumedPage, saveResumedPage } from "./page-resume.js";
import { findSearchMatches } from "./search/search-matches.js";
import { installMarkdownCopy, markPdfTextStyles, markPdfTextStructure } from "./selection/markdown-copy.js";
import { installParagraphDoubleClick } from "./selection/paragraph-double-click.js";
import { highlightTextLayer, registerSearchText } from "./search/search-highlight.js";
import { normalizeSearchText } from "./search/search-text.js";
import { PagePreviews } from "./page-previews.js";
Expand Down Expand Up @@ -1336,6 +1337,7 @@ async function downloadPdf() {

function bindControls() {
installMarkdownCopy(viewer, document.querySelector("#copy-as-markdown"), document, document.querySelector("#copy-tagged-paragraphs"));
installParagraphDoubleClick(viewer, document.querySelector("#copy-as-markdown"));
window.addEventListener("pdf-viewer-toast", (event) => {
if (typeof event.detail?.message === "string") showToast(event.detail.message);
});
Expand Down
81 changes: 81 additions & 0 deletions tests/paragraph-double-click.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { paragraphAtSpan, paragraphCopyText, installParagraphDoubleClick } from '../src/viewer/selection/paragraph-double-click.js';

function span(text, x, y, width = text.length * 6, dataset = {}, height = 12) {
return { textContent: text, dataset, getBoundingClientRect: () => ({
left: x, right: x + width, top: y, bottom: y + height, width, height,
}) };
}

test('selects all wrapped lines but stops at a new paragraph indent', () => {
const a = span('This is a paragraph', 20, 20, 160);
const b = span('continued on line two', 20, 35, 150);
const c = span('with a third line.', 20, 50, 140);
const d = span('A separate paragraph', 48, 65, 180);
const e = span('continues here', 20, 80, 130);
assert.deepEqual(paragraphAtSpan([a,b,c,d,e],b).spans, [a,b,c]);
assert.equal(paragraphCopyText(paragraphAtSpan([a,b,c,d,e],b).lines),
'This is a paragraph continued on line two with a third line.');
assert.deepEqual(paragraphAtSpan([a,b,c,d,e],e).spans, [d,e]);
});

test('uses semantic tags instead of visual guesses when available', () => {
const a = span('Tagged paragraph line one',20,20,175,{copyBlock:'5',copyRole:'P'});
const b = span('line two',20,35,80,{copyBlock:'5',copyRole:'P'});
const c = span('Different block',20,50,150,{copyBlock:'6',copyRole:'P'});
assert.deepEqual(paragraphAtSpan([a,b,c],b).spans,[a,b]);
});

test('excludes other columns and detached headings', () => {
const left1 = span('Left column line one',20,20,155);
const left2 = span('Left column line two',20,35,150);
const right1 = span('Right column line one',220,20,155);
const right2 = span('Right column line two',220,35,150);
const heading = span('Heading',20,0,55,{},20);
const items=[heading,right2,left1,right1,left2];
assert.deepEqual(paragraphAtSpan(items,left1).spans,[left1,left2]);
assert.deepEqual(paragraphAtSpan(items,right1).spans,[right1,right2]);
});

test('reflows split font runs using the existing Markdown formatter', () => {
const a = span('Hello',20,20,35,{markdownBold:'true'});
const b = span('world.',60,20,40,{markdownItalic:'true'});
const c = span('Again.',20,35,60);
const p=paragraphAtSpan([a,b,c],b);
assert.deepEqual(p.spans,[a,b,c]);
assert.equal(paragraphCopyText(p.lines,true),'**Hello** _world._ Again.');
});

test('double-click highlights and copies; unrelated clicks are untouched', async () => {
const first=span('Hello world',20,20,100);
const second=span('from the PDF.',20,35,100);
const spans=[first,second];
const layer={querySelectorAll:()=>spans};
for (const s of spans) s.closest=selector=>selector==='.text-layer'?layer:null;
const selection={removeAllRanges(){this.ranges=[];},addRange(r){this.ranges.push(r);}};
const handlers={}, clipboard={};
const doc={
addEventListener(type,handler){handlers[type]=handler;},
createRange(){return {setStartBefore(s){this.start=s;},setEndAfter(s){this.end=s;}};},
getSelection(){return selection;},
execCommand(command){
assert.equal(command,'copy');
handlers.copy({clipboardData:{setData(type,value){clipboard[type]=value;}},preventDefault(){}});
return true;
},
};
const events=[];
const view={dispatchEvent(e){events.push(e);},CustomEvent:class{constructor(type,{detail}){this.type=type;this.detail=detail;}}};
installParagraphDoubleClick({contains:()=>true},{checked:false},doc,view);
const target={closest(selector){
if(selector.startsWith('a,'))return null;
if(selector.startsWith('.text-layer'))return second;
return null;
}};
await handlers.dblclick({button:0,target,preventDefault(){}});
assert.equal(clipboard['text/plain'],'Hello world from the PDF.');
assert.equal(selection.ranges[0].start,first);
assert.equal(selection.ranges[0].end,second);
assert.equal(events[0].detail.message,'Copied paragraph');
});
Loading