Skip to content

Commit b09f3af

Browse files
fix(web): PDF search finds phrases that wrap across lines
pdf.js text items that end a line become "\n" in the search text, so a query with a space never matched a phrase split across lines. Search now reads any whitespace run, line ends included, as one space on both sides, and drops a line-end hyphen between letters, as pdf.js's own find does. Page matching and run highlighting share the same matcher. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 0382ea0 commit b09f3af

3 files changed

Lines changed: 59 additions & 13 deletions

File tree

‎apps/web/src/components/files/PdfViewer.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ export default function PdfViewer(props: { src: string; title: string }) {
247247
throw new Error(
248248
"This PDF has too much text to search. Save it to search in another app.",
249249
);
250-
if (text.toLowerCase().includes(query.toLowerCase())) found.push(index);
250+
if (pdfFindMatchRuns([text], query)[0]) found.push(index);
251251
}
252252
if (cancelled) return;
253253
setRatios(sizes);

‎apps/web/src/components/files/pdfLayout.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,20 @@ describe("pdfFindMatchRuns", () => {
105105
]);
106106
});
107107

108+
it("matches a space in the query across a line end", () => {
109+
expect(
110+
pdfFindMatchRuns(["Q3 quarterly", "\n", "outlook", " ahead"], "quarterly outlook"),
111+
).toEqual([true, true, true, false]);
112+
});
113+
114+
it("matches a word hyphenated across a line end", () => {
115+
expect(pdfFindMatchRuns(["the quar-", "\n", "terly report"], "quarterly")).toEqual([
116+
true,
117+
false,
118+
true,
119+
]);
120+
});
121+
108122
it("highlights nothing for a blank or missing query", () => {
109123
expect(pdfFindMatchRuns(["a b", "c"], " ")).toEqual([false, false]);
110124
expect(pdfFindMatchRuns(["a b", "c"], "")).toEqual([false, false]);

‎apps/web/src/components/files/pdfLayout.ts‎

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -56,18 +56,50 @@ export function pdfPageText(
5656
return items.map((item) => ("str" in item ? item.str + (item.hasEOL ? "\n" : "") : "")).join("");
5757
}
5858

59+
/**
60+
* Lowercased search text with each whitespace run, line ends included, read as one space, and a
61+
* hyphen that ends a line between letters dropped, as pdf.js's own find does. `owner` maps each
62+
* character back to the run it came from.
63+
*/
64+
function pdfFindText(runs: readonly string[]) {
65+
const chars = runs.flatMap((run, index) =>
66+
Array.from(run.toLowerCase(), (char) => ({ char, run: index })),
67+
);
68+
let text = "";
69+
const owner: number[] = [];
70+
for (let index = 0; index < chars.length; index++) {
71+
const { char, run } = chars[index]!;
72+
if (/\s/.test(char)) {
73+
if (!text.endsWith(" ")) {
74+
text += " ";
75+
owner.push(run);
76+
}
77+
} else if (
78+
char === "-" &&
79+
chars[index + 1]?.char === "\n" &&
80+
/\p{L}/u.test(text.at(-1) ?? "") &&
81+
/\p{L}/u.test(chars[index + 2]?.char ?? "")
82+
) {
83+
index++;
84+
} else {
85+
text += char;
86+
for (let unit = 0; unit < char.length; unit++) owner.push(run);
87+
}
88+
}
89+
return { text, owner };
90+
}
91+
5992
/** Which text runs hold part of a case-insensitive match, when pdf.js splits words across spans. */
6093
export function pdfFindMatchRuns(runs: readonly string[], query: string) {
61-
const texts = runs.map((run) => run.toLowerCase());
62-
const text = texts.join("");
63-
const needle = query.toLowerCase();
64-
let match = needle.trim() ? text.indexOf(needle) : -1;
65-
let offset = 0;
66-
return texts.map((run) => {
67-
while (match !== -1 && match + needle.length <= offset)
68-
match = text.indexOf(needle, match + needle.length);
69-
const hit = match !== -1 && match < offset + run.length;
70-
offset += run.length;
71-
return hit;
72-
});
94+
const { text, owner } = pdfFindText(runs);
95+
const needle = query.toLowerCase().replace(/\s+/g, " ");
96+
const hits = runs.map(() => false);
97+
if (!needle.trim()) return hits;
98+
for (
99+
let match = text.indexOf(needle);
100+
match !== -1;
101+
match = text.indexOf(needle, match + needle.length)
102+
)
103+
for (let index = match; index < match + needle.length; index++) hits[owner[index]!] = true;
104+
return hits;
73105
}

0 commit comments

Comments
 (0)