pdf-presenter 1.0.0 → 1.2.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/README-zhtw.md +23 -1
- package/README.md +28 -1
- package/dist/pdf-presenter.js +345 -29
- package/package.json +1 -1
- package/src/ui/audience.html +3 -1
- package/src/ui/audience.js +18 -0
- package/src/ui/favicon-presenter.svg +1 -0
- package/src/ui/favicon.svg +1 -0
- package/src/ui/listing.html +66 -0
- package/src/ui/listing.js +49 -0
- package/src/ui/modules/cursor-sync.js +76 -0
- package/src/ui/modules/grid-view.js +222 -0
- package/src/ui/presenter-main.js +54 -0
- package/src/ui/presenter.css +185 -0
- package/src/ui/presenter.html +23 -1
package/README-zhtw.md
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
# pdf-presenter
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
<p align="center">
|
|
4
|
+
<a href="https://www.npmjs.com/package/pdf-presenter"><img alt="npm version" src="https://img.shields.io/npm/v/pdf-presenter?color=cb3837&logo=npm&logoColor=white&label=npm"></a>
|
|
5
|
+
<a href="https://www.npmjs.com/package/pdf-presenter"><img alt="npm downloads" src="https://img.shields.io/npm/dm/pdf-presenter?color=cb3837&logo=npm&logoColor=white"></a>
|
|
6
|
+
<a href="https://github.com/htlin222/pdf-presenter/blob/main/LICENSE"><img alt="license" src="https://img.shields.io/npm/l/pdf-presenter?color=blue"></a>
|
|
7
|
+
<a href="https://nodejs.org"><img alt="node" src="https://img.shields.io/node/v/pdf-presenter?color=5fa04e&logo=node.js&logoColor=white"></a>
|
|
8
|
+
</p>
|
|
9
|
+
|
|
10
|
+
<p align="center">
|
|
11
|
+
<a href="https://github.com/htlin222/pdf-presenter/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/htlin222/pdf-presenter/actions/workflows/ci.yml/badge.svg"></a>
|
|
12
|
+
<a href="https://github.com/htlin222/pdf-presenter/actions/workflows/publish.yml"><img alt="Publish" src="https://github.com/htlin222/pdf-presenter/actions/workflows/publish.yml/badge.svg"></a>
|
|
13
|
+
<a href="https://github.com/htlin222/pdf-presenter/releases"><img alt="release" src="https://img.shields.io/github/v/release/htlin222/pdf-presenter?color=6f42c1&logo=github&logoColor=white"></a>
|
|
14
|
+
<a href="https://www.npmjs.com/package/pdf-presenter"><img alt="provenance" src="https://img.shields.io/badge/npm-provenance%20verified-2ea44f?logo=npm&logoColor=white"></a>
|
|
15
|
+
</p>
|
|
16
|
+
|
|
17
|
+
<p align="center">
|
|
18
|
+
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5.x-3178c6?logo=typescript&logoColor=white">
|
|
19
|
+
<img alt="pnpm" src="https://img.shields.io/badge/pnpm-10-f69220?logo=pnpm&logoColor=white">
|
|
20
|
+
<img alt="tsup" src="https://img.shields.io/badge/bundler-tsup-ff4f64">
|
|
21
|
+
<img alt="pdf.js" src="https://img.shields.io/badge/pdf.js-4.x-e31e24">
|
|
22
|
+
<a href="https://github.com/htlin222/pdf-presenter/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/htlin222/pdf-presenter?style=social"></a>
|
|
23
|
+
</p>
|
|
24
|
+
|
|
25
|
+
> 輕量級 CLI 工具,用瀏覽器播放 PDF 投影片,並提供完整的主講者模式 —— 包含講者備註、下一張預覽、可暫停的計時器、帶有逐張時間軸的錄音,以及可調整的面板。不需要轉換 Markdown,也不需要原生相依套件。指向 PDF,就能開始。
|
|
4
26
|
|
|
5
27
|
[English version →](./README.md)
|
|
6
28
|
|
package/README.md
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
# pdf-presenter
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
<p align="center">
|
|
4
|
+
<a href="https://www.npmjs.com/package/pdf-presenter"><img alt="npm version" src="https://img.shields.io/npm/v/pdf-presenter?color=cb3837&logo=npm&logoColor=white&label=npm"></a>
|
|
5
|
+
<a href="https://www.npmjs.com/package/pdf-presenter"><img alt="npm downloads" src="https://img.shields.io/npm/dm/pdf-presenter?color=cb3837&logo=npm&logoColor=white"></a>
|
|
6
|
+
<a href="https://github.com/htlin222/pdf-presenter/blob/main/LICENSE"><img alt="license" src="https://img.shields.io/npm/l/pdf-presenter?color=blue"></a>
|
|
7
|
+
<a href="https://nodejs.org"><img alt="node" src="https://img.shields.io/node/v/pdf-presenter?color=5fa04e&logo=node.js&logoColor=white"></a>
|
|
8
|
+
</p>
|
|
9
|
+
|
|
10
|
+
<p align="center">
|
|
11
|
+
<a href="https://github.com/htlin222/pdf-presenter/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/htlin222/pdf-presenter/actions/workflows/ci.yml/badge.svg"></a>
|
|
12
|
+
<a href="https://github.com/htlin222/pdf-presenter/actions/workflows/publish.yml"><img alt="Publish" src="https://github.com/htlin222/pdf-presenter/actions/workflows/publish.yml/badge.svg"></a>
|
|
13
|
+
<a href="https://github.com/htlin222/pdf-presenter/releases"><img alt="release" src="https://img.shields.io/github/v/release/htlin222/pdf-presenter?color=6f42c1&logo=github&logoColor=white"></a>
|
|
14
|
+
<a href="https://www.npmjs.com/package/pdf-presenter"><img alt="provenance" src="https://img.shields.io/badge/npm-provenance%20verified-2ea44f?logo=npm&logoColor=white"></a>
|
|
15
|
+
</p>
|
|
16
|
+
|
|
17
|
+
<p align="center">
|
|
18
|
+
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5.x-3178c6?logo=typescript&logoColor=white">
|
|
19
|
+
<img alt="pnpm" src="https://img.shields.io/badge/pnpm-10-f69220?logo=pnpm&logoColor=white">
|
|
20
|
+
<img alt="tsup" src="https://img.shields.io/badge/bundler-tsup-ff4f64">
|
|
21
|
+
<img alt="pdf.js" src="https://img.shields.io/badge/pdf.js-4.x-e31e24">
|
|
22
|
+
<a href="https://github.com/htlin222/pdf-presenter/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/htlin222/pdf-presenter?style=social"></a>
|
|
23
|
+
</p>
|
|
24
|
+
|
|
25
|
+
> Lightweight CLI that serves a PDF as browser slides with a full presenter mode — speaker notes, next-slide preview, pause-able timer, audio recording with per-slide timeline metadata, and resizable panes. No markdown conversion, no native dependencies. Point it at a PDF and go.
|
|
26
|
+
|
|
27
|
+
<p align="center">
|
|
28
|
+
<img src="assets/presenter-preview.png" alt="Presenter view — current slide, speaker notes, next slide preview, and timer" width="720" />
|
|
29
|
+
</p>
|
|
4
30
|
|
|
5
31
|
[繁體中文版 →](./README-zhtw.md)
|
|
6
32
|
|
|
@@ -8,6 +34,7 @@
|
|
|
8
34
|
npx pdf-presenter slides.pdf # serve & open browser
|
|
9
35
|
npx pdf-presenter -gn slides.pdf # generate a notes template
|
|
10
36
|
npx pdf-presenter slides.pdf -t 20 # 20-minute countdown
|
|
37
|
+
npx pdf-presenter ./slides/ # browse a directory of PDFs
|
|
11
38
|
```
|
|
12
39
|
|
|
13
40
|
---
|
package/dist/pdf-presenter.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import { existsSync as
|
|
5
|
-
import { basename as
|
|
4
|
+
import { existsSync as existsSync7, readFileSync as readFileSync2, statSync as statSync3 } from "fs";
|
|
5
|
+
import { basename as basename4, relative as relative2, resolve as resolve5 } from "path";
|
|
6
6
|
import { Command, Option } from "commander";
|
|
7
7
|
import open from "open";
|
|
8
8
|
|
|
@@ -13,8 +13,8 @@ import { basename, relative } from "path";
|
|
|
13
13
|
|
|
14
14
|
// src/utils.ts
|
|
15
15
|
import { createServer } from "net";
|
|
16
|
-
import { extname, resolve } from "path";
|
|
17
|
-
import { existsSync, statSync } from "fs";
|
|
16
|
+
import { extname, join, resolve } from "path";
|
|
17
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
|
|
18
18
|
function resolvePdfPath(input) {
|
|
19
19
|
const abs = resolve(input);
|
|
20
20
|
if (!existsSync(abs)) {
|
|
@@ -31,6 +31,31 @@ function resolvePdfPath(input) {
|
|
|
31
31
|
function notesPathFor(pdfPath) {
|
|
32
32
|
return pdfPath.replace(/\.pdf$/i, ".notes.json");
|
|
33
33
|
}
|
|
34
|
+
function listPdfsInDirectory(dirPath) {
|
|
35
|
+
return readdirSync(dirPath).filter((f) => extname(f).toLowerCase() === ".pdf").sort((a, b) => a.localeCompare(b));
|
|
36
|
+
}
|
|
37
|
+
function getPdfNotesStatus(dirPath, pdfName) {
|
|
38
|
+
const notesPath = notesPathFor(join(dirPath, pdfName));
|
|
39
|
+
const result = {
|
|
40
|
+
pdfName,
|
|
41
|
+
hasNotes: false,
|
|
42
|
+
filledCount: 0,
|
|
43
|
+
totalSlides: 0
|
|
44
|
+
};
|
|
45
|
+
if (!existsSync(notesPath)) return result;
|
|
46
|
+
try {
|
|
47
|
+
const raw = JSON.parse(readFileSync(notesPath, "utf8"));
|
|
48
|
+
result.hasNotes = true;
|
|
49
|
+
result.totalSlides = raw.meta?.totalSlides ?? 0;
|
|
50
|
+
const notes = raw.notes ?? {};
|
|
51
|
+
result.filledCount = Object.values(notes).filter(
|
|
52
|
+
(n) => typeof n.note === "string" && n.note.trim().length > 0
|
|
53
|
+
).length;
|
|
54
|
+
} catch {
|
|
55
|
+
result.hasNotes = true;
|
|
56
|
+
}
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
34
59
|
async function findAvailablePort(startPort, maxAttempts = 10) {
|
|
35
60
|
for (let i = 0; i < maxAttempts; i++) {
|
|
36
61
|
const port = startPort + i;
|
|
@@ -229,19 +254,21 @@ function resolvePdfjsDir(callerUrl) {
|
|
|
229
254
|
|
|
230
255
|
// src/server/html-render.ts
|
|
231
256
|
import { readFile as readFile2 } from "fs/promises";
|
|
232
|
-
import { basename as basename2, join } from "path";
|
|
233
|
-
async function renderHtml(uiDir, file,
|
|
234
|
-
const raw = await readFile2(
|
|
235
|
-
|
|
257
|
+
import { basename as basename2, join as join2 } from "path";
|
|
258
|
+
async function renderHtml(uiDir, file, meta) {
|
|
259
|
+
const raw = await readFile2(join2(uiDir, file), "utf8");
|
|
260
|
+
return raw.replace(
|
|
261
|
+
"<!--PDF_PRESENTER_CONFIG-->",
|
|
262
|
+
`<script id="pdf-presenter-config" type="application/json">${JSON.stringify(meta)}</script>`
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
function singlePdfMeta(config) {
|
|
266
|
+
return {
|
|
236
267
|
pdfUrl: "/slides.pdf",
|
|
237
268
|
notesUrl: "/notes.json",
|
|
238
269
|
pdfName: basename2(config.pdfPath),
|
|
239
270
|
timerMinutes: config.timerMinutes ?? null
|
|
240
271
|
};
|
|
241
|
-
return raw.replace(
|
|
242
|
-
"<!--PDF_PRESENTER_CONFIG-->",
|
|
243
|
-
`<script id="pdf-presenter-config" type="application/json">${JSON.stringify(meta)}</script>`
|
|
244
|
-
);
|
|
245
272
|
}
|
|
246
273
|
|
|
247
274
|
// src/server/notes-store.ts
|
|
@@ -400,7 +427,7 @@ async function handleNotesRoutes(req, res, url, deps) {
|
|
|
400
427
|
|
|
401
428
|
// src/server/routes/recording.ts
|
|
402
429
|
import { writeFile as writeFile3, mkdir } from "fs/promises";
|
|
403
|
-
import { dirname as dirname2, join as
|
|
430
|
+
import { dirname as dirname2, join as join3 } from "path";
|
|
404
431
|
async function handleRecordingRoutes(req, res, url, deps) {
|
|
405
432
|
const pathname = url.pathname;
|
|
406
433
|
const method = req.method ?? "GET";
|
|
@@ -436,9 +463,9 @@ async function handleRecordingRoutes(req, res, url, deps) {
|
|
|
436
463
|
);
|
|
437
464
|
return "handled";
|
|
438
465
|
}
|
|
439
|
-
const outDir =
|
|
466
|
+
const outDir = join3(dirname2(deps.pdfPath), "recordings");
|
|
440
467
|
await mkdir(outDir, { recursive: true });
|
|
441
|
-
const outPath =
|
|
468
|
+
const outPath = join3(outDir, filenameParam);
|
|
442
469
|
const text = JSON.stringify(body, null, 2) + "\n";
|
|
443
470
|
await writeFile3(outPath, text, "utf8");
|
|
444
471
|
send(
|
|
@@ -485,9 +512,9 @@ async function handleRecordingRoutes(req, res, url, deps) {
|
|
|
485
512
|
);
|
|
486
513
|
return "handled";
|
|
487
514
|
}
|
|
488
|
-
const outDir =
|
|
515
|
+
const outDir = join3(dirname2(deps.pdfPath), "recordings");
|
|
489
516
|
await mkdir(outDir, { recursive: true });
|
|
490
|
-
const outPath =
|
|
517
|
+
const outPath = join3(outDir, filenameParam);
|
|
491
518
|
await writeFile3(outPath, bytes);
|
|
492
519
|
send(
|
|
493
520
|
res,
|
|
@@ -557,13 +584,135 @@ async function handleStaticRoutes(req, res, url, deps) {
|
|
|
557
584
|
}
|
|
558
585
|
return "pass";
|
|
559
586
|
}
|
|
587
|
+
async function handleSharedAssetRoutes(_req, res, url, deps) {
|
|
588
|
+
const pathname = url.pathname;
|
|
589
|
+
const method = _req.method ?? "GET";
|
|
590
|
+
if (method !== "GET" && method !== "HEAD") return "pass";
|
|
591
|
+
if (pathname.startsWith("/assets/pdfjs/")) {
|
|
592
|
+
const rel = pathname.slice("/assets/pdfjs/".length);
|
|
593
|
+
const safe = resolve3(deps.pdfjsDir, rel);
|
|
594
|
+
if (!safe.startsWith(deps.pdfjsDir + "/") && safe !== deps.pdfjsDir) {
|
|
595
|
+
send(res, 403, "Forbidden");
|
|
596
|
+
return "handled";
|
|
597
|
+
}
|
|
598
|
+
if (!existsSync5(safe)) {
|
|
599
|
+
notFound(res);
|
|
600
|
+
return "handled";
|
|
601
|
+
}
|
|
602
|
+
streamFile(res, safe, contentTypeFor(safe));
|
|
603
|
+
return "handled";
|
|
604
|
+
}
|
|
605
|
+
if (pathname.startsWith("/assets/")) {
|
|
606
|
+
const rel = pathname.slice("/assets/".length);
|
|
607
|
+
const safe = resolve3(deps.uiDir, rel);
|
|
608
|
+
if (!safe.startsWith(deps.uiDir + "/") && safe !== deps.uiDir) {
|
|
609
|
+
send(res, 403, "Forbidden");
|
|
610
|
+
return "handled";
|
|
611
|
+
}
|
|
612
|
+
if (!existsSync5(safe)) {
|
|
613
|
+
notFound(res);
|
|
614
|
+
return "handled";
|
|
615
|
+
}
|
|
616
|
+
streamFile(res, safe, contentTypeFor(safe));
|
|
617
|
+
return "handled";
|
|
618
|
+
}
|
|
619
|
+
return "pass";
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// src/server/routes/listing.ts
|
|
623
|
+
async function handleListingRoutes(req, res, url, deps) {
|
|
624
|
+
const pathname = url.pathname;
|
|
625
|
+
const method = req.method ?? "GET";
|
|
626
|
+
if (method !== "GET" && method !== "HEAD") return "pass";
|
|
627
|
+
if (pathname === "/" || pathname === "/list") {
|
|
628
|
+
send(res, 200, deps.listingHtml, MIME[".html"]);
|
|
629
|
+
return "handled";
|
|
630
|
+
}
|
|
631
|
+
if (pathname === "/api/listing") {
|
|
632
|
+
const pdfNames = listPdfsInDirectory(deps.dirPath);
|
|
633
|
+
const statuses = pdfNames.map(
|
|
634
|
+
(name) => getPdfNotesStatus(deps.dirPath, name)
|
|
635
|
+
);
|
|
636
|
+
send(res, 200, JSON.stringify(statuses), MIME[".json"]);
|
|
637
|
+
return "handled";
|
|
638
|
+
}
|
|
639
|
+
return "pass";
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// src/server/routes/pdf-proxy.ts
|
|
643
|
+
import { existsSync as existsSync6 } from "fs";
|
|
644
|
+
import { basename as basename3, join as join4, resolve as resolve4 } from "path";
|
|
645
|
+
var cache = /* @__PURE__ */ new Map();
|
|
646
|
+
async function getOrCreateCache(pdfName, deps) {
|
|
647
|
+
let entry = cache.get(pdfName);
|
|
648
|
+
if (entry) return entry;
|
|
649
|
+
const pdfPath = join4(deps.dirPath, pdfName);
|
|
650
|
+
const notesPath = notesPathFor(pdfPath);
|
|
651
|
+
const prefix = `/pdf/${encodeURIComponent(pdfName)}`;
|
|
652
|
+
const meta = {
|
|
653
|
+
pdfUrl: `${prefix}/slides.pdf`,
|
|
654
|
+
notesUrl: `${prefix}/notes.json`,
|
|
655
|
+
pdfName: basename3(pdfPath),
|
|
656
|
+
timerMinutes: deps.timerMinutes ?? null,
|
|
657
|
+
listUrl: "/list"
|
|
658
|
+
};
|
|
659
|
+
const [audienceHtml, presenterHtml] = await Promise.all([
|
|
660
|
+
renderHtml(deps.uiDir, "audience.html", meta),
|
|
661
|
+
renderHtml(deps.uiDir, "presenter.html", meta)
|
|
662
|
+
]);
|
|
663
|
+
entry = {
|
|
664
|
+
audienceHtml,
|
|
665
|
+
presenterHtml,
|
|
666
|
+
updateNotes: createNotesUpdater(notesPath)
|
|
667
|
+
};
|
|
668
|
+
cache.set(pdfName, entry);
|
|
669
|
+
return entry;
|
|
670
|
+
}
|
|
671
|
+
async function handlePdfProxyRoutes(req, res, url, deps) {
|
|
672
|
+
const match = url.pathname.match(/^\/pdf\/([^/]+)(\/.*)?$/);
|
|
673
|
+
if (!match) return "pass";
|
|
674
|
+
const pdfName = decodeURIComponent(match[1]);
|
|
675
|
+
const subPath = match[2] || "/";
|
|
676
|
+
if (!isSafeFilename(pdfName)) {
|
|
677
|
+
notFound(res);
|
|
678
|
+
return "handled";
|
|
679
|
+
}
|
|
680
|
+
const pdfPath = join4(deps.dirPath, pdfName);
|
|
681
|
+
const safePath = resolve4(deps.dirPath, pdfName);
|
|
682
|
+
if (!safePath.startsWith(deps.dirPath + "/") || !existsSync6(pdfPath)) {
|
|
683
|
+
notFound(res);
|
|
684
|
+
return "handled";
|
|
685
|
+
}
|
|
686
|
+
const notesPath = notesPathFor(pdfPath);
|
|
687
|
+
const perPdf = await getOrCreateCache(pdfName, deps);
|
|
688
|
+
const subUrl = new URL(subPath + url.search, `http://localhost`);
|
|
689
|
+
if (await handleRecordingRoutes(req, res, subUrl, { pdfPath }) === "handled")
|
|
690
|
+
return "handled";
|
|
691
|
+
if (await handleNotesRoutes(req, res, subUrl, {
|
|
692
|
+
notesPath,
|
|
693
|
+
updateNotes: perPdf.updateNotes
|
|
694
|
+
}) === "handled")
|
|
695
|
+
return "handled";
|
|
696
|
+
if (await handleStaticRoutes(req, res, subUrl, {
|
|
697
|
+
audienceHtml: perPdf.audienceHtml,
|
|
698
|
+
presenterHtml: perPdf.presenterHtml,
|
|
699
|
+
pdfPath,
|
|
700
|
+
notesPath,
|
|
701
|
+
uiDir: deps.uiDir,
|
|
702
|
+
pdfjsDir: deps.pdfjsDir
|
|
703
|
+
}) === "handled")
|
|
704
|
+
return "handled";
|
|
705
|
+
notFound(res);
|
|
706
|
+
return "handled";
|
|
707
|
+
}
|
|
560
708
|
|
|
561
709
|
// src/server.ts
|
|
562
710
|
async function startServer(config) {
|
|
563
711
|
const uiDir = resolveUiDir(import.meta.url);
|
|
564
712
|
const pdfjsDir = resolvePdfjsDir(import.meta.url);
|
|
565
|
-
const
|
|
566
|
-
const
|
|
713
|
+
const meta = singlePdfMeta(config);
|
|
714
|
+
const audienceHtml = await renderHtml(uiDir, "audience.html", meta);
|
|
715
|
+
const presenterHtml = await renderHtml(uiDir, "presenter.html", meta);
|
|
567
716
|
const updateNotes = createNotesUpdater(config.notesPath);
|
|
568
717
|
const handler = async (req, res) => {
|
|
569
718
|
const url = new URL(req.url ?? "/", `http://localhost:${config.port}`);
|
|
@@ -616,19 +765,141 @@ async function startServer(config) {
|
|
|
616
765
|
})
|
|
617
766
|
};
|
|
618
767
|
}
|
|
768
|
+
async function startDirectoryServer(config) {
|
|
769
|
+
const { basename: basename5 } = await import("path");
|
|
770
|
+
const uiDir = resolveUiDir(import.meta.url);
|
|
771
|
+
const pdfjsDir = resolvePdfjsDir(import.meta.url);
|
|
772
|
+
const listingHtml = await renderHtml(uiDir, "listing.html", {
|
|
773
|
+
dirName: basename5(config.dirPath)
|
|
774
|
+
});
|
|
775
|
+
const handler = async (req, res) => {
|
|
776
|
+
const url = new URL(req.url ?? "/", `http://localhost:${config.port}`);
|
|
777
|
+
try {
|
|
778
|
+
if (await handleListingRoutes(req, res, url, {
|
|
779
|
+
dirPath: config.dirPath,
|
|
780
|
+
listingHtml
|
|
781
|
+
}) === "handled")
|
|
782
|
+
return;
|
|
783
|
+
if (await handlePdfProxyRoutes(req, res, url, {
|
|
784
|
+
dirPath: config.dirPath,
|
|
785
|
+
uiDir,
|
|
786
|
+
pdfjsDir,
|
|
787
|
+
timerMinutes: config.timerMinutes
|
|
788
|
+
}) === "handled")
|
|
789
|
+
return;
|
|
790
|
+
if (await handleSharedAssetRoutes(req, res, url, {
|
|
791
|
+
uiDir,
|
|
792
|
+
pdfjsDir
|
|
793
|
+
}) === "handled")
|
|
794
|
+
return;
|
|
795
|
+
notFound(res);
|
|
796
|
+
} catch (err) {
|
|
797
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
798
|
+
if (!res.headersSent) send(res, 500, `Internal Server Error: ${msg}`);
|
|
799
|
+
else res.end();
|
|
800
|
+
}
|
|
801
|
+
};
|
|
802
|
+
const server = createServer2((req, res) => {
|
|
803
|
+
handler(req, res).catch((err) => {
|
|
804
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
805
|
+
if (!res.headersSent) {
|
|
806
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
807
|
+
}
|
|
808
|
+
res.end(`Internal Server Error: ${msg}`);
|
|
809
|
+
});
|
|
810
|
+
});
|
|
811
|
+
await new Promise((resolveP, rejectP) => {
|
|
812
|
+
server.once("error", rejectP);
|
|
813
|
+
server.listen(config.port, "127.0.0.1", () => {
|
|
814
|
+
server.off("error", rejectP);
|
|
815
|
+
resolveP();
|
|
816
|
+
});
|
|
817
|
+
});
|
|
818
|
+
return {
|
|
819
|
+
port: config.port,
|
|
820
|
+
stop: () => new Promise((resolveP, rejectP) => {
|
|
821
|
+
server.close((err) => err ? rejectP(err) : resolveP());
|
|
822
|
+
})
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// package.json
|
|
827
|
+
var package_default = {
|
|
828
|
+
name: "pdf-presenter",
|
|
829
|
+
version: "1.2.0",
|
|
830
|
+
description: "Lightweight CLI that serves PDF slides in the browser with a full presenter mode (speaker notes, next slide preview, timer, audio recording).",
|
|
831
|
+
type: "module",
|
|
832
|
+
bin: {
|
|
833
|
+
"pdf-presenter": "./dist/pdf-presenter.js"
|
|
834
|
+
},
|
|
835
|
+
files: [
|
|
836
|
+
"dist/",
|
|
837
|
+
"src/ui/",
|
|
838
|
+
"README.md",
|
|
839
|
+
"README-zhtw.md",
|
|
840
|
+
"LICENSE"
|
|
841
|
+
],
|
|
842
|
+
scripts: {
|
|
843
|
+
build: "tsup",
|
|
844
|
+
dev: "tsup --watch",
|
|
845
|
+
typecheck: "tsc --noEmit",
|
|
846
|
+
prepublishOnly: "pnpm run typecheck && pnpm run build",
|
|
847
|
+
test: "vitest run",
|
|
848
|
+
"test:watch": "vitest"
|
|
849
|
+
},
|
|
850
|
+
keywords: [
|
|
851
|
+
"pdf",
|
|
852
|
+
"presenter",
|
|
853
|
+
"presentation",
|
|
854
|
+
"slides",
|
|
855
|
+
"slideshow",
|
|
856
|
+
"speaker-notes",
|
|
857
|
+
"cli"
|
|
858
|
+
],
|
|
859
|
+
author: "Hsiehting Lin <hsieh.ting.lin@gmail.com>",
|
|
860
|
+
license: "MIT",
|
|
861
|
+
homepage: "https://github.com/htlin222/pdf-presenter#readme",
|
|
862
|
+
repository: {
|
|
863
|
+
type: "git",
|
|
864
|
+
url: "git+https://github.com/htlin222/pdf-presenter.git"
|
|
865
|
+
},
|
|
866
|
+
bugs: {
|
|
867
|
+
url: "https://github.com/htlin222/pdf-presenter/issues"
|
|
868
|
+
},
|
|
869
|
+
engines: {
|
|
870
|
+
node: ">=18"
|
|
871
|
+
},
|
|
872
|
+
dependencies: {
|
|
873
|
+
commander: "^12.1.0",
|
|
874
|
+
"get-port": "^7.1.0",
|
|
875
|
+
open: "^10.1.0",
|
|
876
|
+
"pdfjs-dist": "^4.7.76"
|
|
877
|
+
},
|
|
878
|
+
devDependencies: {
|
|
879
|
+
"@types/node": "^22.7.0",
|
|
880
|
+
tsup: "^8.3.0",
|
|
881
|
+
typescript: "^5.6.0",
|
|
882
|
+
vitest: "^2.1.0"
|
|
883
|
+
}
|
|
884
|
+
};
|
|
619
885
|
|
|
620
886
|
// src/cli.ts
|
|
621
|
-
var VERSION =
|
|
887
|
+
var VERSION = package_default.version;
|
|
622
888
|
async function runCli(argv) {
|
|
623
889
|
const program = new Command();
|
|
624
890
|
program.name("pdf-presenter").description(
|
|
625
891
|
"Serve a PDF as browser slides with a full presenter mode (notes, next preview, timer)."
|
|
626
|
-
).version(VERSION, "-v, --version").argument("<file>", "Path to
|
|
892
|
+
).version(VERSION, "-v, --version").argument("<file>", "Path to a PDF file or a directory of PDFs").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
893
|
"-gn, --generate-presenter-note-template",
|
|
628
894
|
"Generate a notes template JSON next to the PDF",
|
|
629
895
|
false
|
|
630
896
|
).option("--force", "Overwrite existing notes file when used with -gn", false).action(async (file, options) => {
|
|
631
897
|
try {
|
|
898
|
+
const abs = resolve5(file);
|
|
899
|
+
if (existsSync7(abs) && statSync3(abs).isDirectory()) {
|
|
900
|
+
await runDirectoryServe(abs, options);
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
632
903
|
const pdfPath = resolvePdfPath(file);
|
|
633
904
|
if (options.generatePresenterNoteTemplate) {
|
|
634
905
|
await runGenerate(pdfPath, { force: !!options.force });
|
|
@@ -649,13 +920,13 @@ Error: ${msg}
|
|
|
649
920
|
async function runGenerate(pdfPath, opts) {
|
|
650
921
|
try {
|
|
651
922
|
const result = await generateNotesTemplate(pdfPath, opts);
|
|
652
|
-
const rel = relative2(process.cwd(), result.notesPath) ||
|
|
923
|
+
const rel = relative2(process.cwd(), result.notesPath) || basename4(result.notesPath);
|
|
653
924
|
process.stdout.write(
|
|
654
925
|
`
|
|
655
926
|
\u2705 Generated ${rel} (${result.totalSlides} slides)
|
|
656
927
|
|
|
657
928
|
Edit the "note" fields in the JSON file, then run:
|
|
658
|
-
pdf-presenter ${relative2(process.cwd(), pdfPath) ||
|
|
929
|
+
pdf-presenter ${relative2(process.cwd(), pdfPath) || basename4(pdfPath)}
|
|
659
930
|
|
|
660
931
|
`
|
|
661
932
|
);
|
|
@@ -697,7 +968,7 @@ async function runServe(pdfPath, options) {
|
|
|
697
968
|
Audience: ${url}
|
|
698
969
|
Presenter: ${presenterUrl}
|
|
699
970
|
|
|
700
|
-
PDF: ${
|
|
971
|
+
PDF: ${basename4(pdfPath)}
|
|
701
972
|
Notes: ${notesInfo}
|
|
702
973
|
` + (timerMinutes !== void 0 ? ` Timer: ${formatMinutes(timerMinutes)}
|
|
703
974
|
` : "") + `
|
|
@@ -723,24 +994,69 @@ Received ${signal}, shutting down...
|
|
|
723
994
|
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
724
995
|
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
725
996
|
}
|
|
997
|
+
async function runDirectoryServe(dirPath, options) {
|
|
998
|
+
const startPort = Number.parseInt(options.port, 10);
|
|
999
|
+
if (!Number.isFinite(startPort) || startPort <= 0) {
|
|
1000
|
+
throw new Error(`Invalid --port value: ${options.port}`);
|
|
1001
|
+
}
|
|
1002
|
+
let timerMinutes;
|
|
1003
|
+
if (options.timer !== void 0) {
|
|
1004
|
+
const t = Number.parseFloat(options.timer);
|
|
1005
|
+
if (!Number.isFinite(t) || t <= 0) {
|
|
1006
|
+
throw new Error(`Invalid --timer value: ${options.timer}`);
|
|
1007
|
+
}
|
|
1008
|
+
timerMinutes = t;
|
|
1009
|
+
}
|
|
1010
|
+
const port = await findAvailablePort(startPort);
|
|
1011
|
+
const server = await startDirectoryServer({ dirPath, port, timerMinutes });
|
|
1012
|
+
const url = `http://localhost:${port}`;
|
|
1013
|
+
process.stdout.write(
|
|
1014
|
+
`
|
|
1015
|
+
\u{1F3AF} pdf-presenter v${VERSION} (directory mode)
|
|
1016
|
+
|
|
1017
|
+
Listing: ${url}/list
|
|
1018
|
+
|
|
1019
|
+
Directory: ${basename4(dirPath)}/
|
|
1020
|
+
|
|
1021
|
+
Press Ctrl+C to stop.
|
|
1022
|
+
|
|
1023
|
+
`
|
|
1024
|
+
);
|
|
1025
|
+
if (options.open) {
|
|
1026
|
+
open(`${url}/list`).catch(() => {
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
const shutdown = async (signal) => {
|
|
1030
|
+
process.stdout.write(`
|
|
1031
|
+
Received ${signal}, shutting down...
|
|
1032
|
+
`);
|
|
1033
|
+
try {
|
|
1034
|
+
await server.stop();
|
|
1035
|
+
} catch {
|
|
1036
|
+
}
|
|
1037
|
+
process.exit(0);
|
|
1038
|
+
};
|
|
1039
|
+
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
1040
|
+
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
1041
|
+
}
|
|
726
1042
|
function resolveMaybeExisting(path) {
|
|
727
1043
|
return path;
|
|
728
1044
|
}
|
|
729
1045
|
function describeNotes(notesPath) {
|
|
730
|
-
if (!
|
|
731
|
-
return `${
|
|
1046
|
+
if (!existsSync7(notesPath)) {
|
|
1047
|
+
return `${basename4(notesPath)} (not found \u2014 using empty notes)`;
|
|
732
1048
|
}
|
|
733
1049
|
try {
|
|
734
|
-
const raw =
|
|
1050
|
+
const raw = readFileSync2(notesPath, "utf8");
|
|
735
1051
|
const parsed = JSON.parse(raw);
|
|
736
1052
|
const total = parsed.meta?.totalSlides ?? 0;
|
|
737
1053
|
const filled = Object.values(parsed.notes ?? {}).filter(
|
|
738
1054
|
(e) => typeof e.note === "string" && e.note.trim() !== ""
|
|
739
1055
|
).length;
|
|
740
1056
|
const suffix = total > 0 ? ` (${filled}/${total} slides have notes)` : "";
|
|
741
|
-
return `${
|
|
1057
|
+
return `${basename4(notesPath)}${suffix}`;
|
|
742
1058
|
} catch {
|
|
743
|
-
return `${
|
|
1059
|
+
return `${basename4(notesPath)} (invalid JSON \u2014 using empty notes)`;
|
|
744
1060
|
}
|
|
745
1061
|
}
|
|
746
1062
|
function formatMinutes(minutes) {
|
package/package.json
CHANGED
package/src/ui/audience.html
CHANGED
|
@@ -3,13 +3,15 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
6
|
-
<title>
|
|
6
|
+
<title>Audience</title>
|
|
7
|
+
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg" />
|
|
7
8
|
<link rel="stylesheet" href="/assets/presenter.css" />
|
|
8
9
|
<!--PDF_PRESENTER_CONFIG-->
|
|
9
10
|
</head>
|
|
10
11
|
<body class="audience">
|
|
11
12
|
<div id="stage" class="stage">
|
|
12
13
|
<canvas id="slide-canvas"></canvas>
|
|
14
|
+
<div id="laser-dot" class="laser-dot hidden" aria-hidden="true"></div>
|
|
13
15
|
<div id="black-overlay" class="overlay hidden"></div>
|
|
14
16
|
<div id="freeze-indicator" class="freeze-indicator hidden">FROZEN</div>
|
|
15
17
|
<div id="status" class="status"></div>
|
package/src/ui/audience.js
CHANGED
|
@@ -13,6 +13,7 @@ export async function initAudience() {
|
|
|
13
13
|
const pdf = await loadDocument(config.pdfUrl);
|
|
14
14
|
const total = pdf.numPages;
|
|
15
15
|
const canvas = document.getElementById("slide-canvas");
|
|
16
|
+
const laserDot = document.getElementById("laser-dot");
|
|
16
17
|
const blackOverlay = document.getElementById("black-overlay");
|
|
17
18
|
const freezeIndicator = document.getElementById("freeze-indicator");
|
|
18
19
|
const status = document.getElementById("status");
|
|
@@ -44,6 +45,20 @@ export async function initAudience() {
|
|
|
44
45
|
blackOverlay.classList.toggle("hidden", !on);
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
function setCursor(msg) {
|
|
49
|
+
if (!laserDot) return;
|
|
50
|
+
if (msg.hidden) {
|
|
51
|
+
laserDot.classList.add("hidden");
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const rect = canvas.getBoundingClientRect();
|
|
55
|
+
const x = rect.left + msg.x * rect.width;
|
|
56
|
+
const y = rect.top + msg.y * rect.height;
|
|
57
|
+
laserDot.style.left = `${x}px`;
|
|
58
|
+
laserDot.style.top = `${y}px`;
|
|
59
|
+
laserDot.classList.remove("hidden");
|
|
60
|
+
}
|
|
61
|
+
|
|
47
62
|
channel.addEventListener("message", (ev) => {
|
|
48
63
|
const msg = ev.data;
|
|
49
64
|
if (!msg || typeof msg !== "object") return;
|
|
@@ -57,6 +72,9 @@ export async function initAudience() {
|
|
|
57
72
|
case "black":
|
|
58
73
|
setBlack(!!msg.value);
|
|
59
74
|
break;
|
|
75
|
+
case "cursor":
|
|
76
|
+
setCursor(msg);
|
|
77
|
+
break;
|
|
60
78
|
case "hello":
|
|
61
79
|
channel.postMessage({ type: "audience-ready" });
|
|
62
80
|
break;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#f5a524" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h20"/><path d="M21 3v11a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V3"/><path d="m7 21 5-6 5 6"/><path d="M12 15v6"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#7fd4a0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h20"/><path d="M21 3v11a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V3"/><path d="m7 21 5-6 5 6"/><path d="M12 15v6"/></svg>
|