pdf-presenter 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README-zhtw.md +211 -0
- package/README.md +211 -0
- package/dist/pdf-presenter.js +760 -0
- package/package.json +58 -0
- package/src/ui/audience.html +22 -0
- package/src/ui/audience.js +85 -0
- package/src/ui/modules/import-export.js +94 -0
- package/src/ui/modules/notes-editor.js +126 -0
- package/src/ui/modules/pdf-render.js +66 -0
- package/src/ui/modules/recording-dialog.js +131 -0
- package/src/ui/modules/recording.js +268 -0
- package/src/ui/modules/resizable-layout.js +146 -0
- package/src/ui/modules/timer.js +72 -0
- package/src/ui/presenter-main.js +169 -0
- package/src/ui/presenter.css +669 -0
- package/src/ui/presenter.html +142 -0
- package/src/ui/presenter.js +7 -0
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { existsSync as existsSync6, readFileSync } from "fs";
|
|
5
|
+
import { basename as basename3, relative as relative2 } from "path";
|
|
6
|
+
import { Command, Option } from "commander";
|
|
7
|
+
import open from "open";
|
|
8
|
+
|
|
9
|
+
// src/generate-notes.ts
|
|
10
|
+
import { readFile, writeFile } from "fs/promises";
|
|
11
|
+
import { existsSync as existsSync2 } from "fs";
|
|
12
|
+
import { basename, relative } from "path";
|
|
13
|
+
|
|
14
|
+
// src/utils.ts
|
|
15
|
+
import { createServer } from "net";
|
|
16
|
+
import { extname, resolve } from "path";
|
|
17
|
+
import { existsSync, statSync } from "fs";
|
|
18
|
+
function resolvePdfPath(input) {
|
|
19
|
+
const abs = resolve(input);
|
|
20
|
+
if (!existsSync(abs)) {
|
|
21
|
+
throw new Error(`File not found: ${input}`);
|
|
22
|
+
}
|
|
23
|
+
if (!statSync(abs).isFile()) {
|
|
24
|
+
throw new Error(`Not a file: ${input}`);
|
|
25
|
+
}
|
|
26
|
+
if (extname(abs).toLowerCase() !== ".pdf") {
|
|
27
|
+
throw new Error(`Not a PDF file: ${input}`);
|
|
28
|
+
}
|
|
29
|
+
return abs;
|
|
30
|
+
}
|
|
31
|
+
function notesPathFor(pdfPath) {
|
|
32
|
+
return pdfPath.replace(/\.pdf$/i, ".notes.json");
|
|
33
|
+
}
|
|
34
|
+
async function findAvailablePort(startPort, maxAttempts = 10) {
|
|
35
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
36
|
+
const port = startPort + i;
|
|
37
|
+
if (await isPortFree(port)) return port;
|
|
38
|
+
}
|
|
39
|
+
throw new Error(
|
|
40
|
+
`Could not find a free port (tried ${startPort}..${startPort + maxAttempts - 1})`
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
function isPortFree(port) {
|
|
44
|
+
return new Promise((resolveP) => {
|
|
45
|
+
const srv = createServer();
|
|
46
|
+
srv.once("error", () => resolveP(false));
|
|
47
|
+
srv.once("listening", () => {
|
|
48
|
+
srv.close(() => resolveP(true));
|
|
49
|
+
});
|
|
50
|
+
srv.listen(port, "127.0.0.1");
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// src/generate-notes.ts
|
|
55
|
+
var HINT_MAX = 80;
|
|
56
|
+
async function generateNotesTemplate(pdfPath, options) {
|
|
57
|
+
const notesPath = notesPathFor(pdfPath);
|
|
58
|
+
if (existsSync2(notesPath) && !options.force) {
|
|
59
|
+
const rel = relative(process.cwd(), notesPath) || basename(notesPath);
|
|
60
|
+
const err = new Error(
|
|
61
|
+
`${rel} already exists. Use --force to overwrite.`
|
|
62
|
+
);
|
|
63
|
+
err.code = "NOTES_EXISTS";
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
const pdfjsLib = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
|
67
|
+
const data = new Uint8Array(await readFile(pdfPath));
|
|
68
|
+
const pdf = await pdfjsLib.getDocument({
|
|
69
|
+
data,
|
|
70
|
+
isEvalSupported: false,
|
|
71
|
+
useSystemFonts: true
|
|
72
|
+
}).promise;
|
|
73
|
+
const totalSlides = pdf.numPages;
|
|
74
|
+
if (totalSlides === 0) {
|
|
75
|
+
throw new Error("PDF has no pages");
|
|
76
|
+
}
|
|
77
|
+
const notes = {};
|
|
78
|
+
for (let i = 1; i <= totalSlides; i++) {
|
|
79
|
+
const page = await pdf.getPage(i);
|
|
80
|
+
const text = await page.getTextContent();
|
|
81
|
+
const joined = text.items.map((item) => "str" in item ? item.str : "").join(" ").replace(/\s+/g, " ").trim();
|
|
82
|
+
const hint = joined.slice(0, HINT_MAX).trim();
|
|
83
|
+
notes[String(i)] = { hint, note: "" };
|
|
84
|
+
}
|
|
85
|
+
await pdf.cleanup();
|
|
86
|
+
await pdf.destroy();
|
|
87
|
+
const out = {
|
|
88
|
+
meta: {
|
|
89
|
+
pdf: basename(pdfPath),
|
|
90
|
+
totalSlides,
|
|
91
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
92
|
+
generator: "pdf-presenter"
|
|
93
|
+
},
|
|
94
|
+
notes
|
|
95
|
+
};
|
|
96
|
+
await writeFile(notesPath, JSON.stringify(out, null, 2) + "\n", "utf8");
|
|
97
|
+
return { notesPath, totalSlides };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// src/server.ts
|
|
101
|
+
import { createServer as createServer2 } from "http";
|
|
102
|
+
|
|
103
|
+
// src/server/http-utils.ts
|
|
104
|
+
import { createReadStream, statSync as statSync2 } from "fs";
|
|
105
|
+
import { extname as extname2 } from "path";
|
|
106
|
+
var MIME = {
|
|
107
|
+
".html": "text/html; charset=utf-8",
|
|
108
|
+
".js": "application/javascript; charset=utf-8",
|
|
109
|
+
".mjs": "application/javascript; charset=utf-8",
|
|
110
|
+
".css": "text/css; charset=utf-8",
|
|
111
|
+
".json": "application/json; charset=utf-8",
|
|
112
|
+
".svg": "image/svg+xml",
|
|
113
|
+
".png": "image/png",
|
|
114
|
+
".jpg": "image/jpeg",
|
|
115
|
+
".woff2": "font/woff2"
|
|
116
|
+
};
|
|
117
|
+
var EMPTY_NOTES = JSON.stringify(
|
|
118
|
+
{ meta: { totalSlides: 0, generator: "pdf-presenter" }, notes: {} },
|
|
119
|
+
null,
|
|
120
|
+
2
|
|
121
|
+
);
|
|
122
|
+
var MAX_JSON_BODY = 1e6;
|
|
123
|
+
var MAX_RECORDING_BODY = 500 * 1024 * 1024;
|
|
124
|
+
function send(res, status, body, contentType = "text/plain; charset=utf-8", extraHeaders = {}) {
|
|
125
|
+
res.writeHead(status, {
|
|
126
|
+
"Content-Type": contentType,
|
|
127
|
+
"Cache-Control": "no-store",
|
|
128
|
+
...extraHeaders
|
|
129
|
+
});
|
|
130
|
+
res.end(body);
|
|
131
|
+
}
|
|
132
|
+
function notFound(res) {
|
|
133
|
+
send(res, 404, "Not Found");
|
|
134
|
+
}
|
|
135
|
+
function streamFile(res, filePath, contentType) {
|
|
136
|
+
try {
|
|
137
|
+
const st = statSync2(filePath);
|
|
138
|
+
res.writeHead(200, {
|
|
139
|
+
"Content-Type": contentType,
|
|
140
|
+
"Content-Length": String(st.size),
|
|
141
|
+
"Cache-Control": "no-store"
|
|
142
|
+
});
|
|
143
|
+
const stream = createReadStream(filePath);
|
|
144
|
+
stream.on("error", () => {
|
|
145
|
+
if (!res.headersSent) notFound(res);
|
|
146
|
+
else res.end();
|
|
147
|
+
});
|
|
148
|
+
stream.pipe(res);
|
|
149
|
+
} catch {
|
|
150
|
+
notFound(res);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function contentTypeFor(filePath) {
|
|
154
|
+
return MIME[extname2(filePath).toLowerCase()] ?? "application/octet-stream";
|
|
155
|
+
}
|
|
156
|
+
function isSafeFilename(name) {
|
|
157
|
+
if (name.length === 0 || name.length > 255) return false;
|
|
158
|
+
if (name.includes("/") || name.includes("\\") || name.includes("\0")) return false;
|
|
159
|
+
if (name === "." || name === "..") return false;
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
async function readJsonBody(req) {
|
|
163
|
+
return new Promise((resolveP, rejectP) => {
|
|
164
|
+
let size = 0;
|
|
165
|
+
const chunks = [];
|
|
166
|
+
req.on("data", (chunk) => {
|
|
167
|
+
size += chunk.length;
|
|
168
|
+
if (size > MAX_JSON_BODY) {
|
|
169
|
+
rejectP(new Error("Request body too large"));
|
|
170
|
+
req.destroy();
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
chunks.push(chunk);
|
|
174
|
+
});
|
|
175
|
+
req.on("end", () => {
|
|
176
|
+
try {
|
|
177
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
178
|
+
resolveP(raw.length === 0 ? {} : JSON.parse(raw));
|
|
179
|
+
} catch (err) {
|
|
180
|
+
rejectP(err);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
req.on("error", rejectP);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
async function readBinaryBody(req) {
|
|
187
|
+
return new Promise((resolveP, rejectP) => {
|
|
188
|
+
let size = 0;
|
|
189
|
+
const chunks = [];
|
|
190
|
+
req.on("data", (chunk) => {
|
|
191
|
+
size += chunk.length;
|
|
192
|
+
if (size > MAX_RECORDING_BODY) {
|
|
193
|
+
rejectP(new Error("recording exceeds 500 MB limit"));
|
|
194
|
+
req.destroy();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
chunks.push(chunk);
|
|
198
|
+
});
|
|
199
|
+
req.on("end", () => resolveP(Buffer.concat(chunks)));
|
|
200
|
+
req.on("error", rejectP);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// src/server/paths.ts
|
|
205
|
+
import { existsSync as existsSync3 } from "fs";
|
|
206
|
+
import { createRequire } from "module";
|
|
207
|
+
import { dirname, resolve as resolve2 } from "path";
|
|
208
|
+
import { fileURLToPath } from "url";
|
|
209
|
+
function resolveUiDir(callerUrl) {
|
|
210
|
+
const here = dirname(fileURLToPath(callerUrl));
|
|
211
|
+
const candidates = [
|
|
212
|
+
resolve2(here, "../src/ui"),
|
|
213
|
+
// published layout: dist/ → ../src/ui
|
|
214
|
+
resolve2(here, "./ui")
|
|
215
|
+
// dev: src/ → ./ui
|
|
216
|
+
];
|
|
217
|
+
for (const c of candidates) {
|
|
218
|
+
if (existsSync3(c)) return c;
|
|
219
|
+
}
|
|
220
|
+
throw new Error(
|
|
221
|
+
`Could not locate UI assets (looked in: ${candidates.join(", ")})`
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
function resolvePdfjsDir(callerUrl) {
|
|
225
|
+
const require2 = createRequire(callerUrl);
|
|
226
|
+
const pkgJson = require2.resolve("pdfjs-dist/package.json");
|
|
227
|
+
return dirname(pkgJson);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// src/server/html-render.ts
|
|
231
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
232
|
+
import { basename as basename2, join } from "path";
|
|
233
|
+
async function renderHtml(uiDir, file, config) {
|
|
234
|
+
const raw = await readFile2(join(uiDir, file), "utf8");
|
|
235
|
+
const meta = {
|
|
236
|
+
pdfUrl: "/slides.pdf",
|
|
237
|
+
notesUrl: "/notes.json",
|
|
238
|
+
pdfName: basename2(config.pdfPath),
|
|
239
|
+
timerMinutes: config.timerMinutes ?? null
|
|
240
|
+
};
|
|
241
|
+
return raw.replace(
|
|
242
|
+
"<!--PDF_PRESENTER_CONFIG-->",
|
|
243
|
+
`<script id="pdf-presenter-config" type="application/json">${JSON.stringify(meta)}</script>`
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// src/server/notes-store.ts
|
|
248
|
+
import { existsSync as existsSync4 } from "fs";
|
|
249
|
+
import { readFile as readFile3, writeFile as writeFile2, rename } from "fs/promises";
|
|
250
|
+
function validateNotesDoc(raw) {
|
|
251
|
+
if (!raw || typeof raw !== "object") {
|
|
252
|
+
return { ok: false, error: "body must be a JSON object" };
|
|
253
|
+
}
|
|
254
|
+
const obj = raw;
|
|
255
|
+
if (obj.notes === void 0 || obj.notes === null) {
|
|
256
|
+
return { ok: false, error: "missing 'notes' field" };
|
|
257
|
+
}
|
|
258
|
+
if (typeof obj.notes !== "object" || Array.isArray(obj.notes)) {
|
|
259
|
+
return { ok: false, error: "'notes' must be an object" };
|
|
260
|
+
}
|
|
261
|
+
const notes = {};
|
|
262
|
+
for (const [key, value] of Object.entries(obj.notes)) {
|
|
263
|
+
const n = Number.parseInt(key, 10);
|
|
264
|
+
if (!Number.isInteger(n) || n < 1 || String(n) !== key) {
|
|
265
|
+
return { ok: false, error: `invalid slide key: ${JSON.stringify(key)}` };
|
|
266
|
+
}
|
|
267
|
+
if (!value || typeof value !== "object") {
|
|
268
|
+
return { ok: false, error: `slide ${key} entry must be an object` };
|
|
269
|
+
}
|
|
270
|
+
const entry = value;
|
|
271
|
+
if (entry.hint !== void 0 && typeof entry.hint !== "string") {
|
|
272
|
+
return { ok: false, error: `slide ${key} hint must be a string` };
|
|
273
|
+
}
|
|
274
|
+
if (entry.note !== void 0 && typeof entry.note !== "string") {
|
|
275
|
+
return { ok: false, error: `slide ${key} note must be a string` };
|
|
276
|
+
}
|
|
277
|
+
notes[key] = {
|
|
278
|
+
hint: typeof entry.hint === "string" ? entry.hint : "",
|
|
279
|
+
note: typeof entry.note === "string" ? entry.note : ""
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
const meta = obj.meta && typeof obj.meta === "object" && !Array.isArray(obj.meta) ? obj.meta : {};
|
|
283
|
+
return { ok: true, doc: { meta, notes } };
|
|
284
|
+
}
|
|
285
|
+
async function loadNotesDoc(notesPath) {
|
|
286
|
+
if (!existsSync4(notesPath)) {
|
|
287
|
+
return {
|
|
288
|
+
meta: { generator: "pdf-presenter" },
|
|
289
|
+
notes: {}
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
const raw = await readFile3(notesPath, "utf8");
|
|
293
|
+
try {
|
|
294
|
+
const parsed = JSON.parse(raw);
|
|
295
|
+
return {
|
|
296
|
+
meta: parsed.meta ?? { generator: "pdf-presenter" },
|
|
297
|
+
notes: parsed.notes ?? {}
|
|
298
|
+
};
|
|
299
|
+
} catch {
|
|
300
|
+
return {
|
|
301
|
+
meta: { generator: "pdf-presenter" },
|
|
302
|
+
notes: {}
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
async function writeNotesDoc(notesPath, doc) {
|
|
307
|
+
const body = JSON.stringify(doc, null, 2) + "\n";
|
|
308
|
+
const tmp = `${notesPath}.tmp-${process.pid}-${Date.now()}`;
|
|
309
|
+
await writeFile2(tmp, body, "utf8");
|
|
310
|
+
await rename(tmp, notesPath);
|
|
311
|
+
}
|
|
312
|
+
function createNotesUpdater(notesPath) {
|
|
313
|
+
let chain = Promise.resolve();
|
|
314
|
+
return (updater) => {
|
|
315
|
+
const next = chain.then(async () => {
|
|
316
|
+
const current = await loadNotesDoc(notesPath);
|
|
317
|
+
const updated = await updater(current);
|
|
318
|
+
await writeNotesDoc(notesPath, updated);
|
|
319
|
+
return updated;
|
|
320
|
+
});
|
|
321
|
+
chain = next.catch(() => void 0);
|
|
322
|
+
return next;
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// src/server/routes/notes.ts
|
|
327
|
+
async function handleNotesRoutes(req, res, url, deps) {
|
|
328
|
+
const pathname = url.pathname;
|
|
329
|
+
const method = req.method ?? "GET";
|
|
330
|
+
const { updateNotes } = deps;
|
|
331
|
+
if (pathname === "/api/notes-file" && (method === "PUT" || method === "POST")) {
|
|
332
|
+
const body = await readJsonBody(req);
|
|
333
|
+
const validation = validateNotesDoc(body);
|
|
334
|
+
if (!validation.ok) {
|
|
335
|
+
send(res, 400, JSON.stringify({ error: validation.error }), MIME[".json"]);
|
|
336
|
+
return "handled";
|
|
337
|
+
}
|
|
338
|
+
const incoming = validation.doc;
|
|
339
|
+
await updateNotes(() => ({
|
|
340
|
+
meta: {
|
|
341
|
+
...incoming.meta,
|
|
342
|
+
generator: incoming.meta.generator ?? "pdf-presenter",
|
|
343
|
+
lastEditedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
344
|
+
},
|
|
345
|
+
notes: incoming.notes
|
|
346
|
+
}));
|
|
347
|
+
send(
|
|
348
|
+
res,
|
|
349
|
+
200,
|
|
350
|
+
JSON.stringify({
|
|
351
|
+
ok: true,
|
|
352
|
+
slideCount: Object.keys(incoming.notes).length
|
|
353
|
+
}),
|
|
354
|
+
MIME[".json"]
|
|
355
|
+
);
|
|
356
|
+
return "handled";
|
|
357
|
+
}
|
|
358
|
+
if (pathname === "/api/notes" && (method === "PUT" || method === "POST")) {
|
|
359
|
+
const body = await readJsonBody(req);
|
|
360
|
+
const slideRaw = body.slide;
|
|
361
|
+
const note = body.note;
|
|
362
|
+
const slideNum = typeof slideRaw === "number" ? slideRaw : typeof slideRaw === "string" ? Number.parseInt(slideRaw, 10) : NaN;
|
|
363
|
+
if (!Number.isInteger(slideNum) || slideNum < 1) {
|
|
364
|
+
send(
|
|
365
|
+
res,
|
|
366
|
+
400,
|
|
367
|
+
JSON.stringify({ error: "slide must be a positive integer" }),
|
|
368
|
+
MIME[".json"]
|
|
369
|
+
);
|
|
370
|
+
return "handled";
|
|
371
|
+
}
|
|
372
|
+
if (typeof note !== "string") {
|
|
373
|
+
send(
|
|
374
|
+
res,
|
|
375
|
+
400,
|
|
376
|
+
JSON.stringify({ error: "note must be a string" }),
|
|
377
|
+
MIME[".json"]
|
|
378
|
+
);
|
|
379
|
+
return "handled";
|
|
380
|
+
}
|
|
381
|
+
const key = String(slideNum);
|
|
382
|
+
await updateNotes((doc) => {
|
|
383
|
+
const existing = doc.notes[key] ?? {};
|
|
384
|
+
const nextNotes = {
|
|
385
|
+
...doc.notes,
|
|
386
|
+
[key]: { hint: existing.hint ?? "", note }
|
|
387
|
+
};
|
|
388
|
+
const nextMeta = {
|
|
389
|
+
generator: "pdf-presenter",
|
|
390
|
+
...doc.meta,
|
|
391
|
+
lastEditedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
392
|
+
};
|
|
393
|
+
return { meta: nextMeta, notes: nextNotes };
|
|
394
|
+
});
|
|
395
|
+
send(res, 200, JSON.stringify({ ok: true, slide: slideNum }), MIME[".json"]);
|
|
396
|
+
return "handled";
|
|
397
|
+
}
|
|
398
|
+
return "pass";
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// src/server/routes/recording.ts
|
|
402
|
+
import { writeFile as writeFile3, mkdir } from "fs/promises";
|
|
403
|
+
import { dirname as dirname2, join as join2 } from "path";
|
|
404
|
+
async function handleRecordingRoutes(req, res, url, deps) {
|
|
405
|
+
const pathname = url.pathname;
|
|
406
|
+
const method = req.method ?? "GET";
|
|
407
|
+
if (pathname === "/api/recording-meta" && method === "POST") {
|
|
408
|
+
const filenameParam = url.searchParams.get("filename");
|
|
409
|
+
if (!filenameParam) {
|
|
410
|
+
send(
|
|
411
|
+
res,
|
|
412
|
+
400,
|
|
413
|
+
JSON.stringify({ error: "filename query param required" }),
|
|
414
|
+
MIME[".json"]
|
|
415
|
+
);
|
|
416
|
+
return "handled";
|
|
417
|
+
}
|
|
418
|
+
if (!isSafeFilename(filenameParam)) {
|
|
419
|
+
send(res, 400, JSON.stringify({ error: "invalid filename" }), MIME[".json"]);
|
|
420
|
+
return "handled";
|
|
421
|
+
}
|
|
422
|
+
let body;
|
|
423
|
+
try {
|
|
424
|
+
body = await readJsonBody(req);
|
|
425
|
+
} catch (err) {
|
|
426
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
427
|
+
send(res, 400, JSON.stringify({ error: msg }), MIME[".json"]);
|
|
428
|
+
return "handled";
|
|
429
|
+
}
|
|
430
|
+
if (!body || typeof body !== "object") {
|
|
431
|
+
send(
|
|
432
|
+
res,
|
|
433
|
+
400,
|
|
434
|
+
JSON.stringify({ error: "body must be a JSON object" }),
|
|
435
|
+
MIME[".json"]
|
|
436
|
+
);
|
|
437
|
+
return "handled";
|
|
438
|
+
}
|
|
439
|
+
const outDir = join2(dirname2(deps.pdfPath), "recordings");
|
|
440
|
+
await mkdir(outDir, { recursive: true });
|
|
441
|
+
const outPath = join2(outDir, filenameParam);
|
|
442
|
+
const text = JSON.stringify(body, null, 2) + "\n";
|
|
443
|
+
await writeFile3(outPath, text, "utf8");
|
|
444
|
+
send(
|
|
445
|
+
res,
|
|
446
|
+
200,
|
|
447
|
+
JSON.stringify({
|
|
448
|
+
ok: true,
|
|
449
|
+
path: outPath,
|
|
450
|
+
bytes: Buffer.byteLength(text)
|
|
451
|
+
}),
|
|
452
|
+
MIME[".json"]
|
|
453
|
+
);
|
|
454
|
+
return "handled";
|
|
455
|
+
}
|
|
456
|
+
if (pathname === "/api/recording" && method === "POST") {
|
|
457
|
+
const filenameParam = url.searchParams.get("filename");
|
|
458
|
+
if (!filenameParam) {
|
|
459
|
+
send(
|
|
460
|
+
res,
|
|
461
|
+
400,
|
|
462
|
+
JSON.stringify({ error: "filename query param required" }),
|
|
463
|
+
MIME[".json"]
|
|
464
|
+
);
|
|
465
|
+
return "handled";
|
|
466
|
+
}
|
|
467
|
+
if (!isSafeFilename(filenameParam)) {
|
|
468
|
+
send(res, 400, JSON.stringify({ error: "invalid filename" }), MIME[".json"]);
|
|
469
|
+
return "handled";
|
|
470
|
+
}
|
|
471
|
+
let bytes;
|
|
472
|
+
try {
|
|
473
|
+
bytes = await readBinaryBody(req);
|
|
474
|
+
} catch (err) {
|
|
475
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
476
|
+
send(res, 413, JSON.stringify({ error: msg }), MIME[".json"]);
|
|
477
|
+
return "handled";
|
|
478
|
+
}
|
|
479
|
+
if (bytes.length === 0) {
|
|
480
|
+
send(
|
|
481
|
+
res,
|
|
482
|
+
400,
|
|
483
|
+
JSON.stringify({ error: "empty recording body" }),
|
|
484
|
+
MIME[".json"]
|
|
485
|
+
);
|
|
486
|
+
return "handled";
|
|
487
|
+
}
|
|
488
|
+
const outDir = join2(dirname2(deps.pdfPath), "recordings");
|
|
489
|
+
await mkdir(outDir, { recursive: true });
|
|
490
|
+
const outPath = join2(outDir, filenameParam);
|
|
491
|
+
await writeFile3(outPath, bytes);
|
|
492
|
+
send(
|
|
493
|
+
res,
|
|
494
|
+
200,
|
|
495
|
+
JSON.stringify({ ok: true, path: outPath, bytes: bytes.length }),
|
|
496
|
+
MIME[".json"]
|
|
497
|
+
);
|
|
498
|
+
return "handled";
|
|
499
|
+
}
|
|
500
|
+
return "pass";
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// src/server/routes/static.ts
|
|
504
|
+
import { existsSync as existsSync5 } from "fs";
|
|
505
|
+
import { resolve as resolve3 } from "path";
|
|
506
|
+
async function handleStaticRoutes(req, res, url, deps) {
|
|
507
|
+
const pathname = url.pathname;
|
|
508
|
+
const method = req.method ?? "GET";
|
|
509
|
+
if (method !== "GET" && method !== "HEAD") return "pass";
|
|
510
|
+
if (pathname === "/" || pathname === "/audience") {
|
|
511
|
+
send(res, 200, deps.audienceHtml, MIME[".html"]);
|
|
512
|
+
return "handled";
|
|
513
|
+
}
|
|
514
|
+
if (pathname === "/presenter") {
|
|
515
|
+
send(res, 200, deps.presenterHtml, MIME[".html"]);
|
|
516
|
+
return "handled";
|
|
517
|
+
}
|
|
518
|
+
if (pathname === "/slides.pdf") {
|
|
519
|
+
streamFile(res, deps.pdfPath, "application/pdf");
|
|
520
|
+
return "handled";
|
|
521
|
+
}
|
|
522
|
+
if (pathname === "/notes.json") {
|
|
523
|
+
if (existsSync5(deps.notesPath)) {
|
|
524
|
+
streamFile(res, deps.notesPath, MIME[".json"]);
|
|
525
|
+
} else {
|
|
526
|
+
send(res, 200, EMPTY_NOTES, MIME[".json"]);
|
|
527
|
+
}
|
|
528
|
+
return "handled";
|
|
529
|
+
}
|
|
530
|
+
if (pathname.startsWith("/assets/pdfjs/")) {
|
|
531
|
+
const rel = pathname.slice("/assets/pdfjs/".length);
|
|
532
|
+
const safe = resolve3(deps.pdfjsDir, rel);
|
|
533
|
+
if (!safe.startsWith(deps.pdfjsDir + "/") && safe !== deps.pdfjsDir) {
|
|
534
|
+
send(res, 403, "Forbidden");
|
|
535
|
+
return "handled";
|
|
536
|
+
}
|
|
537
|
+
if (!existsSync5(safe)) {
|
|
538
|
+
notFound(res);
|
|
539
|
+
return "handled";
|
|
540
|
+
}
|
|
541
|
+
streamFile(res, safe, contentTypeFor(safe));
|
|
542
|
+
return "handled";
|
|
543
|
+
}
|
|
544
|
+
if (pathname.startsWith("/assets/")) {
|
|
545
|
+
const rel = pathname.slice("/assets/".length);
|
|
546
|
+
const safe = resolve3(deps.uiDir, rel);
|
|
547
|
+
if (!safe.startsWith(deps.uiDir + "/") && safe !== deps.uiDir) {
|
|
548
|
+
send(res, 403, "Forbidden");
|
|
549
|
+
return "handled";
|
|
550
|
+
}
|
|
551
|
+
if (!existsSync5(safe)) {
|
|
552
|
+
notFound(res);
|
|
553
|
+
return "handled";
|
|
554
|
+
}
|
|
555
|
+
streamFile(res, safe, contentTypeFor(safe));
|
|
556
|
+
return "handled";
|
|
557
|
+
}
|
|
558
|
+
return "pass";
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// src/server.ts
|
|
562
|
+
async function startServer(config) {
|
|
563
|
+
const uiDir = resolveUiDir(import.meta.url);
|
|
564
|
+
const pdfjsDir = resolvePdfjsDir(import.meta.url);
|
|
565
|
+
const audienceHtml = await renderHtml(uiDir, "audience.html", config);
|
|
566
|
+
const presenterHtml = await renderHtml(uiDir, "presenter.html", config);
|
|
567
|
+
const updateNotes = createNotesUpdater(config.notesPath);
|
|
568
|
+
const handler = async (req, res) => {
|
|
569
|
+
const url = new URL(req.url ?? "/", `http://localhost:${config.port}`);
|
|
570
|
+
try {
|
|
571
|
+
if (await handleRecordingRoutes(req, res, url, {
|
|
572
|
+
pdfPath: config.pdfPath
|
|
573
|
+
}) === "handled")
|
|
574
|
+
return;
|
|
575
|
+
if (await handleNotesRoutes(req, res, url, {
|
|
576
|
+
notesPath: config.notesPath,
|
|
577
|
+
updateNotes
|
|
578
|
+
}) === "handled")
|
|
579
|
+
return;
|
|
580
|
+
if (await handleStaticRoutes(req, res, url, {
|
|
581
|
+
audienceHtml,
|
|
582
|
+
presenterHtml,
|
|
583
|
+
pdfPath: config.pdfPath,
|
|
584
|
+
notesPath: config.notesPath,
|
|
585
|
+
uiDir,
|
|
586
|
+
pdfjsDir
|
|
587
|
+
}) === "handled")
|
|
588
|
+
return;
|
|
589
|
+
notFound(res);
|
|
590
|
+
} catch (err) {
|
|
591
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
592
|
+
if (!res.headersSent) send(res, 500, `Internal Server Error: ${msg}`);
|
|
593
|
+
else res.end();
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
const server = createServer2((req, res) => {
|
|
597
|
+
handler(req, res).catch((err) => {
|
|
598
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
599
|
+
if (!res.headersSent) {
|
|
600
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
601
|
+
}
|
|
602
|
+
res.end(`Internal Server Error: ${msg}`);
|
|
603
|
+
});
|
|
604
|
+
});
|
|
605
|
+
await new Promise((resolveP, rejectP) => {
|
|
606
|
+
server.once("error", rejectP);
|
|
607
|
+
server.listen(config.port, "127.0.0.1", () => {
|
|
608
|
+
server.off("error", rejectP);
|
|
609
|
+
resolveP();
|
|
610
|
+
});
|
|
611
|
+
});
|
|
612
|
+
return {
|
|
613
|
+
port: config.port,
|
|
614
|
+
stop: () => new Promise((resolveP, rejectP) => {
|
|
615
|
+
server.close((err) => err ? rejectP(err) : resolveP());
|
|
616
|
+
})
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// src/cli.ts
|
|
621
|
+
var VERSION = "1.0.0";
|
|
622
|
+
async function runCli(argv) {
|
|
623
|
+
const program = new Command();
|
|
624
|
+
program.name("pdf-presenter").description(
|
|
625
|
+
"Serve a PDF as browser slides with a full presenter mode (notes, next preview, timer)."
|
|
626
|
+
).version(VERSION, "-v, --version").argument("<file>", "Path to the PDF file").addOption(new Option("-p, --port <port>", "Server port").default("3000")).option("--no-open", "Don't auto-open browser").option("--presenter", "Open directly in presenter mode", false).option("-n, --notes <path>", "Path to notes JSON file").option("-t, --timer <minutes>", "Countdown timer in minutes").option(
|
|
627
|
+
"-gn, --generate-presenter-note-template",
|
|
628
|
+
"Generate a notes template JSON next to the PDF",
|
|
629
|
+
false
|
|
630
|
+
).option("--force", "Overwrite existing notes file when used with -gn", false).action(async (file, options) => {
|
|
631
|
+
try {
|
|
632
|
+
const pdfPath = resolvePdfPath(file);
|
|
633
|
+
if (options.generatePresenterNoteTemplate) {
|
|
634
|
+
await runGenerate(pdfPath, { force: !!options.force });
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
await runServe(pdfPath, options);
|
|
638
|
+
} catch (err) {
|
|
639
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
640
|
+
process.stderr.write(`
|
|
641
|
+
Error: ${msg}
|
|
642
|
+
`);
|
|
643
|
+
process.exit(1);
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
program.showHelpAfterError();
|
|
647
|
+
await program.parseAsync(argv);
|
|
648
|
+
}
|
|
649
|
+
async function runGenerate(pdfPath, opts) {
|
|
650
|
+
try {
|
|
651
|
+
const result = await generateNotesTemplate(pdfPath, opts);
|
|
652
|
+
const rel = relative2(process.cwd(), result.notesPath) || basename3(result.notesPath);
|
|
653
|
+
process.stdout.write(
|
|
654
|
+
`
|
|
655
|
+
\u2705 Generated ${rel} (${result.totalSlides} slides)
|
|
656
|
+
|
|
657
|
+
Edit the "note" fields in the JSON file, then run:
|
|
658
|
+
pdf-presenter ${relative2(process.cwd(), pdfPath) || basename3(pdfPath)}
|
|
659
|
+
|
|
660
|
+
`
|
|
661
|
+
);
|
|
662
|
+
} catch (err) {
|
|
663
|
+
const code = err.code;
|
|
664
|
+
if (code === "NOTES_EXISTS") {
|
|
665
|
+
process.stderr.write(`
|
|
666
|
+
\u26A0 ${err.message}
|
|
667
|
+
|
|
668
|
+
`);
|
|
669
|
+
process.exit(1);
|
|
670
|
+
}
|
|
671
|
+
throw err;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
async function runServe(pdfPath, options) {
|
|
675
|
+
const startPort = Number.parseInt(options.port, 10);
|
|
676
|
+
if (!Number.isFinite(startPort) || startPort <= 0) {
|
|
677
|
+
throw new Error(`Invalid --port value: ${options.port}`);
|
|
678
|
+
}
|
|
679
|
+
const notesPath = options.notes ? resolveMaybeExisting(options.notes) : notesPathFor(pdfPath);
|
|
680
|
+
let timerMinutes;
|
|
681
|
+
if (options.timer !== void 0) {
|
|
682
|
+
const t = Number.parseFloat(options.timer);
|
|
683
|
+
if (!Number.isFinite(t) || t <= 0) {
|
|
684
|
+
throw new Error(`Invalid --timer value: ${options.timer}`);
|
|
685
|
+
}
|
|
686
|
+
timerMinutes = t;
|
|
687
|
+
}
|
|
688
|
+
const port = await findAvailablePort(startPort);
|
|
689
|
+
const server = await startServer({ pdfPath, notesPath, port, timerMinutes });
|
|
690
|
+
const notesInfo = describeNotes(notesPath);
|
|
691
|
+
const url = `http://localhost:${port}`;
|
|
692
|
+
const presenterUrl = `${url}/presenter`;
|
|
693
|
+
process.stdout.write(
|
|
694
|
+
`
|
|
695
|
+
\u{1F3AF} pdf-presenter v${VERSION}
|
|
696
|
+
|
|
697
|
+
Audience: ${url}
|
|
698
|
+
Presenter: ${presenterUrl}
|
|
699
|
+
|
|
700
|
+
PDF: ${basename3(pdfPath)}
|
|
701
|
+
Notes: ${notesInfo}
|
|
702
|
+
` + (timerMinutes !== void 0 ? ` Timer: ${formatMinutes(timerMinutes)}
|
|
703
|
+
` : "") + `
|
|
704
|
+
Press Ctrl+C to stop.
|
|
705
|
+
|
|
706
|
+
`
|
|
707
|
+
);
|
|
708
|
+
if (options.open) {
|
|
709
|
+
const target = options.presenter ? presenterUrl : url;
|
|
710
|
+
open(target).catch(() => {
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
const shutdown = async (signal) => {
|
|
714
|
+
process.stdout.write(`
|
|
715
|
+
Received ${signal}, shutting down...
|
|
716
|
+
`);
|
|
717
|
+
try {
|
|
718
|
+
await server.stop();
|
|
719
|
+
} catch {
|
|
720
|
+
}
|
|
721
|
+
process.exit(0);
|
|
722
|
+
};
|
|
723
|
+
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
724
|
+
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
725
|
+
}
|
|
726
|
+
function resolveMaybeExisting(path) {
|
|
727
|
+
return path;
|
|
728
|
+
}
|
|
729
|
+
function describeNotes(notesPath) {
|
|
730
|
+
if (!existsSync6(notesPath)) {
|
|
731
|
+
return `${basename3(notesPath)} (not found \u2014 using empty notes)`;
|
|
732
|
+
}
|
|
733
|
+
try {
|
|
734
|
+
const raw = readFileSync(notesPath, "utf8");
|
|
735
|
+
const parsed = JSON.parse(raw);
|
|
736
|
+
const total = parsed.meta?.totalSlides ?? 0;
|
|
737
|
+
const filled = Object.values(parsed.notes ?? {}).filter(
|
|
738
|
+
(e) => typeof e.note === "string" && e.note.trim() !== ""
|
|
739
|
+
).length;
|
|
740
|
+
const suffix = total > 0 ? ` (${filled}/${total} slides have notes)` : "";
|
|
741
|
+
return `${basename3(notesPath)}${suffix}`;
|
|
742
|
+
} catch {
|
|
743
|
+
return `${basename3(notesPath)} (invalid JSON \u2014 using empty notes)`;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
function formatMinutes(minutes) {
|
|
747
|
+
const total = Math.round(minutes * 60);
|
|
748
|
+
const m = Math.floor(total / 60);
|
|
749
|
+
const s = total % 60;
|
|
750
|
+
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// bin/pdf-presenter.ts
|
|
754
|
+
runCli(process.argv).catch((err) => {
|
|
755
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
756
|
+
process.stderr.write(`
|
|
757
|
+
Fatal: ${msg}
|
|
758
|
+
`);
|
|
759
|
+
process.exit(1);
|
|
760
|
+
});
|