From e8b1e23ee85cb84b776ca281bfe4470cfc100853 Mon Sep 17 00:00:00 2001 From: Ricardo-M-L <69202550+Ricardo-M-L@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:44:19 +0800 Subject: [PATCH] fix(zotero): preserve Unicode quote ranges --- marketplace.json | 2 +- plugins/zotero/.zcode-plugin/plugin.json | 2 +- plugins/zotero/README.md | 2 + plugins/zotero/README_CN.md | 2 + ui-plugins/zotero/package.json | 2 +- ui-plugins/zotero/src/CONTRACT.md | 1 + ui-plugins/zotero/src/contract.ts | 2 +- ui-plugins/zotero/src/domain/text.test.ts | 119 ++++++++++++++++++ ui-plugins/zotero/src/domain/text.ts | 78 ++++++++---- ui-plugins/zotero/test/pdf-fixture.d.mts | 2 +- ui-plugins/zotero/test/pdf-fixture.mjs | 13 +- ui-plugins/zotero/test/unicode-quotes.test.ts | 62 +++++++++ 12 files changed, 254 insertions(+), 33 deletions(-) create mode 100644 ui-plugins/zotero/test/unicode-quotes.test.ts diff --git a/marketplace.json b/marketplace.json index 8afcd9c7..8b7c24fd 100644 --- a/marketplace.json +++ b/marketplace.json @@ -1228,7 +1228,7 @@ "en": "Zotero", "zh-CN": "Zotero" }, - "version": "0.1.0", + "version": "0.1.1", "source": "./plugins/zotero", "description": "在对话侧栏使用你的 Zotero 文库:搜索与阅读论文和你的高亮,Agent 在 PDF 中标出依据、生成引用与 .bib 文件,并可导入论文、写笔记、整理分类。", "description_i18n": { diff --git a/plugins/zotero/.zcode-plugin/plugin.json b/plugins/zotero/.zcode-plugin/plugin.json index 1a0eaaa5..6a35313b 100644 --- a/plugins/zotero/.zcode-plugin/plugin.json +++ b/plugins/zotero/.zcode-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "zotero", - "version": "0.1.0", + "version": "0.1.1", "description": "在对话侧栏使用你的 Zotero 文库:搜索与阅读论文和你的高亮,Agent 在 PDF 中标出依据、生成引用与 .bib 文件,并可导入论文、写笔记、整理分类。", "author": { "name": "ZCode" diff --git a/plugins/zotero/README.md b/plugins/zotero/README.md index b3d290cb..0c65ddd2 100644 --- a/plugins/zotero/README.md +++ b/plugins/zotero/README.md @@ -50,6 +50,8 @@ You can also open the panel from the side pane "+" menu. | `export_bib` | Write a `.bib` file into the workspace from a collection and/or items | | `authorize_writes` | Ask for write access ahead of time | +PDF phrase search and highlights match Unicode-equivalent text, including mathematical letters (`𝑥` / `x`), ligatures (`fi` / `fi`) and composed or decomposed accents. Highlight ranges retain complete original characters and combining sequences. + ### Permissions, privacy and network - The plugin only talks to Zotero on this computer (`127.0.0.1:23119`; another port can be set in the plugin setting *Zotero address*, loopback addresses only). Reading needs no permission. diff --git a/plugins/zotero/README_CN.md b/plugins/zotero/README_CN.md index 8f8c5269..d84e2615 100644 --- a/plugins/zotero/README_CN.md +++ b/plugins/zotero/README_CN.md @@ -50,6 +50,8 @@ Git 市场中的安装包已包含编译好的 MCP server 与面板,无需构 | `export_bib` | 从分类或条目生成工作区里的 `.bib` 文件 | | `authorize_writes` | 提前请求写入权限 | +PDF 短语搜索与高亮支持 Unicode 等价文本,包括数学字母(`𝑥` / `x`)、连字(`fi` / `fi`)和预组合或分解形式的重音字符。高亮范围保留原文中的完整字符与组合序列。 + ### 权限、隐私与网络 - 插件只与本机的 Zotero 通信(`127.0.0.1:23119`;可在插件设置“Zotero 地址”中改端口,只接受本机地址)。读取不需要授权。 diff --git a/ui-plugins/zotero/package.json b/ui-plugins/zotero/package.json index c93491c5..f590ac67 100644 --- a/ui-plugins/zotero/package.json +++ b/ui-plugins/zotero/package.json @@ -1,6 +1,6 @@ { "name": "@zcode/plugin-zotero", - "version": "0.1.0", + "version": "0.1.1", "private": true, "license": "MIT", "type": "module", diff --git a/ui-plugins/zotero/src/CONTRACT.md b/ui-plugins/zotero/src/CONTRACT.md index 9565c7a3..1d5f4e9a 100644 --- a/ui-plugins/zotero/src/CONTRACT.md +++ b/ui-plugins/zotero/src/CONTRACT.md @@ -9,6 +9,7 @@ - `zotero/writes.ts`:批量创建(≤ 50)、按版本 PATCH(412 时以新版本重试一次)、删除(仅测试用,未暴露为工具)、附件三步上传(授权 → 传字节 → 登记),上传失败时删除刚建的附件条目。 - `pdf/extract.ts`:pdf.js(legacy 构建,worker 在主线程)提取逐页文本项;`PdfStore` 按路径 + 修改时间 + 大小做 LRU 缓存。只读取 Zotero 为附件报告的 `file://` 路径。 - `domain/`:纯函数。`text.ts` 拼页面文本并保留到文本项的映射;引文匹配做 NFKC、引号 / 连字符统一、行尾断词合并、空白折叠,失败再忽略全部空白,支持“开头 … 结尾”与跨页;矩形按 Helvetica 字宽比例在文本项内插值,竖直范围取基线下 0.25、上 0.75 字号(与 Zotero 自己的高亮一致);排序键 `页序|偏移|距页顶`。`items.ts` 是条目摘要与模型文本。 +- 引文归一化先处理空白、零宽字符与软连字符,再兼容分解并按新的完整字素组合,支持数学字母、连字、组合重音与兼容 Jamo;归一化结果的每个 UTF-16 单元都保留原文起止偏移,搜索与高亮不得截断代理对或组合序列。 - `metadata/metadata.ts`:DOI(doi.org 内容协商 CSL-JSON)与 arXiv(Atom API)到 Zotero 条目;PDF 下载限 http(s)、100 MB、校验 `%PDF-`。 - `app/session.ts`:`ZoteroSession` 组合以上部分,持有面板视图(`viewSeq`)、Agent 高亮(只在会话内,保存后记 `savedAs`)、库版本轮询与变化事件、工作区路径检查(realpath,拒绝跳出工作区)。 - `adapters/`:MCP 工具、资源与订阅。 diff --git a/ui-plugins/zotero/src/contract.ts b/ui-plugins/zotero/src/contract.ts index 665054cf..880b07c1 100644 --- a/ui-plugins/zotero/src/contract.ts +++ b/ui-plugins/zotero/src/contract.ts @@ -1,5 +1,5 @@ export const PLUGIN_NAME = "zotero"; -export const PLUGIN_VERSION = "0.1.0"; +export const PLUGIN_VERSION = "0.1.1"; /** manifest `mcpServers` 键、`ui.surfaces[].id` 与资源命名空间保持一致。 */ export const SERVER_NAME = "zotero"; export const SURFACE_ID = "zotero"; diff --git a/ui-plugins/zotero/src/domain/text.test.ts b/ui-plugins/zotero/src/domain/text.test.ts index 8a97af07..3bbb2868 100644 --- a/ui-plugins/zotero/src/domain/text.test.ts +++ b/ui-plugins/zotero/src/domain/text.test.ts @@ -35,6 +35,46 @@ describe("normalize", () => { it("compact mode drops all whitespace", () => { expect(normalize("a b\nc", true).value).toBe("abc"); }); + + it.each([ + ["𝑥 + 𝐘 + 𝟙", "x + y + 1"], + ["𝒜\u030A", "å"], + ["e\u0301", "é"], + ["a\u0315\u0300", "à\u0315"], + ["가", "가"], + ["fi", "fi"], + ["FULL", "full"], + ])("normalizes complete Unicode sequences in %s", (source, expected) => { + const { value, map, endMap } = normalize(source); + expect(value).toBe(expected); + expect(map).toHaveLength(value.length); + expect(endMap).toHaveLength(value.length); + }); + + it("normalizes the mathematical alphanumeric block without splitting surrogate pairs", () => { + for (let code = 0x1d400; code <= 0x1d7ff; code++) { + const source = String.fromCodePoint(code); + expect(normalize(source).value).toBe(source.normalize("NFKC").toLowerCase()); + } + }); + + it("maps normalized UTF-16 units to complete original graphemes", () => { + expect(normalize("🚀 e\u0301 fi 𝑥")).toEqual({ + value: "🚀 é fi x", + map: [0, 0, 2, 3, 5, 6, 6, 7, 8], + endMap: [2, 2, 3, 5, 6, 7, 7, 8, 10], + }); + }); + + it("recomposes combining sequences after compact whitespace removal", () => { + expect(normalize("a \u0301b", true)).toEqual({ value: "áb", map: [0, 3], endMap: [3, 4] }); + expect(normalize("𝒜 \u030A", true)).toEqual({ value: "å", map: [0], endMap: [4] }); + }); + + it("retains the full original grapheme when compatibility decomposition splits a ligature", () => { + expect(normalize("fi\u0301")).toEqual({ value: "fí", map: [0, 0], endMap: [2, 2] }); + expect(normalize("ffi\u0301")).toEqual({ value: "ffí", map: [0, 0, 0], endMap: [2, 2, 2] }); + }); }); describe("findInPage / findQuote", () => { @@ -50,6 +90,18 @@ describe("findInPage / findQuote", () => { expect(findInPage(target, "the call stack")).not.toBeNull(); }); + it.each([ + ["a \u0301b", "a\u0301b"], + ["e\u200b\u0301", "é"], + ["e\u00ad\u0301", "e\u0301"], + ["ㄱㅏ", "가"], + ["ㄱㅏ", "가"], + ["𝒜 \u030A", "å"], + ])("keeps complete source ranges when folding %s joins a Unicode sequence", (source, quote) => { + const target = page(source); + expect(findInPage(target, quote)).toEqual({ pageIndex: 0, start: 0, end: source.length }); + }); + it("matches 'start … end' quotes", () => { const target = page("We identify a range of use cases where this delayed-popping strategy is beneficial, ranging from closures to trait objects."); const match = findInPage(target, "We identify a range of use cases … ranging from closures"); @@ -69,9 +121,60 @@ describe("findInPage / findQuote", () => { const match = findQuote(pages, "end of one page continues here"); expect(match).toEqual({ pageIndex: 0, start: 4, end: pages[0]!.text.length }); }); + + it.each([ + ["𝑥", "x"], + ["𝑥", "𝑥"], + ["e\u0301", "é"], + ["é", "e\u0301"], + ["fi", "fi"], + ["𝒜\u030A", "å"], + ["😀", "😀"], + ])("maps %s back to the complete original UTF-16 range", (source, quote) => { + const prefix = "Before 🚀 "; + const target = page(`${prefix}${source} after`); + const match = findInPage(target, quote); + expect(match).toEqual({ pageIndex: 0, start: prefix.length, end: prefix.length + source.length }); + expect(textAt(target, match!.start, match!.end)).toBe(source); + }); + + it("keeps search offsets after supplementary characters and between repeated hits", () => { + const target = page("😀 𝑥 and 𝑥 end"); + const hits = searchPages([target], "x", 10); + expect(hits.map(({ start, end }) => [start, end])).toEqual([[3, 5], [10, 12]]); + expect(hits.map(({ snippet }) => snippet)).toEqual([ + "…😀 «𝑥» and 𝑥 end…", + "…😀 𝑥 and «𝑥» end…", + ]); + }); + + it.each(["fi\u0301", "ffi\u0301"])("keeps the original accent when matching part of %s", (source) => { + const target = page(source); + const match = findInPage(target, "f"); + expect(match).toEqual({ pageIndex: 0, start: 0, end: source.length }); + expect(textAt(target, match!.start, match!.end)).toBe(source); + expect(searchPages([target], "f", 10)).toEqual([ + { pageIndex: 0, label: "1", snippet: `…«${source}»…`, start: 0, end: source.length }, + ]); + }); }); describe("rectsFor and sortIndex", () => { + it("covers the entire mathematical glyph without selecting adjacent text", () => { + const target = buildPage( + [ + { str: "A ", transform: [10, 0, 0, 10, 72, 700], width: 20, height: 10 }, + { str: "𝑥", transform: [10, 0, 0, 10, 92, 700], width: 11, height: 10 }, + { str: " end", transform: [10, 0, 0, 10, 103, 700], width: 30, height: 10 }, + ], + { index: 0, label: "1", width: 612, height: 792 }, + ); + const match = findQuote([target], "x")!; + expect(match).toEqual({ pageIndex: 0, start: 2, end: 4 }); + expect(rectsFor(target, match.start, match.end)).toEqual([[92, 697.5, 103, 707.5]]); + expect(sortIndex(target, match.start, rectsFor(target, match.start, match.end))).toBe("00000|000002|00084"); + }); + it("interpolates partial spans by glyph width and merges a line into one rect", () => { const target = buildPage( [ @@ -109,6 +212,22 @@ describe("parsePages", () => { }); describe("real PDF text", () => { + it("finds and highlights normalized mathematical quotes in extracted PDF text", async () => { + const pdf = await extractPdf( + new Uint8Array(makePdf([["The equation x = 1 follows."]], { unicodeMap: { x: "𝑥" } })), + pdfjsDataDir(), + ); + const target = pdf.pages[0]!; + expect(target.text).toContain("𝑥 = 1"); + const hits = searchPages(pdf.pages, "x = 1", 10); + expect(hits).toHaveLength(1); + expect(hits[0]!.snippet).toContain("«𝑥 = 1»"); + const match = findQuote(pdf.pages, "x")!; + expect(textAt(target, match.start, match.end)).toBe("𝑥"); + const [rect] = rectsFor(target, match.start, match.end); + expect(rect![2]).toBeGreaterThan(rect![0]); + }); + it("extracts pages, labels, and anchors quotes across hyphenated line breaks", async () => { const pdf = await extractPdf(new Uint8Array(samplePaper()), pdfjsDataDir()); expect(pdf.pageCount).toBe(3); diff --git a/ui-plugins/zotero/src/domain/text.ts b/ui-plugins/zotero/src/domain/text.ts index 22084045..c71f2894 100644 --- a/ui-plugins/zotero/src/domain/text.ts +++ b/ui-plugins/zotero/src/domain/text.ts @@ -110,39 +110,65 @@ const FOLD: Record = { "…": "...", }; +const GRAPHEMES = new Intl.Segmenter("und", { granularity: "grapheme" }); + /** - * 归一化文本并记录每个结果字符来自原文的哪个位置: + * 归一化文本并记录每个结果 UTF-16 单元在原文中的起止位置: * NFKC(拆连字 fi→fi)、统一引号与连字符、行尾连字符断词合并、空白折叠为单个空格、小写。 * compact 时去掉所有空白(容忍 PDF 缺失或多出的空格)。 */ -export function normalize(text: string, compact = false): { value: string; map: number[] } { - let value = ""; - const map: number[] = []; +export function normalize(text: string, compact = false): { value: string; map: number[]; endMap: number[] } { + let decomposed = ""; + const sourceMap: number[] = []; + const sourceEndMap: number[] = []; let pendingSpace = -1; - for (let index = 0; index < text.length; index++) { - const char = text[index]!; - // 行尾连字符断词:小写字母-换行-小写字母,丢弃连字符与换行。 - if (char === "-" && text[index + 1] === "\n" && /\p{Ll}/u.test(text[index - 1] ?? "") && /\p{Ll}/u.test(text[index + 2] ?? "")) { - index += 1; - continue; - } - if (/\s/.test(char)) { - if (!compact && pendingSpace < 0 && value.length) pendingSpace = index; - continue; + let skipUntil = 0; + for (const { segment, index: start } of GRAPHEMES.segment(text)) { + let offset = start; + for (const char of segment) { + const index = offset; + offset += char.length; + if (index < skipUntil) continue; + // 行尾连字符断词:小写字母-换行-小写字母,丢弃连字符与换行。 + if (char === "-" && text[index + 1] === "\n" && /\p{Ll}$/u.test(text.slice(Math.max(0, index - 2), index)) && /^\p{Ll}/u.test(text.slice(index + 2, index + 4))) { + skipUntil = index + 2; + continue; + } + if (/\s/u.test(char)) { + if (!compact && pendingSpace < 0 && decomposed.length) pendingSpace = index; + continue; + } + // 删除空白 / 零宽字符、兼容分解都可能合并原来的字素边界;先保留分解后的来源,再统一组合。 + const folded = (FOLD[char] ?? char).normalize("NFKD"); + if (!folded) continue; + if (pendingSpace >= 0) { + decomposed += " "; + sourceMap.push(pendingSpace); + sourceEndMap.push(index); + pendingSpace = -1; + } + decomposed += folded; + // 连字可能展开成多个新字素;它们仍属于同一原始字素,不能丢掉其尾部重音。 + for (let unit = 0; unit < folded.length; unit++) { + sourceMap.push(start); + sourceEndMap.push(start + segment.length); + } } - const folded = (FOLD[char] ?? char).normalize("NFKC").toLowerCase(); - if (!folded) continue; - if (pendingSpace >= 0) { - value += " "; - map.push(pendingSpace); - pendingSpace = -1; - } - for (const part of folded) { - value += part; - map.push(index); + } + let value = ""; + const map: number[] = []; + const endMap: number[] = []; + for (const { segment, index } of GRAPHEMES.segment(decomposed)) { + // NFKD 后按新字素做 NFC 等价于 NFKC,兼容 Jamo 与跨删除位置的重音也能重新组合。 + const folded = segment.normalize("NFC").toLowerCase(); + value += folded; + // indexOf 使用 UTF-16 偏移;映射完整来源跨度,防止高亮截断代理对、组合重音或被忽略的间隙。 + for (let unit = 0; unit < folded.length; unit++) { + map.push(sourceMap[index]!); + endMap.push(sourceEndMap[index + segment.length - 1]!); } } - return { value, map }; + return { value, map, endMap }; } export interface Match { @@ -165,7 +191,7 @@ export function findInPage(page: PageText, quote: string, from = 0): Match | nul return { pageIndex: page.index, start: haystack.map[position]!, - end: haystack.map[position + needle.length - 1]! + 1, + end: haystack.endMap[position + needle.length - 1]!, }; } // “开头 … 结尾”:两段都在本页且顺序正确,跨度不超过 3000 字符。 diff --git a/ui-plugins/zotero/test/pdf-fixture.d.mts b/ui-plugins/zotero/test/pdf-fixture.d.mts index a32799ef..d258a39a 100644 --- a/ui-plugins/zotero/test/pdf-fixture.d.mts +++ b/ui-plugins/zotero/test/pdf-fixture.d.mts @@ -1,2 +1,2 @@ -export function makePdf(pages: string[][], options?: { labels?: { start: number; style: "r" | "D"; first?: number }[]; width?: number; height?: number }): Buffer; +export function makePdf(pages: string[][], options?: { labels?: { start: number; style: "r" | "D"; first?: number }[]; width?: number; height?: number; unicodeMap?: Record }): Buffer; export function samplePaper(): Buffer; diff --git a/ui-plugins/zotero/test/pdf-fixture.mjs b/ui-plugins/zotero/test/pdf-fixture.mjs index a4d0fa24..0b8da382 100644 --- a/ui-plugins/zotero/test/pdf-fixture.mjs +++ b/ui-plugins/zotero/test/pdf-fixture.mjs @@ -15,7 +15,7 @@ function winAnsi(text) { /** * @param {string[][]} pages 每页的文本行 - * @param {{ labels?: { start: number, style: "r" | "D", first?: number }[], width?: number, height?: number }} [options] + * @param {{ labels?: { start: number, style: "r" | "D", first?: number }[], width?: number, height?: number, unicodeMap?: Record }} [options] */ export function makePdf(pages, options = {}) { const width = options.width ?? 612; @@ -25,7 +25,16 @@ export function makePdf(pages, options = {}) { objects.push(body); return objects.length; }; - const font = add(Buffer.from("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>")); + let toUnicode = ""; + if (options.unicodeMap) { + // 用 ToUnicode 表保留 PDF 中的原始 Unicode 文本,不依赖机器上的字体。 + const mappings = Object.entries(options.unicodeMap).map(([char, text]) => + `<${char.charCodeAt(0).toString(16).padStart(2, "0")}> <${Buffer.from(text, "utf16le").swap16().toString("hex")}>`, + ); + const cmap = Buffer.from(`/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n/CMapName /FixtureUnicode def\n/CMapType 2 def\n1 begincodespacerange\n<00> \nendcodespacerange\n${mappings.length} beginbfchar\n${mappings.join("\n")}\nendbfchar\nendcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n`); + toUnicode = ` /ToUnicode ${add(Buffer.concat([Buffer.from(`<< /Length ${cmap.length} >>\nstream\n`), cmap, Buffer.from("endstream")]))} 0 R`; + } + const font = add(Buffer.from(`<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding${toUnicode} >>`)); const pagesId = objects.length + 1; objects.push(null); // Pages 占位 const kids = []; diff --git a/ui-plugins/zotero/test/unicode-quotes.test.ts b/ui-plugins/zotero/test/unicode-quotes.test.ts new file mode 100644 index 00000000..45f36147 --- /dev/null +++ b/ui-plugins/zotero/test/unicode-quotes.test.ts @@ -0,0 +1,62 @@ +import { expect, it } from "vitest"; +import { rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { startFakeZotero } from "#test/fake-zotero.mjs"; +import { makePdf } from "#test/pdf-fixture.mjs"; +import { createZoteroMcpServer } from "#z/adapters/server.ts"; +import { ZoteroSession } from "#z/app/session.ts"; +import { URIS, type AnnotationsDoc } from "#z/contract.ts"; +import { pdfjsDataDir } from "#z/pdf/extract.ts"; + +it("searches, highlights and saves a mathematical PDF quote through MCP without truncating its character", async () => { + const fake = await startFakeZotero({ authorize: "always" }); + const session = new ZoteroSession({ + baseUrl: fake.url, + keyFile: join(fake.storage, "keys.json"), + workspaceRoot: fake.storage, + pdfjsDataDir: pdfjsDataDir(), + }); + const server = createZoteroMcpServer({ session, assetRoot: fake.storage, pdfjsDataDir: pdfjsDataDir() }); + const client = new Client({ name: "unicode-quote-test", version: "1" }); + try { + await writeFile( + fake.state.files.get("PDFATTAA"), + makePdf([["The equation x = 1 follows."]], { unicodeMap: { x: "𝑥" } }), + ); + const [clientSide, serverSide] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(serverSide), client.connect(clientSide)]); + + const found = await client.callTool({ name: "find_in_pdf", arguments: { key: "PAPERAAA", query: "x = 1" } }); + expect(found.isError).toBeFalsy(); + expect(found.content).toEqual(expect.arrayContaining([expect.objectContaining({ type: "text", text: expect.stringContaining("«𝑥 = 1»") })])); + + const highlighted = await client.callTool({ name: "highlight", arguments: { key: "PAPERAAA", quote: "equation x" } }); + expect(highlighted.isError, JSON.stringify(highlighted.content)).toBeFalsy(); + const before = await client.readResource({ uri: URIS.annotations("user", "PDFATTAA") }); + const { highlights: [highlight] } = JSON.parse((before.contents[0] as { text: string }).text) as AnnotationsDoc; + expect(highlight.quote).toBe("equation 𝑥"); + expect(highlight.rects).toHaveLength(1); + expect(highlight.rects[0]![2]).toBeGreaterThan(highlight.rects[0]![0]); + + const saved = await client.callTool({ name: "save_highlights", arguments: { ids: [highlight.id] } }); + expect(saved.isError).toBeFalsy(); + const { saved: [{ key }] } = saved.structuredContent as { saved: { key: string }[] }; + const annotation = fake.state.items.get(key).data; + expect(annotation.annotationText).toBe("equation 𝑥"); + expect(annotation.annotationSortIndex).toMatch(/^00000\|000004\|/); + expect(JSON.parse(annotation.annotationPosition)).toEqual({ pageIndex: 0, rects: highlight.rects }); + + const resource = await client.readResource({ uri: URIS.annotations("user", "PDFATTAA") }); + const doc = JSON.parse((resource.contents[0] as { text: string }).text) as AnnotationsDoc; + expect(doc.highlights).toEqual([expect.objectContaining({ quote: "equation 𝑥", savedAs: key })]); + expect(doc.annotations).toEqual(expect.arrayContaining([expect.objectContaining({ text: "equation 𝑥" })])); + } finally { + session.close(); + await client.close(); + await server.close(); + await fake.close(); + await rm(fake.storage, { recursive: true, force: true }); + } +});