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 CHANGED
@@ -1,6 +1,28 @@
1
1
  # pdf-presenter
2
2
 
3
- > 輕量級 CLI 工具,用瀏覽器播放 PDF 投影片,並提供完整的主講者模式 —— 包含講者備註、下一張預覽、計時器。不需要轉換 Markdown,也不需要原生相依套件。指向 PDF,就能開始。
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
- > Lightweight CLI that serves a PDF as browser slides with a full presenter mode — speaker notes, next-slide preview, and a timer. No markdown conversion, no native dependencies. Point it at a PDF and go.
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
  ---
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { existsSync as existsSync6, readFileSync } from "fs";
5
- import { basename as basename3, relative as relative2 } from "path";
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, config) {
234
- const raw = await readFile2(join(uiDir, file), "utf8");
235
- const meta = {
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 join2 } from "path";
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 = join2(dirname2(deps.pdfPath), "recordings");
466
+ const outDir = join3(dirname2(deps.pdfPath), "recordings");
440
467
  await mkdir(outDir, { recursive: true });
441
- const outPath = join2(outDir, filenameParam);
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 = join2(dirname2(deps.pdfPath), "recordings");
515
+ const outDir = join3(dirname2(deps.pdfPath), "recordings");
489
516
  await mkdir(outDir, { recursive: true });
490
- const outPath = join2(outDir, filenameParam);
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 audienceHtml = await renderHtml(uiDir, "audience.html", config);
566
- const presenterHtml = await renderHtml(uiDir, "presenter.html", config);
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 = "1.0.0";
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 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(
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) || basename3(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) || basename3(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: ${basename3(pdfPath)}
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 (!existsSync6(notesPath)) {
731
- return `${basename3(notesPath)} (not found \u2014 using empty notes)`;
1046
+ if (!existsSync7(notesPath)) {
1047
+ return `${basename4(notesPath)} (not found \u2014 using empty notes)`;
732
1048
  }
733
1049
  try {
734
- const raw = readFileSync(notesPath, "utf8");
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 `${basename3(notesPath)}${suffix}`;
1057
+ return `${basename4(notesPath)}${suffix}`;
742
1058
  } catch {
743
- return `${basename3(notesPath)} (invalid JSON \u2014 using empty notes)`;
1059
+ return `${basename4(notesPath)} (invalid JSON \u2014 using empty notes)`;
744
1060
  }
745
1061
  }
746
1062
  function formatMinutes(minutes) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pdf-presenter",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Lightweight CLI that serves PDF slides in the browser with a full presenter mode (speaker notes, next slide preview, timer, audio recording).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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>pdf-presenter — Audience</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>
@@ -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>