pi-studio 0.5.8 → 0.5.9

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.
Files changed (3) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/index.ts +174 -10
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -4,6 +4,12 @@ All notable changes to `pi-studio` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.5.9] — 2026-03-13
8
+
9
+ ### Fixed
10
+ - Studio preview now uses Pandoc's `markdown` reader (matching `pi-markdown-preview`) instead of `gfm` for math-aware rendering, preventing currency amounts like `$135.00` from being misparsed as inline math in preview/PDF.
11
+ - Studio PDF export now preprocesses fenced Mermaid blocks via Mermaid CLI (`mmdc`) before Pandoc export, so Mermaid diagrams render as diagrams in exported PDFs instead of falling back to raw code fences.
12
+
7
13
  ## [0.5.8] — 2026-03-12
8
14
 
9
15
  ### Changed
package/index.ts CHANGED
@@ -6,7 +6,7 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
6
6
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
7
7
  import { homedir, tmpdir } from "node:os";
8
8
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
9
- import { URL } from "node:url";
9
+ import { URL, pathToFileURL } from "node:url";
10
10
  import { WebSocketServer, WebSocket, type RawData } from "ws";
11
11
 
12
12
  type Lens = "writing" | "code";
@@ -1067,9 +1067,156 @@ function normalizeObsidianImages(markdown: string): string {
1067
1067
  .replace(/!\[\[([^\]]+)\]\]/g, (_m, path) => `![](<${path}>)`);
1068
1068
  }
1069
1069
 
