Skip to content
Draft
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
2 changes: 1 addition & 1 deletion marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion plugins/zotero/.zcode-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "zotero",
"version": "0.1.0",
"version": "0.1.1",
"description": "在对话侧栏使用你的 Zotero 文库:搜索与阅读论文和你的高亮,Agent 在 PDF 中标出依据、生成引用与 .bib 文件,并可导入论文、写笔记、整理分类。",
"author": {
"name": "ZCode"
Expand Down
2 changes: 2 additions & 0 deletions plugins/zotero/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions plugins/zotero/README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ Git 市场中的安装包已包含编译好的 MCP server 与面板,无需构
| `export_bib` | 从分类或条目生成工作区里的 `.bib` 文件 |
| `authorize_writes` | 提前请求写入权限 |

PDF 短语搜索与高亮支持 Unicode 等价文本,包括数学字母(`𝑥` / `x`)、连字(`fi` / `fi`)和预组合或分解形式的重音字符。高亮范围保留原文中的完整字符与组合序列。

### 权限、隐私与网络

- 插件只与本机的 Zotero 通信(`127.0.0.1:23119`;可在插件设置“Zotero 地址”中改端口,只接受本机地址)。读取不需要授权。
Expand Down
2 changes: 1 addition & 1 deletion ui-plugins/zotero/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zcode/plugin-zotero",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"license": "MIT",
"type": "module",
Expand Down
1 change: 1 addition & 0 deletions ui-plugins/zotero/src/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 工具、资源与订阅。
Expand Down
2 changes: 1 addition & 1 deletion ui-plugins/zotero/src/contract.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
119 changes: 119 additions & 0 deletions ui-plugins/zotero/src/domain/text.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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");
Expand All @@ -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(
[
Expand Down Expand Up @@ -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);
Expand Down
78 changes: 52 additions & 26 deletions ui-plugins/zotero/src/domain/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,39 +110,65 @@ const FOLD: Record<string, string> = {
"…": "...",
};

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 {
Expand All @@ -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 字符。
Expand Down
2 changes: 1 addition & 1 deletion ui-plugins/zotero/test/pdf-fixture.d.mts
Original file line number Diff line number Diff line change
@@ -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<string, string> }): Buffer;
export function samplePaper(): Buffer;
13 changes: 11 additions & 2 deletions ui-plugins/zotero/test/pdf-fixture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> }} [options]
*/
export function makePdf(pages, options = {}) {
const width = options.width ?? 612;
Expand All @@ -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> <FF>\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 = [];
Expand Down
Loading