pi-studio 0.5.8 → 0.5.10
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/CHANGELOG.md +11 -0
- package/index.ts +222 -13
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,17 @@ All notable changes to `pi-studio` are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.5.10] — 2026-03-14
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- Studio preview/PDF math normalization is now more robust for model-emitted `\(...\)` / `\[...\]` math, including malformed mixed delimiters like `$\(...\)$`, optional spacing around those mixed delimiters, and multiline display-math line-break formatting that previously leaked raw/broken `$$` output into preview.
|
|
11
|
+
|
|
12
|
+
## [0.5.9] — 2026-03-13
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- 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.
|
|
16
|
+
- 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.
|
|
17
|
+
|
|
7
18
|
## [0.5.8] — 2026-03-12
|
|
8
19
|
|
|
9
20
|
### 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";
|
|
@@ -991,13 +991,58 @@ async function fetchLatestNpmVersion(packageName: string, timeoutMs = UPDATE_CHE
|
|
|
991
991
|
}
|
|
992
992
|
}
|
|
993
993
|
|
|
994
|
+
function isLikelyMathExpression(expr: string): boolean {
|
|
995
|
+
const content = expr.trim();
|
|
996
|
+
if (content.length === 0) return false;
|
|
997
|
+
|
|
998
|
+
if (/\\[a-zA-Z]+/.test(content)) return true; // LaTeX commands like \frac, \alpha
|
|
999
|
+
if (/[0-9]/.test(content)) return true;
|
|
1000
|
+
if (/[=+\-*/^_<>≤≥±×÷]/u.test(content)) return true;
|
|
1001
|
+
if (/[{}]/.test(content)) return true;
|
|
1002
|
+
if (/[α-ωΑ-Ω]/u.test(content)) return true;
|
|
1003
|
+
if (/^[A-Za-z]$/.test(content)) return true; // single-variable forms like \(x\)
|
|
1004
|
+
|
|
1005
|
+
// Plain words (e.g. escaped markdown like \[not a link\]) are not math.
|
|
1006
|
+
if (/^[A-Za-z][A-Za-z\s'".,:;!?-]*[A-Za-z]$/.test(content)) return false;
|
|
1007
|
+
|
|
1008
|
+
return false;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function collapseDisplayMathContent(expr: string): string {
|
|
1012
|
+
let content = expr.trim();
|
|
1013
|
+
if (content.includes("\\\\") || content.includes("\n")) {
|
|
1014
|
+
content = content.replace(/\\\\\s*/g, " ");
|
|
1015
|
+
content = content.replace(/\s*\n\s*/g, " ");
|
|
1016
|
+
content = content.replace(/\s{2,}/g, " ").trim();
|
|
1017
|
+
}
|
|
1018
|
+
return content;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
994
1021
|
function normalizeMathDelimitersInSegment(markdown: string): string {
|
|
995
|
-
let normalized = markdown.replace(
|
|
1022
|
+
let normalized = markdown.replace(/\$\s*\\\(([\s\S]*?)\\\)\s*\$/g, (match, expr: string) => {
|
|
1023
|
+
if (!isLikelyMathExpression(expr)) return match;
|
|
1024
|
+
const content = expr.trim();
|
|
1025
|
+
return content.length > 0 ? `\\(${content}\\)` : "\\(\\)";
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
normalized = normalized.replace(/\$\s*\\\[\s*([\s\S]*?)\s*\\\]\s*\$/g, (match, expr: string) => {
|
|
1029
|
+
if (!isLikelyMathExpression(expr)) return match;
|
|
1030
|
+
const content = collapseDisplayMathContent(expr);
|
|
1031
|
+
return content.length > 0 ? `\\[${content}\\]` : "\\[\\]";
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
normalized = normalized.replace(/\\\[\s*([\s\S]*?)\s*\\\]/g, (match, expr: string) => {
|
|
1035
|
+
if (!isLikelyMathExpression(expr)) return `[${expr.trim()}]`;
|
|
1036
|
+
const content = collapseDisplayMathContent(expr);
|
|
1037
|
+
return content.length > 0 ? `\\[${content}\\]` : "\\[\\]";
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
normalized = normalized.replace(/\\\(([\s\S]*?)\\\)/g, (match, expr: string) => {
|
|
1041
|
+
if (!isLikelyMathExpression(expr)) return `(${expr})`;
|
|
996
1042
|
const content = expr.trim();
|
|
997
|
-
return content.length > 0 ?
|
|
1043
|
+
return content.length > 0 ? `\\(${content}\\)` : "\\(\\)";
|
|
998
1044
|
});
|
|
999
1045
|
|
|
1000
|
-
normalized = normalized.replace(/\\\(([\s\S]*?)\\\)/g, (_match, expr: string) => `$${expr}$`);
|
|
1001
1046
|
return normalized;
|
|
1002
1047
|
}
|
|
1003
1048
|
|
|
@@ -1067,9 +1112,156 @@ function normalizeObsidianImages(markdown: string): string {
|
|
|
1067
1112
|
.replace(/!\[\[([^\]]+)\]\]/g, (_m, path) => ``);
|
|
1068
1113
|
}
|
|
1069
1114
|
|
|
1115
|
+
class MermaidCliMissingError extends Error {}
|
|
1116
|
+
|
|
1117
|
+
interface StudioMermaidPdfPreprocessResult {
|
|
1118
|
+
markdown: string;
|
|
1119
|
+
found: number;
|
|
1120
|
+
replaced: number;
|
|
1121
|
+
failed: number;
|
|
1122
|
+
missingCli: boolean;
|
|
1123
|
+
warning?: string;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
function getStudioMermaidPdfTheme(): "default" | "forest" | "dark" | "neutral" {
|
|
1127
|
+
const requested = process.env.MERMAID_PDF_THEME?.trim().toLowerCase();
|
|
1128
|
+
if (requested === "default" || requested === "forest" || requested === "dark" || requested === "neutral") {
|
|
1129
|
+
return requested;
|
|
1130
|
+
}
|
|
1131
|
+
return "default";
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
async function renderStudioMermaidDiagramForPdf(source: string, workDir: string, blockNumber: number): Promise<string> {
|
|
1135
|
+
const mermaidCommand = process.env.MERMAID_CLI_PATH?.trim() || "mmdc";
|
|
1136
|
+
const mermaidTheme = getStudioMermaidPdfTheme();
|
|
1137
|
+
const inputPath = join(workDir, `mermaid-diagram-${blockNumber}.mmd`);
|
|
1138
|
+
const outputPath = join(workDir, `mermaid-diagram-${blockNumber}.pdf`);
|
|
1139
|
+
|
|
1140
|
+
await writeFile(inputPath, source, "utf-8");
|
|
1141
|
+
await new Promise<void>((resolve, reject) => {
|
|
1142
|
+
const args = ["-i", inputPath, "-o", outputPath, "-t", mermaidTheme, "-f"];
|
|
1143
|
+
const child = spawn(mermaidCommand, args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
1144
|
+
const stderrChunks: Buffer[] = [];
|
|
1145
|
+
let settled = false;
|
|
1146
|
+
|
|
1147
|
+
const fail = (error: Error) => {
|
|
1148
|
+
if (settled) return;
|
|
1149
|
+
settled = true;
|
|
1150
|
+
reject(error);
|
|
1151
|
+
};
|
|
1152
|
+
|
|
1153
|
+
child.stderr.on("data", (chunk: Buffer | string) => {
|
|
1154
|
+
stderrChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
child.once("error", (error) => {
|
|
1158
|
+
const errno = error as NodeJS.ErrnoException;
|
|
1159
|
+
if (errno.code === "ENOENT") {
|
|
1160
|
+
fail(
|
|
1161
|
+
new MermaidCliMissingError(
|
|
1162
|
+
"Mermaid CLI (mmdc) not found. Install with `npm install -g @mermaid-js/mermaid-cli` or set MERMAID_CLI_PATH.",
|
|
1163
|
+
),
|
|
1164
|
+
);
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
fail(error);
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1170
|
+
child.once("close", (code) => {
|
|
1171
|
+
if (settled) return;
|
|
1172
|
+
settled = true;
|
|
1173
|
+
if (code === 0) {
|
|
1174
|
+
resolve();
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
|
|
1178
|
+
reject(new Error(`Mermaid CLI failed with exit code ${code}${stderr ? `: ${stderr}` : ""}`));
|
|
1179
|
+
});
|
|
1180
|
+
});
|
|
1181
|
+
|
|
1182
|
+
return outputPath;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
async function preprocessStudioMermaidForPdf(markdown: string, workDir: string): Promise<StudioMermaidPdfPreprocessResult> {
|
|
1186
|
+
const mermaidRegex = /```mermaid[^\n]*\n([\s\S]*?)```/gi;
|
|
1187
|
+
const matches: Array<{ start: number; end: number; raw: string; source: string; number: number }> = [];
|
|
1188
|
+
let match: RegExpExecArray | null;
|
|
1189
|
+
let blockNumber = 1;
|
|
1190
|
+
|
|
1191
|
+
while ((match = mermaidRegex.exec(markdown)) !== null) {
|
|
1192
|
+
const raw = match[0]!;
|
|
1193
|
+
const source = (match[1] ?? "").trimEnd();
|
|
1194
|
+
matches.push({
|
|
1195
|
+
start: match.index,
|
|
1196
|
+
end: match.index + raw.length,
|
|
1197
|
+
raw,
|
|
1198
|
+
source,
|
|
1199
|
+
number: blockNumber++,
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
if (matches.length === 0) {
|
|
1204
|
+
return {
|
|
1205
|
+
markdown,
|
|
1206
|
+
found: 0,
|
|
1207
|
+
replaced: 0,
|
|
1208
|
+
failed: 0,
|
|
1209
|
+
missingCli: false,
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
let transformed = "";
|
|
1214
|
+
let cursor = 0;
|
|
1215
|
+
let replaced = 0;
|
|
1216
|
+
let failed = 0;
|
|
1217
|
+
let missingCli = false;
|
|
1218
|
+
|
|
1219
|
+
for (const block of matches) {
|
|
1220
|
+
transformed += markdown.slice(cursor, block.start);
|
|
1221
|
+
if (missingCli) {
|
|
1222
|
+
failed++;
|
|
1223
|
+
transformed += block.raw;
|
|
1224
|
+
cursor = block.end;
|
|
1225
|
+
continue;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
try {
|
|
1229
|
+
const renderedPath = await renderStudioMermaidDiagramForPdf(block.source, workDir, block.number);
|
|
1230
|
+
const imageRef = pathToFileURL(renderedPath).href;
|
|
1231
|
+
transformed += `\n\n`;
|
|
1232
|
+
replaced++;
|
|
1233
|
+
} catch (error) {
|
|
1234
|
+
if (error instanceof MermaidCliMissingError) {
|
|
1235
|
+
missingCli = true;
|
|
1236
|
+
}
|
|
1237
|
+
failed++;
|
|
1238
|
+
transformed += block.raw;
|
|
1239
|
+
}
|
|
1240
|
+
cursor = block.end;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
transformed += markdown.slice(cursor);
|
|
1244
|
+
|
|
1245
|
+
let warning: string | undefined;
|
|
1246
|
+
if (missingCli) {
|
|
1247
|
+
warning = "Mermaid CLI (mmdc) not found; Mermaid blocks are kept as code in PDF. Install @mermaid-js/mermaid-cli or set MERMAID_CLI_PATH.";
|
|
1248
|
+
} else if (failed > 0) {
|
|
1249
|
+
warning = `Failed to render ${failed} Mermaid block${failed === 1 ? "" : "s"} for PDF. Unrendered blocks are kept as code.`;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
return {
|
|
1253
|
+
markdown: transformed,
|
|
1254
|
+
found: matches.length,
|
|
1255
|
+
replaced,
|
|
1256
|
+
failed,
|
|
1257
|
+
missingCli,
|
|
1258
|
+
warning,
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1070
1262
|
async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolean, resourcePath?: string): Promise<string> {
|
|
1071
1263
|
const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
|
|
1072
|
-
const inputFormat = isLatex ? "latex" : "
|
|
1264
|
+
const inputFormat = isLatex ? "latex" : "markdown+tex_math_dollars+tex_math_single_backslash+tex_math_double_backslash+autolink_bare_uris-raw_html";
|
|
1073
1265
|
const args = ["-f", inputFormat, "-t", "html5", "--mathml", "--wrap=none"];
|
|
1074
1266
|
if (resourcePath) {
|
|
1075
1267
|
args.push(`--resource-path=${resourcePath}`);
|
|
@@ -1132,12 +1324,16 @@ async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolea
|
|
|
1132
1324
|
});
|
|
1133
1325
|
}
|
|
1134
1326
|
|
|
1135
|
-
async function renderStudioPdfWithPandoc(
|
|
1327
|
+
async function renderStudioPdfWithPandoc(
|
|
1328
|
+
markdown: string,
|
|
1329
|
+
isLatex?: boolean,
|
|
1330
|
+
resourcePath?: string,
|
|
1331
|
+
): Promise<{ pdf: Buffer; warning?: string }> {
|
|
1136
1332
|
const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
|
|
1137
1333
|
const pdfEngine = process.env.PANDOC_PDF_ENGINE?.trim() || "xelatex";
|
|
1138
1334
|
const inputFormat = isLatex
|
|
1139
1335
|
? "latex"
|
|
1140
|
-
: "
|
|
1336
|
+
: "markdown+tex_math_dollars+tex_math_single_backslash+tex_math_double_backslash+autolink_bare_uris+superscript+subscript-raw_html";
|
|
1141
1337
|
const normalizedMarkdown = isLatex ? markdown : normalizeObsidianImages(normalizeMathDelimiters(markdown));
|
|
1142
1338
|
|
|
1143
1339
|
const tempDir = join(tmpdir(), `pi-studio-pdf-${Date.now()}-${randomUUID()}`);
|
|
@@ -1147,6 +1343,11 @@ async function renderStudioPdfWithPandoc(markdown: string, isLatex?: boolean, re
|
|
|
1147
1343
|
await mkdir(tempDir, { recursive: true });
|
|
1148
1344
|
await writeFile(preamblePath, PDF_PREAMBLE, "utf-8");
|
|
1149
1345
|
|
|
1346
|
+
const mermaidPrepared: StudioMermaidPdfPreprocessResult = isLatex
|
|
1347
|
+
? { markdown: normalizedMarkdown, found: 0, replaced: 0, failed: 0, missingCli: false }
|
|
1348
|
+
: await preprocessStudioMermaidForPdf(normalizedMarkdown, tempDir);
|
|
1349
|
+
const markdownForPdf = mermaidPrepared.markdown;
|
|
1350
|
+
|
|
1150
1351
|
const args = [
|
|
1151
1352
|
"-f", inputFormat,
|
|
1152
1353
|
"-o", outputPath,
|
|
@@ -1202,10 +1403,10 @@ async function renderStudioPdfWithPandoc(markdown: string, isLatex?: boolean, re
|
|
|
1202
1403
|
fail(new Error(`pandoc PDF export failed with exit code ${code}${stderr ? `: ${stderr}` : ""}${hint}`));
|
|
1203
1404
|
});
|
|
1204
1405
|
|
|
1205
|
-
child.stdin.end(
|
|
1406
|
+
child.stdin.end(markdownForPdf);
|
|
1206
1407
|
});
|
|
1207
1408
|
|
|
1208
|
-
return await readFile(outputPath);
|
|
1409
|
+
return { pdf: await readFile(outputPath), warning: mermaidPrepared.warning };
|
|
1209
1410
|
} finally {
|
|
1210
1411
|
await rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
|
|
1211
1412
|
}
|
|
@@ -4572,6 +4773,7 @@ ${cssVarsBlock}
|
|
|
4572
4773
|
throw new Error(message);
|
|
4573
4774
|
}
|
|
4574
4775
|
|
|
4776
|
+
const exportWarning = String(response.headers.get("x-pi-studio-export-warning") || "").trim();
|
|
4575
4777
|
const blob = await response.blob();
|
|
4576
4778
|
const headerFilename = parseContentDispositionFilename(response.headers.get("content-disposition"));
|
|
4577
4779
|
let downloadName = headerFilename || filenameHint || "studio-preview.pdf";
|
|
@@ -4591,7 +4793,11 @@ ${cssVarsBlock}
|
|
|
4591
4793
|
URL.revokeObjectURL(blobUrl);
|
|
4592
4794
|
}, 1800);
|
|
4593
4795
|
|
|
4594
|
-
|
|
4796
|
+
if (exportWarning) {
|
|
4797
|
+
setStatus("Exported PDF with warning: " + exportWarning, "warning");
|
|
4798
|
+
} else {
|
|
4799
|
+
setStatus("Exported PDF: " + downloadName, "success");
|
|
4800
|
+
}
|
|
4595
4801
|
} catch (error) {
|
|
4596
4802
|
const detail = error && error.message ? error.message : String(error || "unknown error");
|
|
4597
4803
|
setStatus("PDF export failed: " + detail, "error");
|
|
@@ -7876,20 +8082,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
7876
8082
|
const filename = sanitizePdfFilename(requestedFilename || (isLatex ? "studio-latex-preview.pdf" : "studio-preview.pdf"));
|
|
7877
8083
|
|
|
7878
8084
|
try {
|
|
7879
|
-
const pdf = await renderStudioPdfWithPandoc(markdown, isLatex, resourcePath);
|
|
8085
|
+
const { pdf, warning } = await renderStudioPdfWithPandoc(markdown, isLatex, resourcePath);
|
|
7880
8086
|
const safeAsciiName = filename
|
|
7881
8087
|
.replace(/[\x00-\x1f\x7f]/g, "")
|
|
7882
8088
|
.replace(/[;"\\]/g, "_")
|
|
7883
8089
|
.replace(/\s+/g, " ")
|
|
7884
8090
|
.trim() || "studio-preview.pdf";
|
|
7885
8091
|
|
|
7886
|
-
|
|
8092
|
+
const headers: Record<string, string> = {
|
|
7887
8093
|
"Content-Type": "application/pdf",
|
|
7888
8094
|
"Cache-Control": "no-store",
|
|
7889
8095
|
"X-Content-Type-Options": "nosniff",
|
|
7890
8096
|
"Content-Disposition": `attachment; filename="${safeAsciiName}"; filename*=UTF-8''${encodeURIComponent(filename)}`,
|
|
7891
8097
|
"Content-Length": String(pdf.length),
|
|
7892
|
-
}
|
|
8098
|
+
};
|
|
8099
|
+
if (warning) headers["X-Pi-Studio-Export-Warning"] = warning;
|
|
8100
|
+
|
|
8101
|
+
res.writeHead(200, headers);
|
|
7893
8102
|
res.end(pdf);
|
|
7894
8103
|
} catch (error) {
|
|
7895
8104
|
const message = error instanceof Error ? error.message : String(error);
|