1070
+ class MermaidCliMissingError extends Error {}
1071
+
1072
+ interface StudioMermaidPdfPreprocessResult {
1073
+ markdown: string;
1074
+ found: number;
1075
+ replaced: number;
1076
+ failed: number;
1077
+ missingCli: boolean;
1078
+ warning?: string;
1079
+ }
1080
+
1081
+ function getStudioMermaidPdfTheme(): "default" | "forest" | "dark" | "neutral" {
1082
+ const requested = process.env.MERMAID_PDF_THEME?.trim().toLowerCase();
1083
+ if (requested === "default" || requested === "forest" || requested === "dark" || requested === "neutral") {
1084
+ return requested;
1085
+ }
1086
+ return "default";
1087
+ }
1088
+
1089
+ async function renderStudioMermaidDiagramForPdf(source: string, workDir: string, blockNumber: number): Promise<string> {
1090
+ const mermaidCommand = process.env.MERMAID_CLI_PATH?.trim() || "mmdc";
1091
+ const mermaidTheme = getStudioMermaidPdfTheme();
1092
+ const inputPath = join(workDir, `mermaid-diagram-${blockNumber}.mmd`);
1093
+ const outputPath = join(workDir, `mermaid-diagram-${blockNumber}.pdf`);
1094
+
1095
+ await writeFile(inputPath, source, "utf-8");
1096
+ await new Promise<void>((resolve, reject) => {
1097
+ const args = ["-i", inputPath, "-o", outputPath, "-t", mermaidTheme, "-f"];
1098
+ const child = spawn(mermaidCommand, args, { stdio: ["ignore", "ignore", "pipe"] });
1099
+ const stderrChunks: Buffer[] = [];
1100
+ let settled = false;
1101
+
1102
+ const fail = (error: Error) => {
1103
+ if (settled) return;
1104
+ settled = true;
1105
+ reject(error);
1106
+ };
1107
+
1108
+ child.stderr.on("data", (chunk: Buffer | string) => {
1109
+ stderrChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
1110
+ });
1111
+
1112
+ child.once("error", (error) => {
1113
+ const errno = error as NodeJS.ErrnoException;
1114
+ if (errno.code === "ENOENT") {
1115
+ fail(
1116
+ new MermaidCliMissingError(
1117
+ "Mermaid CLI (mmdc) not found. Install with `npm install -g @mermaid-js/mermaid-cli` or set MERMAID_CLI_PATH.",
1118
+ ),
1119
+ );
1120
+ return;
1121
+ }
1122
+ fail(error);
1123
+ });
1124
+
1125
+ child.once("close", (code) => {
1126
+ if (settled) return;
1127
+ settled = true;
1128
+ if (code === 0) {
1129
+ resolve();
1130
+ return;
1131
+ }
1132
+ const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
1133
+ reject(new Error(`Mermaid CLI failed with exit code ${code}${stderr ? `: ${stderr}` : ""}`));
1134
+ });
1135
+ });
1136
+
1137
+ return outputPath;
1138
+ }
1139
+
1140
+ async function preprocessStudioMermaidForPdf(markdown: string, workDir: string): Promise<StudioMermaidPdfPreprocessResult> {
1141
+ const mermaidRegex = /```mermaid[^\n]*\n([\s\S]*?)```/gi;
1142
+ const matches: Array<{ start: number; end: number; raw: string; source: string; number: number }> = [];
1143
+ let match: RegExpExecArray | null;
1144
+ let blockNumber = 1;
1145
+
1146
+ while ((match = mermaidRegex.exec(markdown)) !== null) {
1147
+ const raw = match[0]!;
1148
+ const source = (match[1] ?? "").trimEnd();
1149
+ matches.push({
1150
+ start: match.index,
1151
+ end: match.index + raw.length,
1152
+ raw,
1153
+ source,
1154
+ number: blockNumber++,
1155
+ });
1156
+ }
1157
+
1158
+ if (matches.length === 0) {
1159
+ return {
1160
+ markdown,
1161
+ found: 0,
1162
+ replaced: 0,
1163
+ failed: 0,
1164
+ missingCli: false,
1165
+ };
1166
+ }
1167
+
1168
+ let transformed = "";
1169
+ let cursor = 0;
1170
+ let replaced = 0;
1171
+ let failed = 0;
1172
+ let missingCli = false;
1173
+
1174
+ for (const block of matches) {
1175
+ transformed += markdown.slice(cursor, block.start);
1176
+ if (missingCli) {
1177
+ failed++;
1178
+ transformed += block.raw;
1179
+ cursor = block.end;
1180
+ continue;
1181
+ }
1182
+
1183
+ try {
1184
+ const renderedPath = await renderStudioMermaidDiagramForPdf(block.source, workDir, block.number);
1185
+ const imageRef = pathToFileURL(renderedPath).href;
1186
+ transformed += `\n![Mermaid diagram ${block.number}](<${imageRef}>)\n`;
1187
+ replaced++;
1188
+ } catch (error) {
1189
+ if (error instanceof MermaidCliMissingError) {
1190
+ missingCli = true;
1191
+ }
1192
+ failed++;
1193
+ transformed += block.raw;
1194
+ }
1195
+ cursor = block.end;
1196
+ }
1197
+
1198
+ transformed += markdown.slice(cursor);
1199
+
1200
+ let warning: string | undefined;
1201
+ if (missingCli) {
1202
+ warning = "Mermaid CLI (mmdc) not found; Mermaid blocks are kept as code in PDF. Install @mermaid-js/mermaid-cli or set MERMAID_CLI_PATH.";
1203
+ } else if (failed > 0) {
1204
+ warning = `Failed to render ${failed} Mermaid block${failed === 1 ? "" : "s"} for PDF. Unrendered blocks are kept as code.`;
1205
+ }
1206
+
1207
+ return {
1208
+ markdown: transformed,
1209
+ found: matches.length,
1210
+ replaced,
1211
+ failed,
1212
+ missingCli,
1213
+ warning,
1214
+ };
1215
+ }
1216
+
1070
1217
  async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolean, resourcePath?: string): Promise<string> {
1071
1218
  const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
1072
- const inputFormat = isLatex ? "latex" : "gfm+tex_math_dollars-raw_html";
1219
+ const inputFormat = isLatex ? "latex" : "markdown+tex_math_dollars+autolink_bare_uris-raw_html";
1073
1220
  const args = ["-f", inputFormat, "-t", "html5", "--mathml", "--wrap=none"];
1074
1221
  if (resourcePath) {
1075
1222
  args.push(`--resource-path=${resourcePath}`);
@@ -1132,12 +1279,16 @@ async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolea
1132
1279
  });
1133
1280
  }
1134
1281
 
