pdf.mjs
140 lines 4.4 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago
1 /**
2 * Import a PDF file into a vault note (plain text extracted via unpdf / PDF.js).
3 */
4
5 import '../shims/promise-try.mjs';
6 import crypto from 'crypto';
7 import fs from 'fs';
8 import path from 'path';
9 import { extractText, getDocumentProxy } from 'unpdf';
10 import { writeNote } from '../write.mjs';
11 import { normalizeSlug } from '../vault.mjs';
12
13 /**
14 * Stable id from file bytes (hex, 32 chars).
15 * @param {Buffer} buf
16 */
17 function sourceIdFromPdfBytes(buf) {
18 return crypto.createHash('sha256').update(buf).digest('hex').slice(0, 32);
19 }
20
21 /**
22 * @param {string} inputPath
23 */
24 function titleFromPdfFilename(inputPath) {
25 const base = path.basename(inputPath, path.extname(inputPath));
26 const cleaned = base.replace(/[-_]+/g, ' ').trim();
27 return cleaned || 'Imported PDF';
28 }
29
30 /**
31 * @param {string} text
32 */
33 function normalizeExtractedText(text) {
34 let t = String(text || '').replace(/\r\n/g, '\n');
35 t = t.replace(/\u00a0/g, ' ');
36 t = t.replace(/\n{3,}/g, '\n\n').trim();
37 return t;
38 }
39
40 async function extractPdfBytes(buf) {
41 try {
42 const bytes = Buffer.isBuffer(buf) ? buf : Buffer.from(buf);
43 const pdf = await getDocumentProxy(new Uint8Array(bytes));
44 const { totalPages, text } = await extractText(pdf, { mergePages: true });
45 const markdown = normalizeExtractedText(text);
46 return markdown ? { ok: true, markdown, totalPages } : { ok: false, reason: 'empty_extract' };
47 } catch {
48 return { ok: false, reason: 'empty_extract' };
49 }
50 }
51
52 /**
53 * Convert PDF bytes to normalized Markdown without writing a note.
54 *
55 * @param {Buffer|Uint8Array} buf
56 * @returns {Promise<{ ok: true, markdown: string } | { ok: false, reason: 'empty_extract' }>}
57 */
58 export async function pdfBytesToMarkdown(buf) {
59 const result = await extractPdfBytes(buf);
60 return result.ok
61 ? { ok: true, markdown: result.markdown }
62 : { ok: false, reason: 'empty_extract' };
63 }
64
65 /**
66 * @param {string} input - Path to a .pdf file
67 * @param {{
68 * vaultPath: string,
69 * outputBase: string,
70 * project?: string | null,
71 * tags: string[],
72 * dryRun: boolean,
73 * onProgress?: (p: { progress: number, total?: number, message?: string }) => void | Promise<void>
74 * }} ctx
75 * @returns {Promise<{ imported: { path: string, source_id?: string }[], count: number }>}
76 */
77 export async function importPdf(input, ctx) {
78 const raw = typeof input === 'string' ? input.trim() : '';
79 if (!raw) throw new Error('PDF path is required');
80
81 const { vaultPath, outputBase, project, tags, dryRun, onProgress } = ctx;
82 if (onProgress) await onProgress({ progress: 0, total: 1, message: 'Reading PDF…' });
83
84 const absInput = path.isAbsolute(raw) ? raw : path.resolve(process.cwd(), raw);
85 if (!fs.existsSync(absInput)) {
86 throw new Error(`Input not found: ${input}`);
87 }
88 if (!fs.statSync(absInput).isFile()) {
89 throw new Error(`PDF import requires a single .pdf file (not a directory): ${input}`);
90 }
91 if (!absInput.toLowerCase().endsWith('.pdf')) {
92 throw new Error(`PDF import requires a .pdf file; got: ${path.basename(absInput)}`);
93 }
94
95 const buf = fs.readFileSync(absInput);
96 const source_id = sourceIdFromPdfBytes(buf);
97 const short = source_id.slice(0, 12);
98 const outputRel = path.join(outputBase, 'imports', 'pdf', `${short}.md`).replace(/\\/g, '/');
99
100 const extracted = await extractPdfBytes(buf);
101 if (!extracted.ok) {
102 throw new Error('Could not extract text from this PDF (empty or image-only)');
103 }
104 const bodyText = extracted.markdown;
105 const totalPages = extracted.totalPages;
106
107 const now = new Date().toISOString().slice(0, 10);
108 const baseName = path.basename(absInput);
109 const title = titleFromPdfFilename(absInput);
110
111 const body =
112 bodyText +
113 '\n\n---\n\n' +
114 `_Imported from PDF:_ \`${baseName}\` · ${totalPages} page(s).\n`;
115
116 const merged = {
117 title,
118 date: now,
119 source: 'pdf-import',
120 source_id,
121 pdf_file: baseName,
122 pdf_pages: totalPages,
123 ...(project && { project: normalizeSlug(project) }),
124 ...(tags.length && { tags }),
125 };
126 if (typeof merged.tags === 'string') merged.tags = tags;
127 else if (Array.isArray(merged.tags)) merged.tags = [...new Set([...merged.tags, ...tags])];
128 else merged.tags = tags;
129
130 if (!dryRun) {
131 writeNote(vaultPath, outputRel, {
132 body,
133 frontmatter: Object.fromEntries(Object.entries(merged).filter(([, v]) => v !== undefined && v !== null && v !== '')),
134 });
135 }
136
137 if (onProgress) await onProgress({ progress: 1, total: 1, message: 'Done' });
138
139 return { imported: [{ path: outputRel, source_id }], count: 1 };
140 }
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago