实现pdf中划句子ai伴读的功能,修复pdf高亮显示的问题

This commit is contained in:
yhy
2026-08-29 23:18:15 +08:00
parent 0ed8f42029
commit 913e3675ae
14 changed files with 789 additions and 68 deletions
+29
View File
@@ -1038,6 +1038,35 @@ collapsible-section[open] > :not(.head) .chatpapers-lecture-root {
overflow-y: auto;
}
.chatpapers-lecture-selection-explain {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px;
border-radius: 8px;
background: color-mix(in srgb, #2563eb 6%, transparent);
border: 1px solid color-mix(in srgb, #2563eb 18%, transparent);
font-size: 12px;
line-height: 1.45;
max-height: 220px;
overflow-y: auto;
}
.chatpapers-lecture-selection-title {
font-size: 12px;
font-weight: 700;
color: #1d4ed8;
}
.chatpapers-lecture-selection-replay {
align-self: flex-start;
margin-top: 4px;
}
.chatpapers-selection-explain-btn {
white-space: nowrap;
}
.chatpapers-lecture-para-kind {
font-size: 11px;
font-weight: 600;
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="context-stroke" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Lucide: FoldVertical (adaptive off / fixed height) -->
<path d="M12 22v-6"/>
<path d="M12 8V2"/>
<path d="M4 12H2"/>
<path d="M10 12H8"/>
<path d="M16 12h-2"/>
<path d="M22 12h-2"/>
<path d="m15 19-3-3-3 3"/>
<path d="m15 5-3 3-3-3"/>
</svg>

After

Width:  |  Height:  |  Size: 449 B

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="context-stroke" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Lucide: UnfoldVertical (adaptive on) -->
<path d="M12 22v-6"/>
<path d="M12 8V2"/>
<path d="M4 12H2"/>
<path d="M10 12H8"/>
<path d="M16 12h-2"/>
<path d="M22 12h-2"/>
<path d="m15 19-3 3-3-3"/>
<path d="m15 5-3-3-3 3"/>
</svg>

After

Width:  |  Height:  |  Size: 435 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="context-stroke" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Lucide: AppWindow -->
<rect x="2" y="4" width="20" height="16" rx="2"/>
<path d="M10 4v4"/>
<path d="M2 8h20"/>
<path d="M6 4v4"/>
</svg>

After

Width:  |  Height:  |  Size: 337 B

+10
View File
@@ -101,6 +101,16 @@ lecture-sentence-list-title = Sentences (original, then explanation)
lecture-sentence-label = P{ $p }·S{ $s }
lecture-sentence-list-hint = Sentences in this paragraph
lecture-pdf-sync-hint = The matching passage is highlighted in the PDF reader while playing.
lecture-explain-selection = Explain & read selection
lecture-explain-selection-short = Explain
lecture-selection-empty = Select a sentence in the PDF first
lecture-selection-explaining = Generating explanation for selection…
lecture-selection-done = Selection explanation ready
lecture-selection-no-item = Cannot resolve the current PDF item
lecture-selection-title = Selection explain
lecture-selection-syntax = Syntax / terms
lecture-selection-context = Context role
lecture-selection-replay = Replay explanation
lecture-playing-en = Playing: original
lecture-playing-zh = Playing: translation
lecture-status-ready-step2 = Ready: { $count } paragraphs + note synced
+10
View File
@@ -101,6 +101,16 @@ lecture-sentence-list-title = 逐句精读(先读原文,再听讲解)
lecture-sentence-label = 段{ $p }·句{ $s }
lecture-sentence-list-hint = 本段各句
lecture-pdf-sync-hint = 播放时会在 PDF 阅读器中高亮对应原文。
lecture-explain-selection = 解释朗读选区
lecture-explain-selection-short = 解释朗读
lecture-selection-empty = 请先在 PDF 中选中要解释的句子
lecture-selection-explaining = 正在生成选区讲解…
lecture-selection-done = 选区讲解完成
lecture-selection-no-item = 无法识别当前 PDF 条目
lecture-selection-title = 选区讲解
lecture-selection-syntax = 句法/术语
lecture-selection-context = 上下文作用
lecture-selection-replay = 重播讲解
lecture-playing-en = 正在播放:原文朗读
lecture-playing-zh = 正在播放:中文讲解
lecture-status-ready-step2 = 备课完成:{ $count } 段逐段精读可播放,笔记已同步
@@ -1,6 +1,14 @@
import { pathToFileUrl } from "../infrastructure/platform/paths";
import type { SelectionExplainResult } from "./explainSelectionLecture";
export interface LecturePlaybackHandle {
pause: () => void;
}
let playbackGeneration = 0;
let explainSessionId = 0;
const panePlaybackHandles = new Set<LecturePlaybackHandle>();
function waitAudioEnded(audio: HTMLAudioElement): Promise<void> {
return new Promise((resolve, reject) => {
const cleanup = () => {
@@ -25,18 +33,70 @@ function audioPaths(result: SelectionExplainResult): string[] {
return paths;
}
/** Stop standalone + all registered lecture pane players. */
export function stopAllLecturePlayback(): void {
playbackGeneration += 1;
standaloneAudio?.pause();
if (standaloneAudio) {
standaloneAudio.onended = null;
standaloneAudio.removeAttribute("src");
}
for (const handle of panePlaybackHandles) {
try {
handle.pause();
} catch (e) {
ztoolkit.log("[ChatPapers:Lecture] pause registered player failed", e);
}
}
}
export function registerLecturePlaybackHandle(
handle: LecturePlaybackHandle,
): () => void {
panePlaybackHandles.add(handle);
return () => {
panePlaybackHandles.delete(handle);
};
}
/** Begin a new explain flow; cancels any in-flight playback and explain session. */
export function beginSelectionExplainSession(): number {
explainSessionId += 1;
stopAllLecturePlayback();
return explainSessionId;
}
export function isSelectionExplainSessionActive(sessionId: number): boolean {
return sessionId === explainSessionId;
}
export function cancelSelectionExplainSessions(): void {
explainSessionId += 1;
stopAllLecturePlayback();
}
export async function playSelectionExplainAudio(
audio: HTMLAudioElement,
result: SelectionExplainResult,
options?: { generation?: number },
): Promise<void> {
const paths = audioPaths(result);
if (!paths.length) return;
const generation = options?.generation ?? playbackGeneration;
audio.pause();
audio.onended = null;
for (const path of paths) {
if (generation !== playbackGeneration) return;
audio.src = pathToFileUrl(path);
audio.load();
try {
await audio.play();
} catch (e) {
if (generation !== playbackGeneration) return;
throw e;
}
await waitAudioEnded(audio);
}
}
@@ -46,20 +106,36 @@ let standaloneAudio: HTMLAudioElement | undefined;
function getStandaloneAudio(): HTMLAudioElement {
if (standaloneAudio) return standaloneAudio;
const win = Zotero.getMainWindow?.() as Window | undefined;
const doc = win?.document ?? document;
standaloneAudio = doc.createElement("audio");
standaloneAudio.preload = "none";
standaloneAudio.style.display = "none";
doc.body?.appendChild(standaloneAudio);
return standaloneAudio;
const doc = win?.document;
if (!doc) {
throw new Error("Cannot resolve main window document for audio playback");
}
const audio = doc.createElement("audio");
audio.preload = "none";
audio.style.display = "none";
doc.body?.appendChild(audio);
standaloneAudio = audio;
return audio;
}
/** Play selection explain on one audio element, stopping every other lecture audio first. */
export async function playSelectionExplainExclusive(
result: SelectionExplainResult,
audio?: HTMLAudioElement,
): Promise<void> {
stopAllLecturePlayback();
const generation = playbackGeneration;
await playSelectionExplainAudio(audio ?? getStandaloneAudio(), result, {
generation,
});
}
export async function playSelectionExplainStandalone(
result: SelectionExplainResult,
): Promise<void> {
await playSelectionExplainAudio(getStandaloneAudio(), result);
await playSelectionExplainExclusive(result);
}
export function stopSelectionExplainStandalone(): void {
standaloneAudio?.pause();
stopAllLecturePlayback();
}
+152 -2
View File
@@ -47,7 +47,14 @@ import {
explainSelectionLecture,
type SelectionExplainResult,
} from "../application/explainSelectionLecture";
import { playSelectionExplainAudio } from "../application/playSelectionExplain";
import {
beginSelectionExplainSession,
isSelectionExplainSessionActive,
playSelectionExplainAudio,
playSelectionExplainExclusive,
registerLecturePlaybackHandle,
stopAllLecturePlayback,
} from "../application/playSelectionExplain";
import { getString } from "../../../utils/locale";
import { createLucideIcon } from "../../../utils/icons";
@@ -70,6 +77,8 @@ export class LecturePaneView {
private pdfSyncEnabled = true;
private explainingSelection = false;
private lastSelectionResult?: SelectionExplainResult;
private unregisterPlayback?: () => void;
private explainSessionId = 0;
private lectureChunks?: import("../infrastructure/pdf/chunker").TextChunk[];
private statusEl!: HTMLElement;
@@ -802,6 +811,139 @@ export class LecturePaneView {
);
}
private async onExplainSelection(): Promise<void> {
if (this.explainingSelection) return;
let selectedText = "";
try {
selectedText = getReaderSelectedText();
} catch (e) {
ztoolkit.log("[ChatPapers:Lecture] get selection failed", e);
}
if (!selectedText.trim()) {
this.setStatus("idle", getString("lecture-selection-empty"), "warn");
return;
}
this.explainingSelection = true;
this.explainSelectionBtn.disabled = true;
this.setStatus("summarizing", getString("lecture-selection-explaining"));
try {
const result = await explainSelectionLecture({
item: this.item,
selectedText,
onProgress: (message) => this.setStatus("summarizing", message),
});
await this.applySelectionExplainResult(result);
this.setStatus("ready", getString("lecture-selection-done"));
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
this.setStatus("failed", msg);
} finally {
this.explainingSelection = false;
this.explainSelectionBtn.disabled = false;
}
}
private async applySelectionExplainResult(
result: SelectionExplainResult,
): Promise<void> {
this.lastSelectionResult = result;
this.renderSelectionExplain(result);
await this.syncPdfHighlight(result.original, result.page);
if (result.fromPrepared && result.matched) {
if (!this.paragraphPlayer) {
await this.refreshPlayer();
}
if (this.paragraphPlayer) {
this.playMode = "paragraph";
this.readGranularity = "sentence";
this.updateModeTabs();
this.paragraphPlayer.setGranularity("sentence");
const kind = result.audioPathEn ? "en" : "zh";
if (result.matched.sentenceId.endsWith("-whole")) {
await this.paragraphPlayer.jumpToParagraphId(
result.matched.paragraphId,
kind,
);
} else {
await this.paragraphPlayer.jumpToSentenceId(
result.matched.sentenceId,
kind,
);
}
return;
}
}
if (!this.audioEl) return;
this.paragraphPlayer?.pause();
this.player?.pause();
this.playBtn.hidden = true;
this.pauseBtn.hidden = false;
try {
await playSelectionExplainAudio(this.audioEl, result);
} catch (e) {
ztoolkit.log("[ChatPapers:Lecture] selection audio failed", e);
} finally {
this.playBtn.hidden = false;
this.pauseBtn.hidden = true;
}
}
private renderSelectionExplain(result: SelectionExplainResult): void {
this.selectionExplainEl.replaceChildren();
this.selectionExplainEl.hidden = false;
const title = this.el(
"div",
"chatpapers-lecture-selection-title",
getString("lecture-selection-title"),
);
this.selectionExplainEl.append(title);
this.selectionExplainEl.append(
this.el("div", "chatpapers-lecture-para-original-label", getString("lecture-para-original")),
this.el("div", "chatpapers-lecture-para-original is-active-sentence", result.original),
this.el("div", "chatpapers-lecture-para-translation-label", getString("lecture-para-translation")),
this.el("div", "chatpapers-lecture-para-translation", result.translation),
);
if (result.syntaxNote) {
this.selectionExplainEl.append(
this.el("div", "chatpapers-lecture-para-context-label", getString("lecture-selection-syntax")),
this.el("div", "chatpapers-lecture-para-context", result.syntaxNote),
);
}
if (result.contextRole) {
this.selectionExplainEl.append(
this.el("div", "chatpapers-lecture-para-context-label", getString("lecture-selection-context")),
this.el("div", "chatpapers-lecture-para-context", result.contextRole),
);
}
const replayBtn = this.doc.createElement("button");
replayBtn.type = "button";
replayBtn.className = "chatpapers-lecture-secondary chatpapers-lecture-selection-replay";
replayBtn.append(
createLucideIcon(this.doc, Volume2, {
size: 14,
className: "chatpapers-lecture-btn-icon",
}),
this.doc.createTextNode(getString("lecture-selection-replay")),
);
replayBtn.addEventListener("click", () => {
if (!this.audioEl || !this.lastSelectionResult) return;
void playSelectionExplainAudio(this.audioEl, this.lastSelectionResult);
});
this.selectionExplainEl.append(replayBtn);
}
private async onSaveNote(): Promise<void> {
if (!this.cacheKey) return;
const data = await loadLectureData(this.cacheKey);
@@ -906,8 +1048,14 @@ export class LecturePaneView {
}
}
private setStatus(status: LectureStatus | "idle", message?: string): void {
private setStatus(
status: LectureStatus | "idle",
message?: string,
kind?: "warn" | "error" | "info",
): void {
this.statusEl.dataset.status = status;
if (kind) this.statusEl.dataset.kind = kind;
else this.statusEl.removeAttribute("data-kind");
this.statusTextEl.textContent =
message || getString("lecture-status-idle");
const busy = [
@@ -918,6 +1066,8 @@ export class LecturePaneView {
"tts_generating",
].includes(status);
this.actionBtn.disabled = this.preparing || busy;
this.explainSelectionBtn.disabled =
this.preparing || busy || this.explainingSelection;
this.ttsTestBtn.disabled = this.ttsTesting || this.preparing;
}
@@ -1,11 +1,15 @@
import { config } from "../../../../package.json";
import { getString } from "../../../utils/locale";
import { cacheReaderSelection } from "../../pdf/selection";
import { cacheReaderSelection, getCachedReaderSelectionPosition } from "../../pdf/selection";
import { highlightTextInPdf } from "../../pdf/readerSync";
import { getRegularItem, resolvePaperAttachment } from "../infrastructure/zotero/adapter";
import { paperIdFromCacheKey } from "../infrastructure/pdf/fileHash";
import { explainSelectionLecture } from "../application/explainSelectionLecture";
import { playSelectionExplainStandalone } from "../application/playSelectionExplain";
import {
beginSelectionExplainSession,
isSelectionExplainSessionActive,
playSelectionExplainExclusive,
} from "../application/playSelectionExplain";
import { lectureEvents } from "../infrastructure/events";
function tryString(value: unknown): string {
@@ -47,6 +51,7 @@ export async function runSelectionExplainFromReader(
}
cacheReaderSelection(text);
const sessionId = beginSelectionExplainSession();
showHeadline(getString("lecture-selection-explaining"));
try {
@@ -57,9 +62,12 @@ export async function runSelectionExplainFromReader(
onProgress: (message) => showHeadline(message),
});
if (!isSelectionExplainSessionActive(sessionId)) return;
await highlightTextInPdf(item, {
text: result.original,
page: result.page,
position: getCachedReaderSelectionPosition(result.original),
});
lectureEvents.emit("lecture:selection_explain", {
@@ -69,7 +77,9 @@ export async function runSelectionExplainFromReader(
result,
});
await playSelectionExplainStandalone(result);
if (!isSelectionExplainSessionActive(sessionId)) return;
await playSelectionExplainExclusive(result);
showHeadline(getString("lecture-selection-done"));
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
@@ -96,11 +106,11 @@ export function registerSelectionExplainPopup(): void {
"toolbar-button wide-button chatpapers-selection-explain-btn";
btn.textContent = getString("lecture-explain-selection-short");
btn.title = getString("lecture-explain-selection");
btn.addEventListener("click", (ev) => {
btn.addEventListener("click", (ev: Event) => {
ev.preventDefault();
ev.stopPropagation();
void runSelectionExplainFromReader(reader, text);
});
}, { once: true });
append(btn);
} catch (e) {
ztoolkit.log("[ChatPapers:Lecture] selection popup button failed", e);
+329 -40
View File
@@ -1,9 +1,19 @@
/**
* Sync lecture playback to Zotero PDF reader: navigate + in-document find/highlight.
* Sync lecture playback to Zotero PDF reader: navigate + multi-rect highlight.
* Uses internal reader APIs (best-effort; may break across Zotero versions).
*/
import { findPdfAttachment } from "./extractor";
import {
getCachedReaderSelectionPosition,
normalizeHighlightText,
} from "./selection";
export interface PdfPosition {
pageIndex: number;
rects?: number[][];
nextPageRects?: number[][];
}
function getInternalReader(reader: any): any {
if (!reader) return undefined;
@@ -53,10 +63,7 @@ function findReaderForAttachment(attachmentId: number): any {
/** Build a short phrase suitable for PDF in-document search. */
export function buildPdfSearchQuery(text: string, maxLen = 72): string {
const normalized = text
.replace(/[\u0000-\u001f\f]/g, " ")
.replace(/\s+/g, " ")
.trim();
const normalized = normalizeHighlightText(text);
if (!normalized) return "";
if (normalized.length <= maxLen) return normalized;
const snippet = normalized.slice(0, maxLen);
@@ -64,25 +71,124 @@ export function buildPdfSearchQuery(text: string, maxLen = 72): string {
return lastSpace > 32 ? snippet.slice(0, lastSpace) : snippet;
}
/** Multiple progressively shorter queries — PDF text may differ from extraction. */
function trimQuery(text: string, maxLen: number): string {
const normalized = normalizeHighlightText(text);
if (!normalized) return "";
if (normalized.length <= maxLen) return normalized;
const snippet = normalized.slice(0, maxLen);
const lastSpace = snippet.lastIndexOf(" ");
return lastSpace > 24 ? snippet.slice(0, lastSpace) : snippet;
}
/** Prefer full text first — PDF page content joins line breaks as spaces. */
function buildQueryCandidates(text: string): string[] {
const normalized = text
.replace(/[\u0000-\u001f\f]/g, " ")
.replace(/\s+/g, " ")
.trim();
const words = normalized.split(/\s+/).filter((w) => w.length > 1);
const normalized = normalizeHighlightText(text);
if (!normalized) return [];
const out: string[] = [];
const push = (q: string) => {
const t = q.trim();
if (t.length >= 6) out.push(t);
};
if (words.length >= 8) out.push(words.slice(0, 10).join(" "));
if (words.length >= 5) out.push(words.slice(0, 6).join(" "));
if (words.length >= 3) out.push(words.slice(0, 4).join(" "));
push(trimQuery(normalized, 480));
push(trimQuery(normalized, 320));
push(trimQuery(normalized, 220));
push(trimQuery(normalized, 140));
push(buildPdfSearchQuery(normalized, 96));
push(buildPdfSearchQuery(normalized, 72));
const q50 = buildPdfSearchQuery(text, 50);
const q30 = buildPdfSearchQuery(text, 30);
if (q50) out.push(q50);
if (q30 && q30 !== q50) out.push(q30);
const words = normalized.split(/\s+/).filter((w) => w.length > 1);
if (words.length >= 12) push(words.slice(0, 14).join(" "));
if (words.length >= 8) push(words.slice(0, 10).join(" "));
if (words.length >= 5) push(words.slice(0, 6).join(" "));
return [...new Set(out.filter((q) => q.length >= 8))];
return [...new Set(out)];
}
/** Split wrapped sentences into line-sized chunks for fallback per-line find. */
function splitLineSegments(text: string): string[] {
const normalized = normalizeHighlightText(text);
if (!normalized) return [];
const explicitLines = text
.split(/\n+/)
.map((line) => normalizeHighlightText(line))
.filter((line) => line.length >= 6);
const source = explicitLines.length > 1 ? explicitLines : [normalized];
const segments: string[] = [];
for (const line of source) {
if (line.length <= 88) {
segments.push(line);
continue;
}
const words = line.split(/\s+/).filter(Boolean);
let buf: string[] = [];
let len = 0;
for (const word of words) {
const nextLen = len ? len + 1 + word.length : word.length;
if (nextLen > 72 && buf.length) {
segments.push(buf.join(" "));
buf = [word];
len = word.length;
} else {
buf.push(word);
len = nextLen;
}
}
if (buf.length) segments.push(buf.join(" "));
}
return [...new Set(segments.filter((s) => s.length >= 6))];
}
function cloneIntoFrame<T>(value: T, win: Window | undefined): T {
if (!win) return value;
try {
const utils = (Components as any)?.utils;
if (utils?.cloneInto) return utils.cloneInto(value, win);
} catch {
// ignore
}
return value;
}
function positionRectCount(position?: PdfPosition | null): number {
if (!position) return 0;
return (position.rects?.length ?? 0) + (position.nextPageRects?.length ?? 0);
}
function mergePositions(primary: PdfPosition, extra: PdfPosition): PdfPosition {
const merged: PdfPosition = {
pageIndex: primary.pageIndex,
rects: [...(primary.rects ?? [])],
};
if (primary.nextPageRects?.length) {
merged.nextPageRects = [...primary.nextPageRects];
}
if (extra.pageIndex === primary.pageIndex) {
merged.rects!.push(...(extra.rects ?? []));
return merged;
}
if (extra.pageIndex === primary.pageIndex + 1) {
merged.nextPageRects = [
...(merged.nextPageRects ?? []),
...(extra.rects ?? []),
...(extra.nextPageRects ?? []),
];
return merged;
}
if (extra.pageIndex === primary.pageIndex - 1) {
return {
pageIndex: extra.pageIndex,
rects: [...(extra.rects ?? []), ...(merged.rects ?? [])],
nextPageRects: merged.nextPageRects,
};
}
return merged;
}
async function waitForReaderReady(reader: any): Promise<void> {
@@ -138,12 +244,7 @@ function applyFindHighlight(reader: any, query: string): boolean {
try {
if (internal?._updateState) {
const win = reader?._iframeWindow;
const state =
win && typeof Components !== "undefined"
? Components.utils.cloneInto(payload, win)
: payload;
internal._updateState(state);
internal._updateState(cloneIntoFrame(payload, reader?._iframeWindow));
try {
internal.findNext?.(true);
} catch {
@@ -180,12 +281,7 @@ function clearFindHighlight(reader: any): void {
try {
const internal = getInternalReader(reader);
if (internal?._updateState) {
const win = reader?._iframeWindow;
const state =
win && typeof Components !== "undefined"
? Components.utils.cloneInto(payload, win)
: payload;
internal._updateState(state);
internal._updateState(cloneIntoFrame(payload, reader?._iframeWindow));
return;
}
getPrimaryView(reader)?.setFindState?.(cleared);
@@ -194,9 +290,188 @@ function clearFindHighlight(reader: any): void {
}
}
function clearPositionHighlight(reader: any): void {
const view = getPrimaryView(reader);
if (!view) return;
try {
view._readAloudHighlightedPosition = null;
view._readAloudSentenceHighlightedPosition = null;
view._highlightedPosition = null;
view._render?.();
} catch (e) {
ztoolkit.log("[ChatPapers:ReaderSync] clear position highlight failed", e);
}
}
function applyPositionHighlight(reader: any, position: PdfPosition): boolean {
const view = getPrimaryView(reader);
if (!view || !positionRectCount(position)) return false;
try {
if (typeof view.navigateToPosition === "function") {
void view.navigateToPosition(position, {
ifNeeded: true,
block: "nearest",
inline: "nearest",
});
}
view._readAloudHighlightedPosition = position;
view._readAloudSentenceHighlightedPosition = null;
view._highlightedPosition = null;
view._render?.();
return true;
} catch (e) {
ztoolkit.log("[ChatPapers:ReaderSync] applyPositionHighlight failed", e);
return false;
}
}
function normalizePdfPosition(raw: any, pageHint?: number): PdfPosition | undefined {
if (!raw || typeof raw !== "object") return undefined;
const pageIndex =
typeof raw.pageIndex === "number"
? raw.pageIndex
: pageHint && pageHint > 0
? pageHint - 1
: undefined;
if (pageIndex == null || pageIndex < 0) return undefined;
const rects = Array.isArray(raw.rects)
? raw.rects.filter((r: unknown) => Array.isArray(r) && r.length === 4)
: undefined;
const nextPageRects = Array.isArray(raw.nextPageRects)
? raw.nextPageRects.filter((r: unknown) => Array.isArray(r) && r.length === 4)
: undefined;
if (!rects?.length && !nextPageRects?.length) return undefined;
return {
pageIndex,
rects: rects?.length ? rects : undefined,
nextPageRects: nextPageRects?.length ? nextPageRects : undefined,
};
}
function pickBestMatchPosition(
positions: PdfPosition[],
preferredPageIndex?: number,
): PdfPosition | undefined {
if (!positions.length) return undefined;
let best = positions[0];
let bestScore = -1;
for (const pos of positions) {
let score = positionRectCount(pos) * 10;
if (
preferredPageIndex != null &&
pos.pageIndex === preferredPageIndex - 1
) {
score += 100;
}
if (pos.nextPageRects?.length) score += 5;
if (score > bestScore) {
bestScore = score;
best = pos;
}
}
return best;
}
async function waitForFindPosition(
view: any,
pageHint?: number,
): Promise<PdfPosition | undefined> {
const preferredPageIndex = pageHint && pageHint > 0 ? pageHint : undefined;
for (let i = 0; i < 18; i++) {
const fromResult = normalizePdfPosition(
view?._findState?.result?.annotation?.position,
preferredPageIndex,
);
if (fromResult && positionRectCount(fromResult) > 0) {
return fromResult;
}
try {
const pageIndexes = new Set<number>();
if (preferredPageIndex) pageIndexes.add(preferredPageIndex - 1);
const current =
view?._iframeWindow?.PDFViewerApplication?.pdfViewer?.currentPageNumber;
if (typeof current === "number" && current > 0) {
pageIndexes.add(current - 1);
}
const collected: PdfPosition[] = [];
for (const pageIndex of pageIndexes) {
const matches =
(await view?._findController?.getMatchPositionsAsync?.(pageIndex)) ??
[];
for (const match of matches) {
const pos = normalizePdfPosition(match, pageIndex + 1);
if (pos) collected.push(pos);
}
}
const best = pickBestMatchPosition(collected, preferredPageIndex);
if (best) return best;
} catch {
// ignore
}
await Zotero.Promise.delay(120);
}
return undefined;
}
async function resolveTextPosition(
reader: any,
text: string,
pageHint?: number,
): Promise<PdfPosition | undefined> {
const view = getPrimaryView(reader);
if (!view) return undefined;
const candidates = buildQueryCandidates(text);
for (const query of candidates) {
if (!applyFindHighlight(reader, query)) continue;
const position = await waitForFindPosition(view, pageHint);
if (position && positionRectCount(position) > 0) {
ztoolkit.log(
"[ChatPapers:ReaderSync] matched query",
query.slice(0, 48),
"rects=",
positionRectCount(position),
);
return position;
}
}
const segments = splitLineSegments(text);
if (segments.length <= 1) return undefined;
let merged: PdfPosition | undefined;
for (const segment of segments) {
if (!applyFindHighlight(reader, segment)) continue;
const position = await waitForFindPosition(view, pageHint);
if (!position) continue;
merged = merged ? mergePositions(merged, position) : position;
}
if (merged && positionRectCount(merged) > 0) {
ztoolkit.log(
"[ChatPapers:ReaderSync] merged segment highlight rects=",
positionRectCount(merged),
);
return merged;
}
return undefined;
}
export interface PdfHighlightTarget {
text: string;
page?: number;
position?: PdfPosition;
}
/** Open PDF (if needed), jump to page, and highlight matching text. */
@@ -207,9 +482,6 @@ export async function highlightTextInPdf(
const attachment = findPdfAttachment(item);
if (!attachment || !target.text?.trim()) return;
const candidates = buildQueryCandidates(target.text);
if (!candidates.length) return;
try {
let reader = findReaderForAttachment(attachment.id);
if (!reader) {
@@ -219,16 +491,32 @@ export async function highlightTextInPdf(
await waitForReaderReady(reader);
await navigateReader(reader, target.page);
await Zotero.Promise.delay(350);
await Zotero.Promise.delay(250);
for (const query of candidates) {
if (applyFindHighlight(reader, query)) {
await Zotero.Promise.delay(200);
ztoolkit.log("[ChatPapers:ReaderSync] find applied:", query.slice(0, 40));
const cachedPosition = getCachedReaderSelectionPosition(target.text);
const position =
normalizePdfPosition(target.position, target.page) ??
normalizePdfPosition(cachedPosition, target.page) ??
(await resolveTextPosition(reader, target.text, target.page));
if (position && applyPositionHighlight(reader, position)) {
applyFindHighlight(
reader,
buildQueryCandidates(target.text)[0] ?? buildPdfSearchQuery(target.text, 96),
);
return;
}
const fallbackQuery = buildQueryCandidates(target.text)[0];
if (fallbackQuery && applyFindHighlight(reader, fallbackQuery)) {
ztoolkit.log(
"[ChatPapers:ReaderSync] find-only fallback:",
fallbackQuery.slice(0, 48),
);
return;
}
ztoolkit.log("[ChatPapers:ReaderSync] all find attempts failed");
ztoolkit.log("[ChatPapers:ReaderSync] all highlight attempts failed");
} catch (e) {
ztoolkit.log("[ChatPapers:ReaderSync] highlightTextInPdf failed", e);
}
@@ -242,6 +530,7 @@ export async function clearPdfHighlight(item: Zotero.Item): Promise<void> {
const reader = findReaderForAttachment(attachment.id);
if (!reader) return;
clearFindHighlight(reader);
clearPositionHighlight(reader);
} catch (e) {
ztoolkit.log("[ChatPapers:ReaderSync] clearPdfHighlight failed", e);
}
+89 -7
View File
@@ -33,11 +33,16 @@ function textFromReaderInternals(reader: any): string {
// zotero-plugin-toolkit path: selection popup annotation
try {
const popupText =
reader?._internalReader?._lastView?._selectionPopup?.annotation?.text ??
reader?._internalReader?._primaryView?._selectionPopup?.annotation?.text;
const popup =
reader?._internalReader?._lastView?._selectionPopup ??
reader?._internalReader?._primaryView?._selectionPopup;
const popupText = popup?.annotation?.text;
const t = tryString(popupText);
if (t) return t;
if (t) {
const position = normalizePdfSelectionPosition(popup?.annotation?.position);
if (position) cacheReaderSelection(t, position);
return t;
}
} catch {
// ignore
}
@@ -70,7 +75,13 @@ function textFromReaderInternals(reader: any): string {
}
try {
const t = tryString(view._selectionPopup?.annotation?.text);
if (t) return t;
if (t) {
const position = normalizePdfSelectionPosition(
view._selectionPopup?.annotation?.position,
);
if (position) cacheReaderSelection(t, position);
return t;
}
} catch {
// ignore
}
@@ -139,15 +150,83 @@ function listCandidateReaders(): any[] {
/** Last non-empty selection cached while user selects in the reader. */
let cachedSelection = "";
let cachedSelectionPosition: PdfSelectionPosition | undefined;
let cachedAt = 0;
export function cacheReaderSelection(text: string) {
export interface PdfSelectionPosition {
pageIndex: number;
rects?: number[][];
nextPageRects?: number[][];
}
export function normalizeHighlightText(text: string): string {
return text
.replace(/[\u0000-\u001f\f]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function normalizePdfSelectionPosition(raw: unknown): PdfSelectionPosition | undefined {
if (!raw || typeof raw !== "object") return undefined;
const pos = raw as Record<string, unknown>;
const pageIndex =
typeof pos.pageIndex === "number"
? pos.pageIndex
: typeof pos.pageNumber === "number"
? (pos.pageNumber as number) - 1
: undefined;
if (pageIndex == null || pageIndex < 0) return undefined;
const rects = Array.isArray(pos.rects)
? pos.rects.filter(
(r): r is number[] => Array.isArray(r) && r.length === 4,
)
: undefined;
const nextPageRects = Array.isArray(pos.nextPageRects)
? pos.nextPageRects.filter(
(r): r is number[] => Array.isArray(r) && r.length === 4,
)
: undefined;
if (!rects?.length && !nextPageRects?.length) return undefined;
return {
pageIndex,
rects: rects?.length ? rects : undefined,
nextPageRects: nextPageRects?.length ? nextPageRects : undefined,
};
}
export function cacheReaderSelection(text: string, position?: PdfSelectionPosition) {
const t = text.trim();
if (!t) return;
const sameText =
normalizeHighlightText(cachedSelection) === normalizeHighlightText(t);
cachedSelection = t;
if (position) {
cachedSelectionPosition = position;
} else if (!sameText) {
cachedSelectionPosition = undefined;
}
cachedAt = Date.now();
}
export function getCachedReaderSelectionPosition(
text?: string,
maxAgeMs = 120000,
): PdfSelectionPosition | undefined {
if (!cachedSelectionPosition) return undefined;
if (Date.now() - cachedAt > maxAgeMs) return undefined;
if (!text?.trim()) return cachedSelectionPosition;
const wanted = normalizeHighlightText(text);
const cached = normalizeHighlightText(cachedSelection);
if (!wanted || !cached) return undefined;
if (wanted === cached || cached.includes(wanted) || wanted.includes(cached)) {
return cachedSelectionPosition;
}
return undefined;
}
export function getCachedReaderSelection(maxAgeMs = 120000): string {
if (!cachedSelection) return "";
if (Date.now() - cachedAt > maxAgeMs) return "";
@@ -200,7 +279,10 @@ export function registerReaderSelectionHook() {
tryString(event?.params?.annotation?.text) ||
tryString(event?.params?.text) ||
textFromReaderInternals(event?.reader);
if (text) cacheReaderSelection(text);
const position = normalizePdfSelectionPosition(
event?.params?.annotation?.position,
);
if (text) cacheReaderSelection(text, position);
} catch (e) {
ztoolkit.log("selection popup hook failed", e);
}
+9 -3
View File
@@ -1,5 +1,6 @@
/** Helpers for Zotero ItemPane collapsible sections. */
import { config } from "../../../package.json";
import { getLocaleID, getString } from "../../utils/locale";
import { getPref, setPref } from "../../utils/prefs";
import { openChatPaneWindow, openLecturePaneWindow } from "./itemPaneWindow";
@@ -17,9 +18,14 @@ const FIXED_HEIGHT: Record<ItemPaneKind, number> = {
lecture: 420,
};
const OPEN_WINDOW_ICON = "chrome://zotero/skin/16/universal/open.svg";
const ADAPTIVE_ICON = "chrome://zotero/skin/16/universal/expand-all.svg";
const ADAPTIVE_ICON_OFF = "chrome://zotero/skin/16/universal/collapse-all.svg";
/** Lucide-derived toolbar icons (see addon/content/icons/pane-*.svg). */
function paneIconURL(file: string): string {
return `chrome://${config.addonRef}/content/icons/${file}`;
}
const OPEN_WINDOW_ICON = paneIconURL("pane-open-window.svg");
const ADAPTIVE_ICON = paneIconURL("pane-adaptive-height.svg");
const ADAPTIVE_ICON_OFF = paneIconURL("pane-adaptive-height-fixed.svg");
export function prepareItemPaneBody(body: HTMLElement): void {
body.classList.add("chatpapers-pane-body");
+20
View File
@@ -2,6 +2,26 @@ import type { IconNode } from "lucide";
export type LucideIcon = IconNode;
function serializeLucideNode([tag, attrs]: IconNode[number]): string {
const parts = Object.entries(attrs)
.map(([key, value]) => `${key}="${String(value).replace(/"/g, "&quot;")}"`)
.join(" ");
return parts ? `<${tag} ${parts}/>` : `<${tag}/>`;
}
/** Lucide icon as a 16px SVG data URL for Zotero toolbar list-style-image. */
export function lucideToolbarIconUrl(icon: LucideIcon): string {
const body = icon.map(serializeLucideNode).join("");
const svg = [
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"',
'fill="none" stroke="context-stroke" stroke-width="2"',
'stroke-linecap="round" stroke-linejoin="round">',
body,
"</svg>",
].join("");
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
}
export function createLucideIcon(
doc: Document,
icon: LucideIcon,
+10
View File
@@ -49,6 +49,8 @@ export type FluentMessageId =
| 'lecture-beat-tts-pending'
| 'lecture-empty-desc'
| 'lecture-empty-title'
| 'lecture-explain-selection'
| 'lecture-explain-selection-short'
| 'lecture-granularity-paragraph'
| 'lecture-granularity-sentence'
| 'lecture-loading'
@@ -76,6 +78,14 @@ export type FluentMessageId =
| 'lecture-playing-zh'
| 'lecture-reprepare'
| 'lecture-save-note'
| 'lecture-selection-context'
| 'lecture-selection-done'
| 'lecture-selection-empty'
| 'lecture-selection-explaining'
| 'lecture-selection-no-item'
| 'lecture-selection-replay'
| 'lecture-selection-syntax'
| 'lecture-selection-title'
| 'lecture-sentence-label'
| 'lecture-sentence-list-hint'
| 'lecture-sentence-list-title'