1135
- async function renderStudioPdfWithPandoc(markdown: string, isLatex?: boolean, resourcePath?: string): Promise<Buffer> {
1282
+ async function renderStudioPdfWithPandoc(
1283
+ markdown: string,
1284
+ isLatex?: boolean,
1285
+ resourcePath?: string,
1286
+ ): Promise<{ pdf: Buffer; warning?: string }> {
1136
1287
  const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
1137
1288
  const pdfEngine = process.env.PANDOC_PDF_ENGINE?.trim() || "xelatex";
1138
1289
  const inputFormat = isLatex
1139
1290
  ? "latex"
1140
- : "gfm+tex_math_dollars+autolink_bare_uris+superscript+subscript-raw_html";
1291
+ : "markdown+tex_math_dollars+autolink_bare_uris+superscript+subscript-raw_html";
1141
1292
  const normalizedMarkdown = isLatex ? markdown : normalizeObsidianImages(normalizeMathDelimiters(markdown));
1142
1293
 
1143
1294
  const tempDir = join(tmpdir(), `pi-studio-pdf-${Date.now()}-${randomUUID()}`);
@@ -1147,6 +1298,11 @@ async function renderStudioPdfWithPandoc(markdown: string, isLatex?: boolean, re
1147
1298
  await mkdir(tempDir, { recursive: true });
1148
1299
  await writeFile(preamblePath, PDF_PREAMBLE, "utf-8");
1149
1300
 
1301
+ const mermaidPrepared: StudioMermaidPdfPreprocessResult = isLatex
1302
+ ? { markdown: normalizedMarkdown, found: 0, replaced: 0, failed: 0, missingCli: false }
1303
+ : await preprocessStudioMermaidForPdf(normalizedMarkdown, tempDir);
1304
+ const markdownForPdf = mermaidPrepared.markdown;
1305
+
1150
1306
  const args = [
1151
1307
  "-f", inputFormat,
1152
1308
  "-o", outputPath,
@@ -1202,10 +1358,10 @@ async function renderStudioPdfWithPandoc(markdown: string, isLatex?: boolean, re
1202
1358
  fail(new Error(`pandoc PDF export failed with exit code ${code}${stderr ? `: ${stderr}` : ""}${hint}`));
1203
1359
  });
1204
1360
 
1205
- child.stdin.end(normalizedMarkdown);
1361
+ child.stdin.end(markdownForPdf);
1206
1362
  });
1207
1363
 
1208
- return await readFile(outputPath);
1364
+ return { pdf: await readFile(outputPath), warning: mermaidPrepared.warning };
1209
1365
  } finally {
1210
1366
  await rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
1211
1367
  }
@@ -4572,6 +4728,7 @@ ${cssVarsBlock}
4572
4728
  throw new Error(message);
4573
4729
  }
4574
4730
 
4731
+ const exportWarning = String(response.headers.get("x-pi-studio-export-warning") || "").trim();
4575
4732
  const blob = await response.blob();
4576
4733
  const headerFilename = parseContentDispositionFilename(response.headers.get("content-disposition"));
4577
4734
  let downloadName = headerFilename || filenameHint || "studio-preview.pdf";
@@ -4591,7 +4748,11 @@ ${cssVarsBlock}
4591
4748
  URL.revokeObjectURL(blobUrl);
4592
4749
  }, 1800);
4593
4750
 
4594
- setStatus("Exported PDF: " + downloadName, "success");
4751
+ if (exportWarning) {
4752
+ setStatus("Exported PDF with warning: " + exportWarning, "warning");
4753
+ } else {
4754
+ setStatus("Exported PDF: " + downloadName, "success");
4755
+ }
4595
4756
  } catch (error) {
4596
4757
  const detail = error && error.message ? error.message : String(error || "unknown error");
4597
4758
  setStatus("PDF export failed: " + detail, "error");
@@ -7876,20 +8037,23 @@ export default function (pi: ExtensionAPI) {
7876
8037
  const filename = sanitizePdfFilename(requestedFilename || (isLatex ? "studio-latex-preview.pdf" : "studio-preview.pdf"));
7877
8038
 
7878
8039
  try {
7879
- const pdf = await renderStudioPdfWithPandoc(markdown, isLatex, resourcePath);
8040
+ const { pdf, warning } = await renderStudioPdfWithPandoc(markdown, isLatex, resourcePath);
7880
8041
  const safeAsciiName = filename
7881
8042
  .replace(/[\x00-\x1f\x7f]/g, "")
7882
8043
  .replace(/[;"\\]/g, "_")
7883
8044
  .replace(/\s+/g, " ")
7884
8045
  .trim() || "studio-preview.pdf";
7885
8046
 
7886
- res.writeHead(200, {
8047
+ const headers: Record<string, string> = {
7887
8048
  "Content-Type": "application/pdf",
7888
8049
  "Cache-Control": "no-store",
7889
8050
  "X-Content-Type-Options": "nosniff",
7890
8051
  "Content-Disposition": `attachment; filename="${safeAsciiName}"; filename*=UTF-8''${encodeURIComponent(filename)}`,
7891
8052
  "Content-Length": String(pdf.length),
7892
- });
8053
+ };
8054
+ if (warning) headers["X-Pi-Studio-Export-Warning"] = warning;
8055
+
8056
+ res.writeHead(200, headers);
7893
8057
  res.end(pdf);
7894
8058
  } catch (error) {
7895
8059
  const message = error instanceof Error ? error.message : String(error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.5.8",
3
+ "version": "0.5.9",
4
4
  "description": "Browser GUI for structured critique workflows in pi",
5
5
  "type": "module",
6
6
  "license": "MIT",