From 4ac8d0886167a9e6bf0770addb11e97eb620eca8 Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:01:05 +1100 Subject: [PATCH] fix(web): PDF find matches curly quotes when you type straight ones Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/files/pdfLayout.test.ts | 13 +++++++++++++ apps/web/src/components/files/pdfLayout.ts | 14 +++++++++----- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/files/pdfLayout.test.ts b/apps/web/src/components/files/pdfLayout.test.ts index 5a57be1de95c0..9aff44bede12c 100644 --- a/apps/web/src/components/files/pdfLayout.test.ts +++ b/apps/web/src/components/files/pdfLayout.test.ts @@ -136,6 +136,19 @@ describe("pdfFindMatchRuns", () => { expect(pdfFindIncludes(page, " ")).toBe(false); }); + it("matches straight quotes typed for typeset curly ones", () => { + expect(pdfFindMatchRuns(["We don\u2019t ", "ship \u201cbeta\u201d"], "don't")).toEqual([ + true, + false, + ]); + expect(pdfFindMatchRuns(["We don\u2019t ", "ship \u201cbeta\u201d"], '"beta"')).toEqual([ + false, + true, + ]); + expect(pdfFindIncludes("We don\u2019t ship \u201cbeta\u201d", 'don\'t ship "beta"')).toBe(true); + expect(pdfFindIncludes("We don't ship", "don\u2019t")).toBe(true); + }); + 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]); diff --git a/apps/web/src/components/files/pdfLayout.ts b/apps/web/src/components/files/pdfLayout.ts index 451af886fb67b..eb32d982e89d0 100644 --- a/apps/web/src/components/files/pdfLayout.ts +++ b/apps/web/src/components/files/pdfLayout.ts @@ -56,6 +56,11 @@ export function pdfPageText( return items.map((item) => ("str" in item ? item.str + (item.hasEOL ? "\n" : "") : "")).join(""); } +// A typed straight quote finds a typeset curly one, as pdf.js's own find does. +function foldQuotes(text: string) { + return text.replace(/[\u2018-\u201b]/g, "'").replace(/[\u201c-\u201f]/g, '"'); +} + /** * 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 @@ -63,7 +68,7 @@ export function pdfPageText( */ function pdfFindText(runs: readonly string[]) { const chars = runs.flatMap((run, index) => - Array.from(run.toLowerCase(), (char) => ({ char, run: index })), + Array.from(foldQuotes(run.toLowerCase()), (char) => ({ char, run: index })), ); let text = ""; let last = ""; @@ -95,10 +100,9 @@ function pdfFindText(runs: readonly string[]) { /** 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, " "); + const needle = foldQuotes(query.toLowerCase()).replace(/\s+/g, " "); if (!needle.trim()) return false; - return text - .toLowerCase() + return foldQuotes(text.toLowerCase()) .replace(/(\p{L})-\n(?=\p{L})/gu, "$1") .replace(/\s+/g, " ") .includes(needle); @@ -107,7 +111,7 @@ export function pdfFindIncludes(text: string, query: string) { /** 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 { text, owner } = pdfFindText(runs); - const needle = query.toLowerCase().replace(/\s+/g, " "); + const needle = foldQuotes(query.toLowerCase()).replace(/\s+/g, " "); const hits = runs.map(() => false); if (!needle.trim()) return hits; for (