Skip to content

Commit 5941e53

Browse files
authored
Add lightweight PDF text extraction (#99)
* add lightweight PDF text extraction * refine PDF parser loading and warnings
1 parent ab56c83 commit 5941e53

9 files changed

Lines changed: 240 additions & 4 deletions

File tree

next/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"pptxgenjs": "^4.0.1",
2828
"react": "19.2.4",
2929
"react-dom": "19.2.4",
30+
"unpdf": "^1.2.2",
3031
"xlsx": "^0.18.5",
3132
"zustand": "^5.0.13"
3233
},

next/src/components/ai-prompt-bar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { useDraft } from "@/lib/use-draft";
77
import { useUploadFile } from "@/lib/use-upload";
88

99
const ACCEPT_TYPES =
10-
".md,.txt,.csv,.tsv,.xlsx,.xls,.json,.sql,.yaml,.yml,.png,.jpg,.jpeg,.gif,.webp,.svg,.html,.htm,.xml,.log";
10+
".md,.txt,.pdf,.csv,.tsv,.xlsx,.xls,.json,.sql,.yaml,.yml,.png,.jpg,.jpeg,.gif,.webp,.svg,.html,.htm,.xml,.log";
1111

1212
/**
1313
* Sticky one-line input pinned to the bottom of the editor's Text tab. The

next/src/components/formats-gallery.tsx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { useT } from "@/lib/i18n";
88
* Format gallery — content-format snippets the user can one-click load into
99
* the editor. Different from `samples-gallery.tsx` (which ships fully
1010
* pre-rendered HTML samples tied to specific skills): here every card is a
11-
* tiny example of an *input* shape (.md / .csv / .json / .sql / .yaml /
11+
* tiny example of an *input* shape (.md / .pdf / .csv / .json / .sql / .yaml /
1212
* image, …). After loading, the template picker in the top toolbar decides
1313
* the *output* shape.
1414
*/
@@ -106,6 +106,31 @@ Third, every release notes file ends with one customer-visible sentence.
106106
If anything blocks you on this, ping me directly.
107107
108108
— Sam
109+
`,
110+
},
111+
{
112+
id: "pdf-paper",
113+
ext: ".pdf",
114+
label: "PDF",
115+
icon: "📄",
116+
kind: "text",
117+
description: "Text-layer PDFs — extracted locally into page sections.",
118+
format: "pdf",
119+
filename: "agentic-rl-paper.pdf",
120+
content: `# PDF: agentic-rl-paper.pdf
121+
122+
Source: PDF
123+
Pages: 3
124+
Extraction: embedded text
125+
126+
## Page 1
127+
Agentic reinforcement learning focuses on agents that can plan, act, observe feedback, and improve policy behavior across multi-step environments.
128+
129+
## Page 2
130+
LLM reinforcement learning often optimizes model outputs using preference data, reward models, or task-specific feedback over generated responses.
131+
132+
## Page 3
133+
The practical distinction is workflow scope: agentic RL evaluates behavior across trajectories, while LLM RL usually evaluates individual or batched language outputs.
109134
`,
110135
},
111136
{

next/src/lib/i18n.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -528,7 +528,7 @@ const en: Dict = {
528528
"editor.attach": "Attach",
529529
"editor.attachTooltip": "Drop a file or click to attach — appended to the editor below",
530530
"editor.dropTitle": "Drop to attach",
531-
"editor.dropHint": ".md .txt .csv .tsv .xlsx .json .sql .yaml .png .jpg — appended to your editor",
531+
"editor.dropHint": ".md .txt .pdf .csv .tsv .xlsx .json .sql .yaml .png .jpg — appended to your editor",
532532
"editor.backup": "⤓ Backup",
533533
"editor.backupTooltip": "Download the current content as a .md / .txt backup",
534534
"editor.restoring": "Restoring last content…",
@@ -889,7 +889,7 @@ const zhCN: Dict = {
889889
"editor.attach": "附加文件",
890890
"editor.attachTooltip": "拖文件进来或点这里上传 — 自动接到编辑器内容末尾",
891891
"editor.dropTitle": "松手附加",
892-
"editor.dropHint": ".md .txt .csv .tsv .xlsx .json .sql .yaml .png .jpg — 内容会接到编辑器末尾",
892+
"editor.dropHint": ".md .txt .pdf .csv .tsv .xlsx .json .sql .yaml .png .jpg — 内容会接到编辑器末尾",
893893
"editor.backup": "⤓ 备份",
894894
"editor.backupTooltip": "把当前内容下载为 .md / .txt 备份文件",
895895
"editor.restoring": "恢复上次内容…",
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { detectFormat, summarizeForAgent } from "../auto";
4+
5+
describe("PDF summaries", () => {
6+
it("detects extracted PDF markdown as pdf content", () => {
7+
const input = [
8+
"# PDF: paper.pdf",
9+
"",
10+
"Source: PDF",
11+
"Pages: 2",
12+
"Extraction: embedded text",
13+
"",
14+
"## Page 1",
15+
"First page",
16+
].join("\n");
17+
18+
expect(detectFormat(input)).toBe("pdf");
19+
20+
const summary = summarizeForAgent(input);
21+
expect(summary.format).toBe("pdf");
22+
expect(summary.preview).toBe("[PDF 文档, 2 页, 86 字符, extraction: embedded text]");
23+
});
24+
});
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
3+
const unpdf = vi.hoisted(() => ({
4+
extractText: vi.fn(),
5+
getDocumentProxy: vi.fn(),
6+
}));
7+
8+
vi.mock("unpdf", () => unpdf);
9+
10+
import { parseFile } from "../file";
11+
12+
describe("parseFile", () => {
13+
it("extracts text-layer PDFs into markdown-style page sections", async () => {
14+
const proxy = { destroy: vi.fn() };
15+
unpdf.getDocumentProxy.mockResolvedValue(proxy);
16+
unpdf.extractText.mockResolvedValue({
17+
totalPages: 2,
18+
text: [
19+
"Agentic RL studies agents that learn through environment feedback.",
20+
"LLM RL usually optimizes language model behavior from preferences.",
21+
],
22+
});
23+
24+
const file = new File([new Uint8Array([37, 80, 68, 70])], "paper.pdf", {
25+
type: "application/pdf",
26+
});
27+
28+
const parsed = await parseFile(file);
29+
30+
expect(unpdf.getDocumentProxy).toHaveBeenCalledWith(expect.any(Uint8Array));
31+
expect(unpdf.extractText).toHaveBeenCalledWith(proxy, { mergePages: false });
32+
expect(proxy.destroy).toHaveBeenCalled();
33+
expect(parsed).toEqual({
34+
filename: "paper.pdf",
35+
format: "pdf",
36+
text: [
37+
"# PDF: paper.pdf",
38+
"",
39+
"Source: PDF",
40+
"Pages: 2",
41+
"Extraction: embedded text",
42+
"",
43+
"## Page 1",
44+
"Agentic RL studies agents that learn through environment feedback.",
45+
"",
46+
"## Page 2",
47+
"LLM RL usually optimizes language model behavior from preferences.",
48+
].join("\n"),
49+
});
50+
});
51+
52+
it("marks image-heavy PDFs when little text is available", async () => {
53+
const proxy = { destroy: vi.fn() };
54+
unpdf.getDocumentProxy.mockResolvedValue(proxy);
55+
unpdf.extractText.mockResolvedValue({
56+
totalPages: 3,
57+
text: ["", " ", "Appendix"],
58+
});
59+
60+
const file = new File([new Uint8Array([37, 80, 68, 70])], "scan.pdf", {
61+
type: "application/pdf",
62+
});
63+
64+
const parsed = await parseFile(file);
65+
66+
expect(parsed.format).toBe("pdf");
67+
expect(parsed.text).toContain(
68+
"> Note: This PDF appears to be scanned or image-heavy. Text extraction is limited.",
69+
);
70+
expect(parsed.text).toContain("Pages: 3");
71+
expect(parsed.text).toContain("## Page 3\nAppendix");
72+
});
73+
74+
it("does not mark concise text-layer PDFs as limited extraction", async () => {
75+
const proxy = { destroy: vi.fn() };
76+
unpdf.getDocumentProxy.mockResolvedValue(proxy);
77+
unpdf.extractText.mockResolvedValue({
78+
totalPages: 1,
79+
text: ["Paid. Total: $42."],
80+
});
81+
82+
const file = new File([new Uint8Array([37, 80, 68, 70])], "receipt.pdf", {
83+
type: "application/pdf",
84+
});
85+
86+
const parsed = await parseFile(file);
87+
88+
expect(parsed.format).toBe("pdf");
89+
expect(parsed.text).toContain("Extraction: embedded text");
90+
expect(parsed.text).not.toContain("Text extraction is limited");
91+
expect(parsed.text).toContain("## Page 1\nPaid. Total: $42.");
92+
});
93+
});

next/src/lib/parsers/auto.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export type DetectedFormat =
66
| "json"
77
| "csv"
88
| "tsv"
9+
| "pdf"
910
| "sql"
1011
| "yaml"
1112
| "text";
@@ -14,6 +15,15 @@ export function detectFormat(input: string): DetectedFormat {
1415
const t = input.trim();
1516
if (!t) return "text";
1617

18+
// PDF text extracted by parseFile().
19+
if (
20+
/^# PDF:\s+\S/m.test(t) &&
21+
/^Source:\s+PDF$/m.test(t) &&
22+
/^Pages:\s+\d+$/m.test(t)
23+
) {
24+
return "pdf";
25+
}
26+
1727
// HTML
1828
if (/^<!DOCTYPE\s+html/i.test(t) || /^<html[\s>]/i.test(t)) return "html";
1929

@@ -126,6 +136,12 @@ export function summarizeForAgent(input: string): ParsedSummary {
126136
case "html":
127137
preview = `[HTML 文档, ${input.length} 字符]`;
128138
break;
139+
case "pdf": {
140+
const pages = input.match(/^Pages:\s+(\d+)$/m)?.[1] ?? "?";
141+
const extraction = input.match(/^Extraction:\s+(.+)$/m)?.[1] ?? "embedded text";
142+
preview = `[PDF 文档, ${pages} 页, ${input.length} 字符, extraction: ${extraction}]`;
143+
break;
144+
}
129145
case "sql":
130146
preview = `[SQL 查询/脚本]`;
131147
break;

next/src/lib/parsers/file.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,79 @@ const TEXT_EXTS = new Set([
1717
]);
1818
const IMAGE_EXTS = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"]);
1919
const SHEET_EXTS = new Set(["xlsx", "xls", "ods", "xlsm"]);
20+
const PDF_EXTS = new Set(["pdf"]);
21+
const LIMITED_PDF_TEXT_WARNING =
22+
"> Note: This PDF appears to be scanned or image-heavy. Text extraction is limited.";
2023

2124
function ext(name: string): string {
2225
const i = name.lastIndexOf(".");
2326
return i > -1 ? name.slice(i + 1).toLowerCase() : "";
2427
}
2528

29+
function normalizePdfPageText(text: string): string {
30+
return text
31+
.replace(/\r\n?/g, "\n")
32+
.split("\n")
33+
.map((line) => line.trimEnd())
34+
.join("\n")
35+
.trim();
36+
}
37+
38+
function hasLimitedPdfText(pages: string[]): boolean {
39+
if (pages.length === 0) return true;
40+
const emptyPages = pages.filter((page) => page.replace(/\s/g, "").length === 0).length;
41+
return emptyPages === pages.length || emptyPages / pages.length > 0.5;
42+
}
43+
44+
export function formatPdfText(
45+
filename: string,
46+
totalPages: number,
47+
pages: string[],
48+
): string {
49+
const pageCount = Math.max(totalPages, pages.length);
50+
const normalizedPages = Array.from({ length: pageCount }, (_, i) =>
51+
normalizePdfPageText(pages[i] ?? ""),
52+
);
53+
const isLimitedExtraction = hasLimitedPdfText(normalizedPages);
54+
55+
const out = [
56+
`# PDF: ${filename}`,
57+
"",
58+
"Source: PDF",
59+
`Pages: ${pageCount}`,
60+
`Extraction: ${isLimitedExtraction ? "limited embedded text" : "embedded text"}`,
61+
];
62+
63+
if (isLimitedExtraction) {
64+
out.push("", LIMITED_PDF_TEXT_WARNING);
65+
}
66+
67+
for (const [i, pageText] of normalizedPages.entries()) {
68+
out.push("", `## Page ${i + 1}`, pageText || "_No extractable text on this page._");
69+
}
70+
71+
return out.join("\n");
72+
}
73+
2674
export async function parseFile(file: File): Promise<FileParseResult> {
2775
const e = ext(file.name);
2876

77+
if (PDF_EXTS.has(e) || file.type === "application/pdf") {
78+
const buf = await file.arrayBuffer();
79+
const { extractText, getDocumentProxy } = await import("unpdf");
80+
const pdf = await getDocumentProxy(new Uint8Array(buf));
81+
try {
82+
const { totalPages, text } = await extractText(pdf, { mergePages: false });
83+
return {
84+
filename: file.name,
85+
format: "pdf",
86+
text: formatPdfText(file.name, totalPages, text),
87+
};
88+
} finally {
89+
await pdf.destroy?.();
90+
}
91+
}
92+
2993
if (SHEET_EXTS.has(e)) {
3094
const buf = await file.arrayBuffer();
3195
const wb = XLSX.read(buf, { type: "array" });

pnpm-lock.yaml

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)