@bendyline/squisq-cli 2.5.5 → 2.6.1

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.
@@ -5,11 +5,12 @@ import {
5
5
  } from "./chunk-GGVASRPG.js";
6
6
 
7
7
  // src/api.ts
8
- import { readFile as readFile3 } from "fs/promises";
8
+ import { mkdir, readFile as readFile3, writeFile } from "fs/promises";
9
+ import { dirname as dirname2, resolve as resolvePath } from "path";
9
10
  import { resolveMediaSchedule as resolveMediaSchedule2 } from "@bendyline/squisq/schemas";
10
11
  import { flattenBlocks as flattenBlocks2 } from "@bendyline/squisq/doc";
11
12
  import { ffmpegGifOutputArgs, generateRenderHtml } from "@bendyline/squisq-video";
12
- import { resolveDimensions } from "@bendyline/squisq-video";
13
+ import { resolveDashboardDimensions, resolveDimensions } from "@bendyline/squisq-video";
13
14
  import {
14
15
  convert as formatsConvert,
15
16
  prepareConversion as formatsPrepareConversion
@@ -94,13 +95,13 @@ async function buildMixedAudioTrack(doc, container, ffmpegPath, coverPreRoll, si
94
95
  return mixTimelineClips(ffmpegPath, usable, signal);
95
96
  }
96
97
  async function mixTimelineClips(ffmpegPath, usable, signal) {
97
- const { writeFile, readFile: readFile4, mkdir, rm: rm2 } = await import("fs/promises");
98
+ const { writeFile: writeFile2, readFile: readFile4, mkdir: mkdir2, rm: rm2 } = await import("fs/promises");
98
99
  const { join: join3 } = await import("path");
99
100
  const { tmpdir: tmpdir2 } = await import("os");
100
101
  const { randomBytes: randomBytes2 } = await import("crypto");
101
102
  signal?.throwIfAborted();
102
103
  const workDir = join3(tmpdir2(), `squisq-audio-mix-${randomBytes2(8).toString("hex")}`);
103
- await mkdir(workDir, { recursive: true });
104
+ await mkdir2(workDir, { recursive: true });
104
105
  const ms = (s) => Math.max(0, Math.round(s * 1e3));
105
106
  try {
106
107
  signal?.throwIfAborted();
@@ -110,7 +111,7 @@ async function mixTimelineClips(ffmpegPath, usable, signal) {
110
111
  for (const { clip, buffer } of usable) {
111
112
  signal?.throwIfAborted();
112
113
  const p = join3(workDir, `clip-${inputs.length}.mp3`);
113
- await writeFile(p, new Uint8Array(buffer));
114
+ await writeFile2(p, new Uint8Array(buffer));
114
115
  signal?.throwIfAborted();
115
116
  const i = inputs.push(p) - 1;
116
117
  const delayMs = ms(clip.startSec);
@@ -330,7 +331,7 @@ function mp4Format() {
330
331
  const animationsEnabled = typeof mp4Opts.animationsEnabled === "boolean" ? mp4Opts.animationsEnabled : MP4_DEFAULTS.animationsEnabled;
331
332
  const outputPath = join(tmpdir(), `squisq-mp4-${randomBytes(8).toString("hex")}.mp4`);
332
333
  try {
333
- const { renderDocToMp4: renderDocToMp42 } = await import("./api-E5PDCULT.js");
334
+ const { renderDocToMp4: renderDocToMp42 } = await import("./api-O3IMGDPH.js");
334
335
  await renderDocToMp42(input.doc, input.container, {
335
336
  outputPath,
336
337
  fps,
@@ -371,7 +372,7 @@ function gifFormat() {
371
372
  const defaultHeight = portrait ? GIF_DEFAULTS.width : GIF_DEFAULTS.height;
372
373
  const outputPath = join(tmpdir(), `squisq-gif-${randomBytes(8).toString("hex")}.gif`);
373
374
  try {
374
- const { renderDocToGif: renderDocToGif2 } = await import("./api-E5PDCULT.js");
375
+ const { renderDocToGif: renderDocToGif2 } = await import("./api-O3IMGDPH.js");
375
376
  const result = await renderDocToGif2(input.doc, input.container, {
376
377
  outputPath,
377
378
  fps: typeof gifOpts.fps === "number" ? gifOpts.fps : GIF_DEFAULTS.fps,
@@ -404,10 +405,38 @@ function gifFormat() {
404
405
  }
405
406
  };
406
407
  }
408
+ function pngFormat() {
409
+ return {
410
+ id: "png",
411
+ templateAnnotationHandling: "rendered",
412
+ label: "Dashboard Image",
413
+ mimeType: "image/png",
414
+ extensions: [".png"],
415
+ async exportDoc(input, options) {
416
+ options.signal?.throwIfAborted();
417
+ const pngOpts = options.formatOptions?.png ?? {};
418
+ const { renderDocToDashboardPng: renderDocToDashboardPng2 } = await import("./api-O3IMGDPH.js");
419
+ const result = await renderDocToDashboardPng2(input.doc, input.container, {
420
+ resolution: pngOpts.resolution,
421
+ width: pngOpts.width,
422
+ height: pngOpts.height,
423
+ layout: pngOpts.layout,
424
+ style: pngOpts.style,
425
+ title: pngOpts.title,
426
+ documentTitle: options.title ?? input.baseName,
427
+ signal: options.signal,
428
+ onProgress: pngOpts.onProgress
429
+ });
430
+ options.signal?.throwIfAborted();
431
+ return { bytes: result.bytes, mimeType: "image/png", suggestedFilename: "", warnings: [] };
432
+ }
433
+ };
434
+ }
407
435
  function createCliRegistry() {
408
436
  const registry = defaultRegistry();
409
437
  registry.register(mp4Format());
410
438
  registry.register(gifFormat());
439
+ registry.register(pngFormat());
411
440
  return registry;
412
441
  }
413
442
 
@@ -824,6 +853,12 @@ function throwIfAborted(signal) {
824
853
  }
825
854
 
826
855
  // src/api.ts
856
+ import {
857
+ DASHBOARD_RESOLUTIONS,
858
+ DEFAULT_DASHBOARD_RESOLUTION,
859
+ resolveDashboardDimensions as resolveDashboardDimensions2,
860
+ validateDashboardImageDimensions
861
+ } from "@bendyline/squisq-video";
827
862
  import { ConversionError } from "@bendyline/squisq-formats";
828
863
  var playerBundlePromise;
829
864
  var fullPlayerBundlePromise;
@@ -863,17 +898,18 @@ async function prepareConversion(source, options = {}) {
863
898
  ...options
864
899
  });
865
900
  }
866
- async function captureDocFrames(doc, container, options) {
867
- const { fps, width, height, captionStyle, coverPreRoll, animationsEnabled, onProgress, signal } = options;
868
- signal?.throwIfAborted();
869
- resolveAppliedCoverPreRoll(coverPreRoll, true);
870
- const ffmpegPath = (await detectFfmpegDetailed(signal))?.path ?? null;
871
- signal?.throwIfAborted();
872
- if (!ffmpegPath) {
873
- throw new Error(
874
- "ffmpeg is required but not found in PATH.\nInstall it with:\n macOS: brew install ffmpeg\n Ubuntu: sudo apt install ffmpeg\n Windows: winget install ffmpeg\nOr: npm install ffmpeg-static, or set SQUISQ_FFMPEG to an ffmpeg binary."
875
- );
876
- }
901
+ async function withRenderPage(doc, container, options, fn) {
902
+ const {
903
+ signal,
904
+ width,
905
+ height,
906
+ includeAudio,
907
+ captionStyle,
908
+ animationsEnabled,
909
+ displayMode,
910
+ dashboard,
911
+ onProgress
912
+ } = options;
877
913
  onProgress?.("collecting media", 0);
878
914
  signal?.throwIfAborted();
879
915
  const { collectImagePaths } = await import("@bendyline/squisq-formats/html");
@@ -890,14 +926,16 @@ async function captureDocFrames(doc, container, options) {
890
926
  }
891
927
  }
892
928
  const audio = /* @__PURE__ */ new Map();
893
- for (const seg of doc.audio?.segments ?? []) {
894
- signal?.throwIfAborted();
895
- const data = await container.readFile(seg.src);
896
- signal?.throwIfAborted();
897
- if (data) {
898
- budget.admit(seg.src, data);
899
- audio.set(seg.src, data);
900
- audio.set(seg.name, data);
929
+ if (includeAudio) {
930
+ for (const seg of doc.audio?.segments ?? []) {
931
+ signal?.throwIfAborted();
932
+ const data = await container.readFile(seg.src);
933
+ signal?.throwIfAborted();
934
+ if (data) {
935
+ budget.admit(seg.src, data);
936
+ audio.set(seg.src, data);
937
+ audio.set(seg.name, data);
938
+ }
901
939
  }
902
940
  }
903
941
  const mediaSrcs = new Set(resolveMediaSchedule2(doc).map((clip) => clip.src));
@@ -927,7 +965,9 @@ async function captureDocFrames(doc, container, options) {
927
965
  width,
928
966
  height,
929
967
  captionStyle,
930
- animationsEnabled
968
+ animationsEnabled,
969
+ displayMode,
970
+ dashboard
931
971
  });
932
972
  onProgress?.("launching browser", 15);
933
973
  signal?.throwIfAborted();
@@ -953,7 +993,6 @@ async function captureDocFrames(doc, container, options) {
953
993
  }
954
994
  signal?.addEventListener("abort", handleAbort, { once: true });
955
995
  let renderAPI = null;
956
- const capturedFrames = new CapturedFrameCollector();
957
996
  try {
958
997
  const page = await browser.newPage({ viewport: { width, height } });
959
998
  const pageErrors = [];
@@ -994,58 +1033,135 @@ Page errors:
994
1033
  return api;
995
1034
  });
996
1035
  signal?.throwIfAborted();
997
- const docDuration = await renderAPI.evaluate((api) => api.getDuration());
998
- signal?.throwIfAborted();
999
- if (docDuration <= 0) throw new Error("Document has zero duration \u2014 nothing to render");
1000
- const hasCover = coverPreRoll > 0 ? await renderAPI.evaluate((api) => api.hasCoverBlock()) : false;
1001
- const appliedCoverPreRoll = resolveAppliedCoverPreRoll(coverPreRoll, hasCover);
1002
- const storyFrameCount = Math.ceil(docDuration * fps);
1003
- const preRollFrameCount = Math.ceil(appliedCoverPreRoll * fps);
1004
- const totalFrames = preRollFrameCount + storyFrameCount;
1005
- onProgress?.("capturing frames", 20);
1006
- capturedFrames.throwIfAborted(signal);
1007
- if (preRollFrameCount > 0) {
1008
- capturedFrames.throwIfAborted(signal);
1009
- await renderAPI.evaluate((api) => api.showCover());
1010
- await page.waitForTimeout(100);
1011
- capturedFrames.throwIfAborted(signal);
1012
- const coverFrame = await page.screenshot({ type: "png" });
1013
- capturedFrames.throwIfAborted(signal);
1014
- capturedFrames.append(coverFrame, preRollFrameCount);
1015
- await renderAPI.evaluate((api) => api.hideCover());
1016
- }
1017
- const frameInterval = 1 / fps;
1018
- for (let i = 0; i < storyFrameCount; i++) {
1019
- capturedFrames.throwIfAborted(signal);
1020
- const time = i * frameInterval;
1021
- await renderAPI.evaluate((api, t) => api.seekTo(t), time);
1022
- const frame = await page.screenshot({ type: "png" });
1023
- capturedFrames.throwIfAborted(signal);
1024
- capturedFrames.append(frame);
1025
- if (i % Math.max(1, Math.floor(fps / 2)) === 0 || i === storyFrameCount - 1) {
1026
- onProgress?.(
1027
- "capturing frames",
1028
- 20 + Math.round(capturedFrames.frameCount / totalFrames * 60)
1029
- );
1036
+ return await fn({ page, renderAPI });
1037
+ } finally {
1038
+ signal?.removeEventListener("abort", handleAbort);
1039
+ await renderAPI?.dispose().catch(() => void 0);
1040
+ await browser.close().catch(() => void 0);
1041
+ }
1042
+ }
1043
+ async function captureDocFrames(doc, container, options) {
1044
+ const { fps, width, height, captionStyle, coverPreRoll, animationsEnabled, onProgress, signal } = options;
1045
+ signal?.throwIfAborted();
1046
+ resolveAppliedCoverPreRoll(coverPreRoll, true);
1047
+ const ffmpegPath = (await detectFfmpegDetailed(signal))?.path ?? null;
1048
+ signal?.throwIfAborted();
1049
+ if (!ffmpegPath) {
1050
+ throw new Error(
1051
+ "ffmpeg is required but not found in PATH.\nInstall it with:\n macOS: brew install ffmpeg\n Ubuntu: sudo apt install ffmpeg\n Windows: winget install ffmpeg\nOr: npm install ffmpeg-static, or set SQUISQ_FFMPEG to an ffmpeg binary."
1052
+ );
1053
+ }
1054
+ const capturedFrames = new CapturedFrameCollector();
1055
+ try {
1056
+ return await withRenderPage(
1057
+ doc,
1058
+ container,
1059
+ { signal, width, height, includeAudio: true, captionStyle, animationsEnabled, onProgress },
1060
+ async ({ page, renderAPI }) => {
1061
+ const docDuration = await renderAPI.evaluate((api) => api.getDuration());
1062
+ signal?.throwIfAborted();
1063
+ if (docDuration <= 0) throw new Error("Document has zero duration \u2014 nothing to render");
1064
+ const hasCover = coverPreRoll > 0 ? await renderAPI.evaluate((api) => api.hasCoverBlock()) : false;
1065
+ const appliedCoverPreRoll = resolveAppliedCoverPreRoll(coverPreRoll, hasCover);
1066
+ const storyFrameCount = Math.ceil(docDuration * fps);
1067
+ const preRollFrameCount = Math.ceil(appliedCoverPreRoll * fps);
1068
+ const totalFrames = preRollFrameCount + storyFrameCount;
1069
+ onProgress?.("capturing frames", 20);
1030
1070
  capturedFrames.throwIfAborted(signal);
1071
+ if (preRollFrameCount > 0) {
1072
+ capturedFrames.throwIfAborted(signal);
1073
+ await renderAPI.evaluate((api) => api.showCover());
1074
+ await page.waitForTimeout(100);
1075
+ capturedFrames.throwIfAborted(signal);
1076
+ const coverFrame = await page.screenshot({ type: "png" });
1077
+ capturedFrames.throwIfAborted(signal);
1078
+ capturedFrames.append(coverFrame, preRollFrameCount);
1079
+ await renderAPI.evaluate((api) => api.hideCover());
1080
+ }
1081
+ const frameInterval = 1 / fps;
1082
+ for (let i = 0; i < storyFrameCount; i++) {
1083
+ capturedFrames.throwIfAborted(signal);
1084
+ const time = i * frameInterval;
1085
+ await renderAPI.evaluate((api, t) => api.seekTo(t), time);
1086
+ const frame = await page.screenshot({ type: "png" });
1087
+ capturedFrames.throwIfAborted(signal);
1088
+ capturedFrames.append(frame);
1089
+ if (i % Math.max(1, Math.floor(fps / 2)) === 0 || i === storyFrameCount - 1) {
1090
+ onProgress?.(
1091
+ "capturing frames",
1092
+ 20 + Math.round(capturedFrames.frameCount / totalFrames * 60)
1093
+ );
1094
+ capturedFrames.throwIfAborted(signal);
1095
+ }
1096
+ }
1097
+ return {
1098
+ frames: capturedFrames.release(),
1099
+ totalDuration: docDuration + appliedCoverPreRoll,
1100
+ appliedCoverPreRoll,
1101
+ ffmpegPath
1102
+ };
1031
1103
  }
1032
- }
1033
- return {
1034
- frames: capturedFrames.release(),
1035
- totalDuration: docDuration + appliedCoverPreRoll,
1036
- appliedCoverPreRoll,
1037
- ffmpegPath
1038
- };
1104
+ );
1039
1105
  } catch (err) {
1040
1106
  capturedFrames.clear();
1041
1107
  signal?.throwIfAborted();
1042
1108
  throw err;
1043
- } finally {
1044
- signal?.removeEventListener("abort", handleAbort);
1045
- await renderAPI?.dispose().catch(() => void 0);
1046
- await browser.close().catch(() => void 0);
1047
1109
  }
1048
1110
  }
1111
+ async function renderDocToDashboardPng(doc, container, options = {}) {
1112
+ const {
1113
+ signal,
1114
+ outputPath,
1115
+ resolution,
1116
+ width,
1117
+ height,
1118
+ layout,
1119
+ title,
1120
+ style,
1121
+ documentTitle,
1122
+ onProgress
1123
+ } = options;
1124
+ signal?.throwIfAborted();
1125
+ const dimensions = resolveDashboardDimensions({ resolution, width, height });
1126
+ const bytes = await withRenderPage(
1127
+ doc,
1128
+ container,
1129
+ {
1130
+ signal,
1131
+ width: dimensions.width,
1132
+ height: dimensions.height,
1133
+ includeAudio: false,
1134
+ animationsEnabled: false,
1135
+ displayMode: "dashboard",
1136
+ dashboard: { layout, title, style, documentTitle },
1137
+ onProgress
1138
+ },
1139
+ async ({ page, renderAPI }) => {
1140
+ onProgress?.("rendering dashboard", 50);
1141
+ signal?.throwIfAborted();
1142
+ await renderAPI.evaluate((api) => api.seekTo(0));
1143
+ signal?.throwIfAborted();
1144
+ await page.waitForTimeout(100);
1145
+ signal?.throwIfAborted();
1146
+ onProgress?.("capturing image", 85);
1147
+ return await page.screenshot({ type: "png" });
1148
+ }
1149
+ );
1150
+ let writtenPath;
1151
+ if (outputPath) {
1152
+ const absolute = resolvePath(outputPath);
1153
+ await mkdir(dirname2(absolute), { recursive: true });
1154
+ await writeFile(absolute, bytes);
1155
+ writtenPath = absolute;
1156
+ }
1157
+ onProgress?.("done", 100);
1158
+ return {
1159
+ bytes,
1160
+ width: dimensions.width,
1161
+ height: dimensions.height,
1162
+ ...writtenPath ? { outputPath: writtenPath } : {}
1163
+ };
1164
+ }
1049
1165
  async function renderDocToMp4(doc, container, options) {
1050
1166
  options.signal?.throwIfAborted();
1051
1167
  const fps = options.fps ?? 30;
@@ -1235,10 +1351,15 @@ export {
1235
1351
  CapturedFrameBudgetError,
1236
1352
  convert,
1237
1353
  prepareConversion,
1354
+ renderDocToDashboardPng,
1238
1355
  renderDocToMp4,
1239
1356
  renderDocToGif,
1240
1357
  extractThumbnails,
1241
1358
  MemoryContentContainer2 as MemoryContentContainer,
1359
+ DASHBOARD_RESOLUTIONS,
1360
+ DEFAULT_DASHBOARD_RESOLUTION,
1361
+ resolveDashboardDimensions2 as resolveDashboardDimensions,
1362
+ validateDashboardImageDimensions,
1242
1363
  ConversionError,
1243
1364
  createCliRegistry
1244
1365
  };
package/dist/index.js CHANGED
@@ -6,9 +6,10 @@ import {
6
6
  detectFfmpegDetailed,
7
7
  getFfmpegVersion,
8
8
  readInput,
9
+ renderDocToDashboardPng,
9
10
  renderDocToGif,
10
11
  renderDocToMp4
11
- } from "./chunk-SFBZGL54.js";
12
+ } from "./chunk-2ZJW7F62.js";
12
13
  import "./chunk-GGVASRPG.js";
13
14
 
14
15
  // src/index.ts
@@ -137,7 +138,7 @@ function suggestId(value, candidates) {
137
138
  }
138
139
 
139
140
  // src/commands/convert.ts
140
- var VALID_FORMATS = [...BUILTIN_FORMAT_IDS, "mp4", "gif"];
141
+ var VALID_FORMATS = [...BUILTIN_FORMAT_IDS, "mp4", "gif", "png"];
141
142
  var DEFAULT_FORMATS = [
142
143
  "docx",
143
144
  "pptx",
@@ -187,7 +188,7 @@ function formatFromOutputPath(outputPath) {
187
188
  }
188
189
  function registerConvertCommand(program2) {
189
190
  program2.command("convert").description(
190
- "Convert a document to DOCX, PPTX, PDF, HTML, EPUB, MP4, animated GIF, and container formats"
191
+ "Convert a document to DOCX, PPTX, PDF, HTML, EPUB, MP4, animated GIF, dashboard PNG, and container formats"
191
192
  ).argument("<input>", "Path to .md/.docx/.pptx/.pdf/.xlsx/.csv/.html file, .zip/.dbk, or folder").addOption(
192
193
  new Option(
193
194
  "-o, --output <file>",
@@ -498,8 +499,157 @@ function clearProgress() {
498
499
  process.stderr.write("\r" + " ".repeat(80) + "\r");
499
500
  }
500
501
 
502
+ // src/commands/image.ts
503
+ import { mkdir as mkdir3 } from "fs/promises";
504
+ import { dirname as dirname3, basename as basename3, extname as extname3, resolve as resolve3 } from "path";
505
+ import {
506
+ DASHBOARD_AUTO_LAYOUT_ID,
507
+ DASHBOARD_STYLE_IDS,
508
+ listDashboardLayouts,
509
+ resolveDashboardStyleId
510
+ } from "@bendyline/squisq/doc";
511
+ import {
512
+ DASHBOARD_RESOLUTIONS,
513
+ DEFAULT_DASHBOARD_RESOLUTION,
514
+ resolveDashboardDimensions
515
+ } from "@bendyline/squisq-video";
516
+ var IMAGE_COMMAND_NAME = "image";
517
+ var VALID_IMAGE_FORMATS = ["png"];
518
+ function registerImageCommand(program2) {
519
+ const resolutionIds = DASHBOARD_RESOLUTIONS.map((preset) => preset.id).join(", ");
520
+ program2.command(IMAGE_COMMAND_NAME).description("Render a squisq document's Dashboard to a PNG image").argument("<input>", "Path to .md/.json file, .zip/.dbk container, or folder").argument("[output]", "Output .png path (default: <input>.png)").option("-o, --output <path>", "Output .png path").option("--format <format>", `Output format: ${VALID_IMAGE_FORMATS.join(", ")} (default: png)`).option(
521
+ "--resolution <preset>",
522
+ `Named resolution: ${resolutionIds} (default: ${DEFAULT_DASHBOARD_RESOLUTION})`
523
+ ).option("--width <pixels>", "Custom image width (requires --height; excludes --resolution)").option("--height <pixels>", "Custom image height (requires --width; excludes --resolution)").option(
524
+ "--layout <id>",
525
+ `Dashboard layout id, or "${DASHBOARD_AUTO_LAYOUT_ID}" to pick by block count (default)`
526
+ ).option(
527
+ "--style <variant>",
528
+ `Cell style variant: ${DASHBOARD_STYLE_IDS.join(", ")} (default: the document's own setting)`
529
+ ).option("--title", "Include the document-title band (default: the document's own setting)").option("--no-title", "Hide the document-title band").option(
530
+ "--no-auto-templates",
531
+ "Disable content-aware template auto-picking for unannotated headings"
532
+ ).option("-t, --theme <id>", "Squisq theme ID to apply (e.g., documentary, cinematic, bold)").option(
533
+ "--transform <style>",
534
+ "Transform style to apply before rendering (e.g., documentary, magazine, minimal)"
535
+ ).option("--overwrite", "Replace an existing output file (default: refuse and exit non-zero)").action(async (inputPath, outputArg, opts) => {
536
+ try {
537
+ if (outputArg && opts.output) {
538
+ throw new Error("The positional output and --output cannot be used together.");
539
+ }
540
+ if (outputArg) opts.output = outputArg;
541
+ await runImage(inputPath, opts);
542
+ } catch (err) {
543
+ const message = err instanceof Error ? err.message : String(err);
544
+ console.error(`Error: ${message}`);
545
+ process.exitCode = 1;
546
+ }
547
+ });
548
+ }
549
+ async function runImage(inputPath, opts) {
550
+ const resolvedInput = resolve3(inputPath);
551
+ const requestedFormat = opts.format?.toLowerCase();
552
+ if (requestedFormat !== void 0 && !VALID_IMAGE_FORMATS.includes(requestedFormat)) {
553
+ throw new Error(
554
+ `Invalid format "${requestedFormat}". Valid: ${VALID_IMAGE_FORMATS.join(", ")}`
555
+ );
556
+ }
557
+ if (opts.output && extname3(opts.output).toLowerCase() !== ".png") {
558
+ throw new Error("Output path must end in .png");
559
+ }
560
+ const width = opts.width === void 0 ? void 0 : Number(opts.width);
561
+ const height = opts.height === void 0 ? void 0 : Number(opts.height);
562
+ const dimensions = resolveDashboardDimensions({
563
+ resolution: opts.resolution,
564
+ width,
565
+ height
566
+ });
567
+ if (opts.layout !== void 0 && opts.layout.trim().length === 0) {
568
+ throw new Error("The --layout id must be a non-empty string.");
569
+ }
570
+ const requestedStyle = opts.style === void 0 ? void 0 : resolveDashboardStyleId(opts.style);
571
+ if (opts.style !== void 0 && requestedStyle === void 0) {
572
+ throw new Error(
573
+ `Unknown dashboard style "${opts.style}". Valid: ${DASHBOARD_STYLE_IDS.join(", ")}`
574
+ );
575
+ }
576
+ if (opts.transform) {
577
+ await assertValidTransformStyle(opts.transform);
578
+ }
579
+ const inputBasename = basename3(resolvedInput);
580
+ const inputExt = extname3(inputBasename);
581
+ const baseName = inputExt ? inputBasename.slice(0, -inputExt.length) : inputBasename;
582
+ const outputPath = opts.output ? resolve3(opts.output) : resolve3(dirname3(resolvedInput), `${baseName}.png`);
583
+ await assertOutputsWritable([outputPath], opts.overwrite);
584
+ await mkdir3(dirname3(outputPath), { recursive: true });
585
+ console.error(`Reading: ${resolvedInput}`);
586
+ const result = await readInput(resolvedInput);
587
+ const { container } = result;
588
+ if (opts.theme) {
589
+ await assertValidThemeId(opts.theme, result);
590
+ }
591
+ let doc = result.doc;
592
+ if (result.markdownDoc) {
593
+ if (opts.autoTemplates === false) {
594
+ const { markdownToDoc, resolveAudioMapping } = await import("@bendyline/squisq/doc");
595
+ doc = await resolveAudioMapping(
596
+ markdownToDoc(result.markdownDoc, { autoTemplates: false }),
597
+ container
598
+ );
599
+ }
600
+ } else {
601
+ console.error("Using pre-built Doc JSON");
602
+ }
603
+ const requestedLayout = opts.layout?.trim().toLowerCase();
604
+ if (requestedLayout && requestedLayout !== DASHBOARD_AUTO_LAYOUT_ID) {
605
+ const available = listDashboardLayouts(doc);
606
+ if (!available.some((layout) => layout.id === requestedLayout)) {
607
+ const known = available.map((layout) => layout.id).join(", ");
608
+ throw new Error(
609
+ `Unknown dashboard layout "${requestedLayout}". Valid: ${DASHBOARD_AUTO_LAYOUT_ID}, ${known}`
610
+ );
611
+ }
612
+ }
613
+ if (opts.transform) {
614
+ doc = await applyTransformToDoc(doc, opts.transform, opts.theme);
615
+ console.error(` Applied transform: ${opts.transform}`);
616
+ }
617
+ if (opts.theme) {
618
+ doc = { ...doc, themeId: opts.theme };
619
+ }
620
+ console.error(
621
+ `Rendering dashboard PNG: ${dimensions.width}\xD7${dimensions.height}, layout: ${requestedLayout ?? DASHBOARD_AUTO_LAYOUT_ID}, style: ${requestedStyle ?? "document"}, title: ${titleReadout(opts.title)}`
622
+ );
623
+ const rendered = await renderDocToDashboardPng(doc, container, {
624
+ outputPath,
625
+ ...opts.resolution !== void 0 ? { resolution: opts.resolution } : width !== void 0 && height !== void 0 ? { width, height } : {},
626
+ layout: requestedLayout,
627
+ style: requestedStyle,
628
+ title: opts.title,
629
+ documentTitle: baseName,
630
+ onProgress: (phase, percent) => writeProgress2(phase, percent, 100)
631
+ });
632
+ clearProgress2();
633
+ console.error(` \u2713 ${rendered.outputPath} (${rendered.width}\xD7${rendered.height})`);
634
+ console.error("Done.");
635
+ }
636
+ function titleReadout(title) {
637
+ if (title === void 0) return "document";
638
+ return title ? "on" : "off";
639
+ }
640
+ var BAR_WIDTH2 = 30;
641
+ function writeProgress2(label, current, total) {
642
+ const pct = Math.min(100, Math.round(current / total * 100));
643
+ const filled = Math.round(pct / 100 * BAR_WIDTH2);
644
+ const bar = "\u2588".repeat(filled) + "\u2591".repeat(BAR_WIDTH2 - filled);
645
+ process.stderr.write(`\r ${label}: ${bar} ${pct}% `);
646
+ }
647
+ function clearProgress2() {
648
+ process.stderr.write("\r" + " ".repeat(80) + "\r");
649
+ }
650
+
501
651
  // src/commands/validate.ts
502
- import { resolve as resolve3 } from "path";
652
+ import { resolve as resolve4 } from "path";
503
653
  import { validateMarkdownDoc } from "@bendyline/squisq/doc";
504
654
  function registerValidateCommand(program2) {
505
655
  program2.command("validate").description("Validate a squisq markdown document and report structural problems").argument("<input>", "Path to .md file, .zip/.dbk container, or folder").option("--json", "Output diagnostics as JSON (for tooling and agents)").option("--strict", "Exit non-zero on warnings as well as errors").action(async (inputPath, opts) => {
@@ -518,7 +668,7 @@ function registerValidateCommand(program2) {
518
668
  });
519
669
  }
520
670
  async function runValidate(inputPath, opts) {
521
- const resolvedInput = resolve3(inputPath);
671
+ const resolvedInput = resolve4(inputPath);
522
672
  const { container, markdownDoc, doc } = await readInput(resolvedInput);
523
673
  if (!markdownDoc) {
524
674
  const diagnostics = doc.diagnostics ?? [];
@@ -585,13 +735,16 @@ var defaultDoctorRuntime = {
585
735
  log: (line) => console.error(line)
586
736
  };
587
737
  function registerDoctorCommand(program2) {
588
- program2.command("doctor").description("Check that ffmpeg and Playwright Chromium are available for video rendering").action(async () => {
738
+ program2.command("doctor").description(
739
+ "Check that ffmpeg and Playwright Chromium are available for video/image rendering"
740
+ ).action(async () => {
589
741
  const allPresent = await runDoctor();
590
742
  process.exitCode = allPresent ? 0 : 1;
591
743
  });
592
744
  }
593
745
  async function runDoctor(runtime = defaultDoctorRuntime) {
594
- let allPresent = true;
746
+ let ffmpegOk = true;
747
+ let chromiumOk = true;
595
748
  runtime.log(`Node: ${runtime.nodeVersion}`);
596
749
  try {
597
750
  const detection = await runtime.detectFfmpeg();
@@ -601,7 +754,7 @@ async function runDoctor(runtime = defaultDoctorRuntime) {
601
754
  `ffmpeg: ${detection.path} (source: ${detection.source}) \u2014 ${version2 ?? "version unknown"}`
602
755
  );
603
756
  } else {
604
- allPresent = false;
757
+ ffmpegOk = false;
605
758
  runtime.log("ffmpeg: not found.");
606
759
  runtime.log(" Install it with:");
607
760
  runtime.log(" macOS: brew install ffmpeg");
@@ -610,7 +763,7 @@ async function runDoctor(runtime = defaultDoctorRuntime) {
610
763
  runtime.log(" Or: npm install ffmpeg-static, or set SQUISQ_FFMPEG.");
611
764
  }
612
765
  } catch (err) {
613
- allPresent = false;
766
+ ffmpegOk = false;
614
767
  runtime.log(`ffmpeg: ${err instanceof Error ? err.message : String(err)}`);
615
768
  }
616
769
  try {
@@ -618,18 +771,21 @@ async function runDoctor(runtime = defaultDoctorRuntime) {
618
771
  await browser.close();
619
772
  runtime.log(`Chromium: available (${browser.executablePath})`);
620
773
  } catch (err) {
621
- allPresent = false;
774
+ chromiumOk = false;
622
775
  const detail = err instanceof Error ? err.message.split("\n")[0] : String(err);
623
776
  runtime.log(`Chromium: not available \u2014 ${detail}`);
624
777
  runtime.log(" Run: npx playwright install chromium");
625
778
  }
779
+ runtime.log(`video/gif (ffmpeg + Chromium): ${ffmpegOk && chromiumOk ? "ready" : "not ready"}`);
780
+ runtime.log(`image PNG (Chromium only): ${chromiumOk ? "ready" : "not ready"}`);
781
+ const allPresent = ffmpegOk && chromiumOk;
626
782
  runtime.log(allPresent ? "\n\u2713 All checks passed" : "\n\u2717 Some checks failed");
627
783
  return allPresent;
628
784
  }
629
785
 
630
786
  // src/commands/transform.ts
631
- import { mkdir as mkdir3, readFile } from "fs/promises";
632
- import { dirname as dirname3, resolve as resolve4 } from "path";
787
+ import { mkdir as mkdir4, readFile } from "fs/promises";
788
+ import { dirname as dirname4, resolve as resolve5 } from "path";
633
789
  import { Option as Option2 } from "commander";
634
790
  import {
635
791
  DEFAULT_WRAP_WIDTH,
@@ -662,7 +818,7 @@ function registerTransformCommand(program2) {
662
818
  });
663
819
  }
664
820
  async function runTransform(inputPath, opts) {
665
- const resolvedInput = resolve4(inputPath);
821
+ const resolvedInput = resolve5(inputPath);
666
822
  let source;
667
823
  try {
668
824
  source = await readFile(resolvedInput, { encoding: "utf-8" });
@@ -704,9 +860,9 @@ async function runTransform(inputPath, opts) {
704
860
  await writeFileGuarded(resolvedInput, bytes, true);
705
861
  console.error(`\u2713 ${summary} \u2192 ${resolvedInput}`);
706
862
  } else if (opts.output) {
707
- const resolvedOutput = resolve4(opts.output);
863
+ const resolvedOutput = resolve5(opts.output);
708
864
  await assertOutputsWritable([resolvedOutput], opts.overwrite);
709
- await mkdir3(dirname3(resolvedOutput), { recursive: true });
865
+ await mkdir4(dirname4(resolvedOutput), { recursive: true });
710
866
  await writeFileGuarded(resolvedOutput, bytes, opts.overwrite);
711
867
  console.error(`\u2713 ${summary} \u2192 ${resolvedOutput}`);
712
868
  } else {
@@ -728,6 +884,7 @@ var program = new Command2();
728
884
  program.name("squisq").description("Squisq CLI \u2014 convert and process markdown-based documents").version(version);
729
885
  registerConvertCommand(program);
730
886
  registerVideoCommand(program);
887
+ registerImageCommand(program);
731
888
  registerValidateCommand(program);
732
889
  registerDoctorCommand(program);
733
890
  registerTransformCommand(program);