Skip to content
Merged
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
3 changes: 2 additions & 1 deletion apps/web/src/components/files/PdfViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { readBoundedDocument } from "./readBoundedDocument";
import { openPdf } from "./pdfDocument";
import {
currentPdfPage,
pdfFindIncludes,
pdfFindMatchRuns,
pdfPageOffsets,
pdfPageScrollTop,
Expand Down Expand Up @@ -247,7 +248,7 @@ export default function PdfViewer(props: { src: string; title: string }) {
throw new Error(
"This PDF has too much text to search. Save it to search in another app.",
);
if (text.toLowerCase().includes(query.toLowerCase())) found.push(index);
if (pdfFindIncludes(text, query)) found.push(index);
}
if (cancelled) return;
setRatios(sizes);
Expand Down
31 changes: 31 additions & 0 deletions apps/web/src/components/files/pdfLayout.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vite-plus/test";
import {
currentPdfPage,
pdfFindIncludes,
pdfFindMatchRuns,
pdfPageOffsets,
pdfPageScrollTop,
Expand Down Expand Up @@ -105,6 +106,36 @@ describe("pdfFindMatchRuns", () => {
]);
});

it("matches a space in the query across a line end", () => {
expect(
pdfFindMatchRuns(["Q3 quarterly", "\n", "outlook", " ahead"], "quarterly outlook"),
).toEqual([true, true, true, false]);
});

it("matches a word hyphenated across a line end", () => {
expect(pdfFindMatchRuns(["the quar-", "\n", "terly report"], "quarterly")).toEqual([
true,
false,
true,
]);
});

it("drops a line-end hyphen after a letter outside the BMP", () => {
expect(pdfFindMatchRuns(["\u{10400}-", "\n", "\u{10401}"], "\u{10400}\u{10401}")).toEqual([
true,
false,
true,
]);
});

it("finds the same pages the highlights do", () => {
const page = "Q3 quarterly\noutlook ahead, the quar-\nterly report";
expect(pdfFindIncludes(page, "Quarterly OUTLOOK")).toBe(true);
expect(pdfFindIncludes(page, "quarterly report")).toBe(true);
expect(pdfFindIncludes(page, "bud-get")).toBe(false);
expect(pdfFindIncludes(page, " ")).toBe(false);
});

it("highlights nothing for a blank or missing query", () => {
expect(pdfFindMatchRuns(["a b", "c"], " ")).toEqual([false, false]);
expect(pdfFindMatchRuns(["a b", "c"], "")).toEqual([false, false]);
Expand Down
71 changes: 59 additions & 12 deletions apps/web/src/components/files/pdfLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,18 +56,65 @@ export function pdfPageText(
return items.map((item) => ("str" in item ? item.str + (item.hasEOL ? "\n" : "") : "")).join("");
}

/**
* Lowercased search text with each whitespace run, line ends included, read as one space, and a
* hyphen that ends a line between letters dropped, as pdf.js's own find does. `owner` maps each
* character back to the run it came from.
*/
function pdfFindText(runs: readonly string[]) {
const chars = runs.flatMap((run, index) =>
Array.from(run.toLowerCase(), (char) => ({ char, run: index })),
);
let text = "";
let last = "";
const owner: number[] = [];
for (let index = 0; index < chars.length; index++) {
const { char, run } = chars[index]!;
if (/\s/.test(char)) {
if (!text.endsWith(" ")) {
text += " ";
last = " ";
owner.push(run);
}
} else if (
char === "-" &&
chars[index + 1]?.char === "\n" &&
/\p{L}/u.test(last) &&
/\p{L}/u.test(chars[index + 2]?.char ?? "")
) {
index++;
} else {
text += char;
last = char;
for (let unit = 0; unit < char.length; unit++) owner.push(run);
}
}
return { text, owner };
}

/** Whether a page's text holds the query, under the same rules as `pdfFindMatchRuns`, without
* mapping characters back to runs: the page scan checks every page on each query. */
export function pdfFindIncludes(text: string, query: string) {
const needle = query.toLowerCase().replace(/\s+/g, " ");
if (!needle.trim()) return false;
return text
.toLowerCase()
.replace(/(\p{L})-\n(?=\p{L})/gu, "$1")
.replace(/\s+/g, " ")
.includes(needle);
}

/** Which text runs hold part of a case-insensitive match, when pdf.js splits words across spans. */
export function pdfFindMatchRuns(runs: readonly string[], query: string) {
const texts = runs.map((run) => run.toLowerCase());
const text = texts.join("");
const needle = query.toLowerCase();
let match = needle.trim() ? text.indexOf(needle) : -1;
let offset = 0;
return texts.map((run) => {
while (match !== -1 && match + needle.length <= offset)
match = text.indexOf(needle, match + needle.length);
const hit = match !== -1 && match < offset + run.length;
offset += run.length;
return hit;
});
const { text, owner } = pdfFindText(runs);
const needle = query.toLowerCase().replace(/\s+/g, " ");
const hits = runs.map(() => false);
if (!needle.trim()) return hits;
for (
let match = text.indexOf(needle);
match !== -1;
match = text.indexOf(needle, match + needle.length)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)
for (let index = match; index < match + needle.length; index++) hits[owner[index]!] = true;
return hits;
}
Loading