pi-studio 0.5.20 → 0.5.21
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 +7 -0
- package/client/studio-client.js +58 -8
- package/client/studio.css +27 -0
- package/index.ts +336 -28
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,13 @@ All notable changes to `pi-studio` are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.5.21] — 2026-03-19
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- PDF export now uses a two-step prepare/download flow and opens the generated PDF in the system’s default viewer first, so browser surfaces like cmux do not need to navigate away from the current Studio page.
|
|
11
|
+
- LaTeX preview and PDF export now use the document `.aux` file when available to substitute basic `\eqref{...}`, `\ref{...}`, and `\autoref{...}` values more reliably, and preview decorates block equations with their resolved equation numbers.
|
|
12
|
+
- Upload + working-directory LaTeX workflows now derive the effective source path more reliably, helping Studio find the correct `.aux` file for reference resolution.
|
|
13
|
+
|
|
7
14
|
## [0.5.20] — 2026-03-19
|
|
8
15
|
|
|
9
16
|
### Fixed
|
package/client/studio-client.js
CHANGED
|
@@ -1351,6 +1351,8 @@
|
|
|
1351
1351
|
|
|
1352
1352
|
let response;
|
|
1353
1353
|
try {
|
|
1354
|
+
const effectivePath = getEffectiveSavePath();
|
|
1355
|
+
const sourcePath = effectivePath || sourceState.path || "";
|
|
1354
1356
|
response = await fetch("/render-preview?token=" + encodeURIComponent(token), {
|
|
1355
1357
|
method: "POST",
|
|
1356
1358
|
headers: {
|
|
@@ -1358,8 +1360,8 @@
|
|
|
1358
1360
|
},
|
|
1359
1361
|
body: JSON.stringify({
|
|
1360
1362
|
markdown: String(markdown || ""),
|
|
1361
|
-
sourcePath:
|
|
1362
|
-
resourceDir: (!
|
|
1363
|
+
sourcePath: sourcePath,
|
|
1364
|
+
resourceDir: (!sourcePath && resourceDirInput) ? resourceDirInput.value.trim() : "",
|
|
1363
1365
|
}),
|
|
1364
1366
|
signal: controller ? controller.signal : undefined,
|
|
1365
1367
|
});
|
|
@@ -1444,16 +1446,17 @@
|
|
|
1444
1446
|
return;
|
|
1445
1447
|
}
|
|
1446
1448
|
|
|
1447
|
-
const
|
|
1448
|
-
const
|
|
1449
|
+
const effectivePath = getEffectiveSavePath();
|
|
1450
|
+
const sourcePath = effectivePath || sourceState.path || "";
|
|
1451
|
+
const resourceDir = (!sourcePath && resourceDirInput) ? resourceDirInput.value.trim() : "";
|
|
1449
1452
|
const isEditorPreview = rightView === "editor-preview";
|
|
1450
1453
|
const editorPdfLanguage = isEditorPreview ? normalizeFenceLanguage(editorLanguage || "") : "";
|
|
1451
1454
|
const isLatex = isEditorPreview
|
|
1452
1455
|
? editorPdfLanguage === "latex"
|
|
1453
1456
|
: /\\documentclass\b|\\begin\{document\}/.test(markdown);
|
|
1454
1457
|
let filenameHint = isEditorPreview ? "studio-editor-preview.pdf" : "studio-response-preview.pdf";
|
|
1455
|
-
if (
|
|
1456
|
-
const baseName =
|
|
1458
|
+
if (sourcePath) {
|
|
1459
|
+
const baseName = sourcePath.split(/[\\/]/).pop() || "studio";
|
|
1457
1460
|
const stem = baseName.replace(/\.[^.]+$/, "") || "studio";
|
|
1458
1461
|
filenameHint = stem + "-preview.pdf";
|
|
1459
1462
|
}
|
|
@@ -1478,8 +1481,8 @@
|
|
|
1478
1481
|
}),
|
|
1479
1482
|
});
|
|
1480
1483
|
|
|
1484
|
+
const contentType = String(response.headers.get("content-type") || "").toLowerCase();
|
|
1481
1485
|
if (!response.ok) {
|
|
1482
|
-
const contentType = String(response.headers.get("content-type") || "").toLowerCase();
|
|
1483
1486
|
let message = "PDF export failed with HTTP " + response.status + ".";
|
|
1484
1487
|
if (contentType.includes("application/json")) {
|
|
1485
1488
|
const payload = await response.json().catch(() => null);
|
|
@@ -1495,6 +1498,53 @@
|
|
|
1495
1498
|
throw new Error(message);
|
|
1496
1499
|
}
|
|
1497
1500
|
|
|
1501
|
+
if (contentType.includes("application/json")) {
|
|
1502
|
+
const payload = await response.json().catch(() => null);
|
|
1503
|
+
if (!payload || typeof payload.downloadUrl !== "string") {
|
|
1504
|
+
throw new Error("PDF export prepared successfully, but Studio did not receive a download URL.");
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
const exportWarning = typeof payload.warning === "string" ? payload.warning.trim() : "";
|
|
1508
|
+
const openError = typeof payload.openError === "string" ? payload.openError.trim() : "";
|
|
1509
|
+
const openedExternal = payload.openedExternal === true;
|
|
1510
|
+
let downloadName = typeof payload.filename === "string" && payload.filename.trim()
|
|
1511
|
+
? payload.filename.trim()
|
|
1512
|
+
: (filenameHint || "studio-preview.pdf");
|
|
1513
|
+
if (!/\.pdf$/i.test(downloadName)) {
|
|
1514
|
+
downloadName += ".pdf";
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
if (openedExternal) {
|
|
1518
|
+
if (exportWarning) {
|
|
1519
|
+
setStatus("Opened PDF in default viewer with warning: " + exportWarning, "warning");
|
|
1520
|
+
} else {
|
|
1521
|
+
setStatus("Opened PDF in default viewer: " + downloadName, "success");
|
|
1522
|
+
}
|
|
1523
|
+
return;
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
const link = document.createElement("a");
|
|
1527
|
+
link.href = payload.downloadUrl;
|
|
1528
|
+
link.download = downloadName;
|
|
1529
|
+
link.rel = "noopener";
|
|
1530
|
+
document.body.appendChild(link);
|
|
1531
|
+
link.click();
|
|
1532
|
+
link.remove();
|
|
1533
|
+
|
|
1534
|
+
if (openError) {
|
|
1535
|
+
if (exportWarning) {
|
|
1536
|
+
setStatus("Opened browser fallback because external viewer failed (" + openError + "). Warning: " + exportWarning, "warning");
|
|
1537
|
+
} else {
|
|
1538
|
+
setStatus("Opened browser fallback because external viewer failed (" + openError + ").", "warning");
|
|
1539
|
+
}
|
|
1540
|
+
} else if (exportWarning) {
|
|
1541
|
+
setStatus("Exported PDF with warning: " + exportWarning, "warning");
|
|
1542
|
+
} else {
|
|
1543
|
+
setStatus("Exported PDF: " + downloadName, "success");
|
|
1544
|
+
}
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1498
1548
|
const exportWarning = String(response.headers.get("x-pi-studio-export-warning") || "").trim();
|
|
1499
1549
|
const blob = await response.blob();
|
|
1500
1550
|
const headerFilename = parseContentDispositionFilename(response.headers.get("content-disposition"));
|
|
@@ -4030,7 +4080,7 @@
|
|
|
4030
4080
|
const text = typeof reader.result === "string" ? reader.result : "";
|
|
4031
4081
|
setEditorText(text, { preserveScroll: false, preserveSelection: false });
|
|
4032
4082
|
setSourceState({
|
|
4033
|
-
source: "
|
|
4083
|
+
source: "upload",
|
|
4034
4084
|
label: "upload: " + file.name,
|
|
4035
4085
|
path: null,
|
|
4036
4086
|
});
|
package/client/studio.css
CHANGED
|
@@ -762,6 +762,33 @@
|
|
|
762
762
|
font-family: "STIX Two Math", "Cambria Math", "Latin Modern Math", "STIXGeneral", serif;
|
|
763
763
|
}
|
|
764
764
|
|
|
765
|
+
.rendered-markdown .studio-display-equation {
|
|
766
|
+
position: relative;
|
|
767
|
+
margin: 1em 0;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
.rendered-markdown .studio-display-equation-body {
|
|
771
|
+
padding-right: 4.5em;
|
|
772
|
+
overflow-x: auto;
|
|
773
|
+
overflow-y: hidden;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
.rendered-markdown .studio-display-equation-body math[display="block"] {
|
|
777
|
+
margin: 0;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
.rendered-markdown .studio-display-equation-number {
|
|
781
|
+
position: absolute;
|
|
782
|
+
top: 50%;
|
|
783
|
+
right: 0;
|
|
784
|
+
transform: translateY(-50%);
|
|
785
|
+
color: var(--muted);
|
|
786
|
+
font-size: 0.95em;
|
|
787
|
+
line-height: 1;
|
|
788
|
+
white-space: nowrap;
|
|
789
|
+
font-variant-numeric: tabular-nums;
|
|
790
|
+
}
|
|
791
|
+
|
|
765
792
|
.rendered-markdown math[display="block"] {
|
|
766
793
|
display: block;
|
|
767
794
|
margin: 1em 0;
|
package/index.ts
CHANGED
|
@@ -56,6 +56,15 @@ interface StudioContextUsageSnapshot {
|
|
|
56
56
|
percent: number | null;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
interface PreparedStudioPdfExport {
|
|
60
|
+
pdf: Buffer;
|
|
61
|
+
filename: string;
|
|
62
|
+
warning?: string;
|
|
63
|
+
createdAt: number;
|
|
64
|
+
filePath?: string;
|
|
65
|
+
tempDirPath?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
59
68
|
interface InitialStudioDocument {
|
|
60
69
|
text: string;
|
|
61
70
|
label: string;
|
|
@@ -158,6 +167,8 @@ const REQUEST_BODY_MAX_BYTES = 1_000_000;
|
|
|
158
167
|
const RESPONSE_HISTORY_LIMIT = 30;
|
|
159
168
|
const UPDATE_CHECK_TIMEOUT_MS = 1800;
|
|
160
169
|
const CMUX_NOTIFY_TIMEOUT_MS = 1200;
|
|
170
|
+
const PREPARED_PDF_EXPORT_TTL_MS = 5 * 60 * 1000;
|
|
171
|
+
const MAX_PREPARED_PDF_EXPORTS = 8;
|
|
161
172
|
const STUDIO_TERMINAL_NOTIFY_TITLE = "pi Studio";
|
|
162
173
|
const CMUX_STUDIO_STATUS_KEY = "pi_studio";
|
|
163
174
|
const CMUX_STUDIO_STATUS_COLOR_DARK = "#5ea1ff";
|
|
@@ -1099,6 +1110,160 @@ function buildStudioPandocBibliographyArgs(markdown: string, isLatex: boolean |
|
|
|
1099
1110
|
];
|
|
1100
1111
|
}
|
|
1101
1112
|
|
|
1113
|
+
function parseStudioAuxTopLevelGroups(input: string): string[] {
|
|
1114
|
+
const groups: string[] = [];
|
|
1115
|
+
let i = 0;
|
|
1116
|
+
while (i < input.length) {
|
|
1117
|
+
while (i < input.length && /\s/.test(input[i]!)) i++;
|
|
1118
|
+
if (i >= input.length) break;
|
|
1119
|
+
if (input[i] !== "{") break;
|
|
1120
|
+
i++;
|
|
1121
|
+
let depth = 1;
|
|
1122
|
+
let current = "";
|
|
1123
|
+
while (i < input.length && depth > 0) {
|
|
1124
|
+
const ch = input[i]!;
|
|
1125
|
+
i++;
|
|
1126
|
+
if (ch === "{") {
|
|
1127
|
+
depth++;
|
|
1128
|
+
current += ch;
|
|
1129
|
+
continue;
|
|
1130
|
+
}
|
|
1131
|
+
if (ch === "}") {
|
|
1132
|
+
depth--;
|
|
1133
|
+
if (depth > 0) current += ch;
|
|
1134
|
+
continue;
|
|
1135
|
+
}
|
|
1136
|
+
current += ch;
|
|
1137
|
+
}
|
|
1138
|
+
groups.push(current);
|
|
1139
|
+
}
|
|
1140
|
+
return groups;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function resolveStudioLatexAuxPath(sourcePath: string | undefined, baseDir: string | undefined): string | undefined {
|
|
1144
|
+
const source = typeof sourcePath === "string" ? sourcePath.trim() : "";
|
|
1145
|
+
const workingDir = resolveStudioPandocWorkingDir(baseDir);
|
|
1146
|
+
if (!source) return undefined;
|
|
1147
|
+
const expanded = expandHome(source);
|
|
1148
|
+
const resolvedSource = isAbsolute(expanded)
|
|
1149
|
+
? expanded
|
|
1150
|
+
: resolve(workingDir || process.cwd(), expanded);
|
|
1151
|
+
|
|
1152
|
+
if (!/\.(tex|latex)$/i.test(resolvedSource)) return undefined;
|
|
1153
|
+
const auxPath = resolvedSource.replace(/\.[^.]+$/i, ".aux");
|
|
1154
|
+
try {
|
|
1155
|
+
return statSync(auxPath).isFile() ? auxPath : undefined;
|
|
1156
|
+
} catch {
|
|
1157
|
+
return undefined;
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
function readStudioLatexAuxLabels(sourcePath: string | undefined, baseDir: string | undefined): Map<string, { number: string; kind: string }> {
|
|
1162
|
+
const auxPath = resolveStudioLatexAuxPath(sourcePath, baseDir);
|
|
1163
|
+
const labels = new Map<string, { number: string; kind: string }>();
|
|
1164
|
+
if (!auxPath) return labels;
|
|
1165
|
+
|
|
1166
|
+
let text = "";
|
|
1167
|
+
try {
|
|
1168
|
+
text = readFileSync(auxPath, "utf-8");
|
|
1169
|
+
} catch {
|
|
1170
|
+
return labels;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
for (const line of text.split(/\r?\n/)) {
|
|
1174
|
+
const match = line.match(/^\\newlabel\{([^}]+)\}\{(.*)\}$/);
|
|
1175
|
+
if (!match) continue;
|
|
1176
|
+
const label = match[1] ?? "";
|
|
1177
|
+
if (!label || label.endsWith("@cref")) continue;
|
|
1178
|
+
const groups = parseStudioAuxTopLevelGroups(match[2] ?? "");
|
|
1179
|
+
if (groups.length === 0) continue;
|
|
1180
|
+
const number = String(groups[0] ?? "").trim();
|
|
1181
|
+
if (!number) continue;
|
|
1182
|
+
const rawKind = String(groups[3] ?? "").trim();
|
|
1183
|
+
const kind = rawKind.split(".")[0] || (label.startsWith("eq:") ? "equation" : label.startsWith("fig:") ? "figure" : "ref");
|
|
1184
|
+
labels.set(label, { number, kind });
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
return labels;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
function formatStudioLatexReference(label: string, referenceType: "eqref" | "ref" | "autoref", labels: Map<string, { number: string; kind: string }>): string | null {
|
|
1191
|
+
const entry = labels.get(label);
|
|
1192
|
+
if (!entry) return null;
|
|
1193
|
+
if (referenceType === "eqref") return `(${entry.number})`;
|
|
1194
|
+
if (referenceType === "autoref") {
|
|
1195
|
+
if (entry.kind === "equation") return `Equation ${entry.number}`;
|
|
1196
|
+
if (entry.kind === "figure") return `Figure ${entry.number}`;
|
|
1197
|
+
if (entry.kind === "section" || entry.kind === "subsection" || entry.kind === "subsubsection") return `Section ${entry.number}`;
|
|
1198
|
+
if (entry.kind === "algorithm") return `Algorithm ${entry.number}`;
|
|
1199
|
+
}
|
|
1200
|
+
return entry.number;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
function preprocessStudioLatexReferences(markdown: string, sourcePath: string | undefined, baseDir: string | undefined): string {
|
|
1204
|
+
const labels = readStudioLatexAuxLabels(sourcePath, baseDir);
|
|
1205
|
+
if (labels.size === 0) return markdown;
|
|
1206
|
+
let transformed = String(markdown ?? "");
|
|
1207
|
+
transformed = transformed.replace(/\\eqref\s*\{([^}]+)\}/g, (match, label) => formatStudioLatexReference(String(label || "").trim(), "eqref", labels) ?? match);
|
|
1208
|
+
transformed = transformed.replace(/\\autoref\s*\{([^}]+)\}/g, (match, label) => formatStudioLatexReference(String(label || "").trim(), "autoref", labels) ?? match);
|
|
1209
|
+
transformed = transformed.replace(/\\ref\s*\{([^}]+)\}/g, (match, label) => formatStudioLatexReference(String(label || "").trim(), "ref", labels) ?? match);
|
|
1210
|
+
return transformed;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
function escapeStudioHtmlText(text: string): string {
|
|
1214
|
+
return String(text ?? "")
|
|
1215
|
+
.replace(/&/g, "&")
|
|
1216
|
+
.replace(/</g, "<")
|
|
1217
|
+
.replace(/>/g, ">")
|
|
1218
|
+
.replace(/"/g, """)
|
|
1219
|
+
.replace(/'/g, "'");
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
function decorateStudioLatexRenderedHtml(html: string, sourcePath: string | undefined, baseDir: string | undefined): string {
|
|
1223
|
+
const labels = readStudioLatexAuxLabels(sourcePath, baseDir);
|
|
1224
|
+
if (labels.size === 0) return html;
|
|
1225
|
+
let transformed = String(html ?? "");
|
|
1226
|
+
|
|
1227
|
+
transformed = transformed.replace(/<a\b([^>]*)>([\s\S]*?)<\/a>/g, (match, attrs) => {
|
|
1228
|
+
const typeMatch = String(attrs ?? "").match(/\bdata-reference-type="([^"]+)"/);
|
|
1229
|
+
const labelMatch = String(attrs ?? "").match(/\bdata-reference="([^"]+)"/);
|
|
1230
|
+
if (!typeMatch || !labelMatch) return match;
|
|
1231
|
+
const referenceTypeRaw = String(typeMatch[1] ?? "").trim();
|
|
1232
|
+
const label = String(labelMatch[1] ?? "").trim();
|
|
1233
|
+
const referenceType =
|
|
1234
|
+
referenceTypeRaw === "eqref" || referenceTypeRaw === "autoref" || referenceTypeRaw === "ref"
|
|
1235
|
+
? referenceTypeRaw
|
|
1236
|
+
: null;
|
|
1237
|
+
if (!referenceType || !label) return match;
|
|
1238
|
+
const formatted = formatStudioLatexReference(label, referenceType, labels);
|
|
1239
|
+
if (!formatted) return match;
|
|
1240
|
+
return `<a${attrs}>${escapeStudioHtmlText(formatted)}</a>`;
|
|
1241
|
+
});
|
|
1242
|
+
|
|
1243
|
+
transformed = transformed.replace(/<math\b[^>]*display="block"[^>]*>[\s\S]*?<\/math>/g, (block) => {
|
|
1244
|
+
if (/studio-display-equation/.test(block)) return block;
|
|
1245
|
+
const labelMatch = block.match(/\\label\s*\{([^}]+)\}/);
|
|
1246
|
+
if (!labelMatch) return block;
|
|
1247
|
+
const label = String(labelMatch[1] ?? "").trim();
|
|
1248
|
+
if (!label) return block;
|
|
1249
|
+
const formatted = formatStudioLatexReference(label, "eqref", labels);
|
|
1250
|
+
if (!formatted) return block;
|
|
1251
|
+
return `<div class="studio-display-equation"><div class="studio-display-equation-body">${block}</div><div class="studio-display-equation-number">${escapeStudioHtmlText(formatted)}</div></div>`;
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
return transformed;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
function injectStudioLatexEquationTags(markdown: string, sourcePath: string | undefined, baseDir: string | undefined): string {
|
|
1258
|
+
const labels = readStudioLatexAuxLabels(sourcePath, baseDir);
|
|
1259
|
+
if (labels.size === 0) return markdown;
|
|
1260
|
+
return String(markdown ?? "").replace(/\\label\s*\{([^}]+)\}/g, (match, label) => {
|
|
1261
|
+
const entry = labels.get(String(label || "").trim());
|
|
1262
|
+
if (!entry || entry.kind !== "equation") return match;
|
|
1263
|
+
return `\\tag{${entry.number}}\\label{${String(label || "").trim()}}`;
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1102
1267
|
function readStudioGitDiff(baseDir: string):
|
|
1103
1268
|
| { ok: true; text: string; label: string }
|
|
1104
1269
|
| { ok: false; level: "info" | "warning" | "error"; message: string } {
|
|
@@ -1681,17 +1846,18 @@ async function preprocessStudioMermaidForPdf(markdown: string, workDir: string):
|
|
|
1681
1846
|
};
|
|
1682
1847
|
}
|
|
1683
1848
|
|
|
1684
|
-
async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolean, resourcePath?: string): Promise<string> {
|
|
1849
|
+
async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolean, resourcePath?: string, sourcePath?: string): Promise<string> {
|
|
1685
1850
|
const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
|
|
1851
|
+
const sourceWithResolvedRefs = isLatex ? preprocessStudioLatexReferences(markdown, sourcePath, resourcePath) : markdown;
|
|
1686
1852
|
const inputFormat = isLatex ? "latex" : "markdown+lists_without_preceding_blankline+tex_math_dollars+tex_math_single_backslash+tex_math_double_backslash+autolink_bare_uris-raw_html";
|
|
1687
|
-
const bibliographyArgs = buildStudioPandocBibliographyArgs(
|
|
1853
|
+
const bibliographyArgs = buildStudioPandocBibliographyArgs(sourceWithResolvedRefs, isLatex, resourcePath);
|
|
1688
1854
|
const args = ["-f", inputFormat, "-t", "html5", "--mathml", "--wrap=none", ...bibliographyArgs];
|
|
1689
1855
|
if (resourcePath) {
|
|
1690
1856
|
args.push(`--resource-path=${resourcePath}`);
|
|
1691
1857
|
// Embed images as data URIs so they render in the browser preview
|
|
1692
1858
|
args.push("--embed-resources", "--standalone");
|
|
1693
1859
|
}
|
|
1694
|
-
const normalizedMarkdown = isLatex ?
|
|
1860
|
+
const normalizedMarkdown = isLatex ? sourceWithResolvedRefs : normalizeObsidianImages(normalizeMathDelimiters(sourceWithResolvedRefs));
|
|
1695
1861
|
const pandocWorkingDir = resolveStudioPandocWorkingDir(resourcePath);
|
|
1696
1862
|
|
|
1697
1863
|
return await new Promise<string>((resolve, reject) => {
|
|
@@ -1737,6 +1903,9 @@ async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolea
|
|
|
1737
1903
|
const bodyMatch = renderedHtml.match(/<body[^>]*>([\s\S]*)<\/body>/i);
|
|
1738
1904
|
if (bodyMatch) renderedHtml = bodyMatch[1];
|
|
1739
1905
|
}
|
|
1906
|
+
if (isLatex) {
|
|
1907
|
+
renderedHtml = decorateStudioLatexRenderedHtml(renderedHtml, sourcePath, resourcePath);
|
|
1908
|
+
}
|
|
1740
1909
|
succeed(stripMathMlAnnotationTags(renderedHtml));
|
|
1741
1910
|
return;
|
|
1742
1911
|
}
|
|
@@ -1832,12 +2001,16 @@ async function renderStudioPdfWithPandoc(
|
|
|
1832
2001
|
isLatex?: boolean,
|
|
1833
2002
|
resourcePath?: string,
|
|
1834
2003
|
editorPdfLanguage?: string,
|
|
2004
|
+
sourcePath?: string,
|
|
1835
2005
|
): Promise<{ pdf: Buffer; warning?: string }> {
|
|
1836
2006
|
const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
|
|
1837
2007
|
const pdfEngine = process.env.PANDOC_PDF_ENGINE?.trim() || "xelatex";
|
|
1838
|
-
const
|
|
2008
|
+
const sourceWithResolvedRefs = isLatex
|
|
2009
|
+
? injectStudioLatexEquationTags(preprocessStudioLatexReferences(markdown, sourcePath, resourcePath), sourcePath, resourcePath)
|
|
2010
|
+
: markdown;
|
|
2011
|
+
const effectiveEditorLanguage = inferStudioPdfLanguage(sourceWithResolvedRefs, editorPdfLanguage);
|
|
1839
2012
|
const pandocWorkingDir = resolveStudioPandocWorkingDir(resourcePath);
|
|
1840
|
-
const bibliographyArgs = buildStudioPandocBibliographyArgs(
|
|
2013
|
+
const bibliographyArgs = buildStudioPandocBibliographyArgs(sourceWithResolvedRefs, isLatex, resourcePath);
|
|
1841
2014
|
|
|
1842
2015
|
const runPandocPdfExport = async (
|
|
1843
2016
|
inputFormat: string,
|
|
@@ -1934,7 +2107,7 @@ async function renderStudioPdfWithPandoc(
|
|
|
1934
2107
|
const inputFormat = isLatex
|
|
1935
2108
|
? "latex"
|
|
1936
2109
|
: "markdown+lists_without_preceding_blankline+tex_math_dollars+tex_math_single_backslash+tex_math_double_backslash+autolink_bare_uris+superscript+subscript-raw_html";
|
|
1937
|
-
const normalizedMarkdown = prepareStudioPdfMarkdown(
|
|
2110
|
+
const normalizedMarkdown = prepareStudioPdfMarkdown(sourceWithResolvedRefs, isLatex, effectiveEditorLanguage);
|
|
1938
2111
|
|
|
1939
2112
|
const tempDir = join(tmpdir(), `pi-studio-pdf-${Date.now()}-${randomUUID()}`);
|
|
1940
2113
|
const preamblePath = join(tempDir, "_pdf_preamble.tex");
|
|
@@ -2095,6 +2268,27 @@ function openUrlInDefaultBrowser(url: string): Promise<void> {
|
|
|
2095
2268
|
});
|
|
2096
2269
|
}
|
|
2097
2270
|
|
|
2271
|
+
function openPathInDefaultViewer(path: string): Promise<void> {
|
|
2272
|
+
const openCommand =
|
|
2273
|
+
process.platform === "darwin"
|
|
2274
|
+
? { command: "open", args: [path] }
|
|
2275
|
+
: process.platform === "win32"
|
|
2276
|
+
? { command: "cmd", args: ["/c", "start", "", path] }
|
|
2277
|
+
: { command: "xdg-open", args: [path] };
|
|
2278
|
+
|
|
2279
|
+
return new Promise<void>((resolve, reject) => {
|
|
2280
|
+
const child = spawn(openCommand.command, openCommand.args, {
|
|
2281
|
+
stdio: "ignore",
|
|
2282
|
+
detached: true,
|
|
2283
|
+
});
|
|
2284
|
+
child.once("error", reject);
|
|
2285
|
+
child.once("spawn", () => {
|
|
2286
|
+
child.unref();
|
|
2287
|
+
resolve();
|
|
2288
|
+
});
|
|
2289
|
+
});
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2098
2292
|
function detectLensFromText(text: string): Lens {
|
|
2099
2293
|
const lines = text.split("\n");
|
|
2100
2294
|
const fencedCodeBlocks = (text.match(/```[\w-]*\n[\s\S]*?```/g) ?? []).length;
|
|
@@ -3044,6 +3238,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
3044
3238
|
let serverState: StudioServerState | null = null;
|
|
3045
3239
|
let activeRequest: ActiveStudioRequest | null = null;
|
|
3046
3240
|
let lastStudioResponse: LastStudioResponse | null = null;
|
|
3241
|
+
let preparedPdfExports = new Map<string, PreparedStudioPdfExport>();
|
|
3047
3242
|
let initialStudioDocument: InitialStudioDocument | null = null;
|
|
3048
3243
|
let studioCwd = process.cwd();
|
|
3049
3244
|
let lastCommandCtx: ExtensionCommandContext | null = null;
|
|
@@ -4105,6 +4300,100 @@ export default function (pi: ExtensionAPI) {
|
|
|
4105
4300
|
}
|
|
4106
4301
|
};
|
|
4107
4302
|
|
|
4303
|
+
const disposePreparedPdfExport = (entry: PreparedStudioPdfExport | null | undefined) => {
|
|
4304
|
+
if (!entry?.tempDirPath) return;
|
|
4305
|
+
void rm(entry.tempDirPath, { recursive: true, force: true }).catch(() => undefined);
|
|
4306
|
+
};
|
|
4307
|
+
|
|
4308
|
+
const clearPreparedPdfExports = () => {
|
|
4309
|
+
for (const entry of preparedPdfExports.values()) {
|
|
4310
|
+
disposePreparedPdfExport(entry);
|
|
4311
|
+
}
|
|
4312
|
+
preparedPdfExports.clear();
|
|
4313
|
+
};
|
|
4314
|
+
|
|
4315
|
+
const prunePreparedPdfExports = () => {
|
|
4316
|
+
const now = Date.now();
|
|
4317
|
+
for (const [id, entry] of preparedPdfExports) {
|
|
4318
|
+
if (entry.createdAt + PREPARED_PDF_EXPORT_TTL_MS <= now) {
|
|
4319
|
+
preparedPdfExports.delete(id);
|
|
4320
|
+
disposePreparedPdfExport(entry);
|
|
4321
|
+
}
|
|
4322
|
+
}
|
|
4323
|
+
while (preparedPdfExports.size > MAX_PREPARED_PDF_EXPORTS) {
|
|
4324
|
+
const oldestKey = preparedPdfExports.keys().next().value;
|
|
4325
|
+
if (!oldestKey) break;
|
|
4326
|
+
const oldestEntry = preparedPdfExports.get(oldestKey);
|
|
4327
|
+
preparedPdfExports.delete(oldestKey);
|
|
4328
|
+
disposePreparedPdfExport(oldestEntry);
|
|
4329
|
+
}
|
|
4330
|
+
};
|
|
4331
|
+
|
|
4332
|
+
const storePreparedPdfExport = (pdf: Buffer, filename: string, warning?: string): string => {
|
|
4333
|
+
prunePreparedPdfExports();
|
|
4334
|
+
const exportId = randomUUID();
|
|
4335
|
+
preparedPdfExports.set(exportId, {
|
|
4336
|
+
pdf,
|
|
4337
|
+
filename,
|
|
4338
|
+
warning,
|
|
4339
|
+
createdAt: Date.now(),
|
|
4340
|
+
});
|
|
4341
|
+
return exportId;
|
|
4342
|
+
};
|
|
4343
|
+
|
|
4344
|
+
const ensurePreparedPdfExportFile = async (exportId: string): Promise<PreparedStudioPdfExport | null> => {
|
|
4345
|
+
prunePreparedPdfExports();
|
|
4346
|
+
const entry = preparedPdfExports.get(exportId);
|
|
4347
|
+
if (!entry) return null;
|
|
4348
|
+
if (entry.filePath && entry.tempDirPath) return entry;
|
|
4349
|
+
|
|
4350
|
+
const tempDirPath = join(tmpdir(), `pi-studio-prepared-pdf-${Date.now()}-${randomUUID()}`);
|
|
4351
|
+
const filePath = join(tempDirPath, sanitizePdfFilename(entry.filename));
|
|
4352
|
+
await mkdir(tempDirPath, { recursive: true });
|
|
4353
|
+
await writeFile(filePath, entry.pdf);
|
|
4354
|
+
entry.tempDirPath = tempDirPath;
|
|
4355
|
+
entry.filePath = filePath;
|
|
4356
|
+
preparedPdfExports.set(exportId, entry);
|
|
4357
|
+
return entry;
|
|
4358
|
+
};
|
|
4359
|
+
|
|
4360
|
+
const getPreparedPdfExport = (exportId: string): PreparedStudioPdfExport | null => {
|
|
4361
|
+
prunePreparedPdfExports();
|
|
4362
|
+
return preparedPdfExports.get(exportId) ?? null;
|
|
4363
|
+
};
|
|
4364
|
+
|
|
4365
|
+
const handlePreparedPdfDownloadRequest = (requestUrl: URL, res: ServerResponse) => {
|
|
4366
|
+
const exportId = requestUrl.searchParams.get("id") ?? "";
|
|
4367
|
+
if (!exportId) {
|
|
4368
|
+
respondText(res, 400, "Missing PDF export id.");
|
|
4369
|
+
return;
|
|
4370
|
+
}
|
|
4371
|
+
|
|
4372
|
+
const prepared = getPreparedPdfExport(exportId);
|
|
4373
|
+
if (!prepared) {
|
|
4374
|
+
respondText(res, 404, "PDF export is no longer available. Re-export the document.");
|
|
4375
|
+
return;
|
|
4376
|
+
}
|
|
4377
|
+
|
|
4378
|
+
const safeAsciiName = prepared.filename
|
|
4379
|
+
.replace(/[\x00-\x1f\x7f]/g, "")
|
|
4380
|
+
.replace(/[;"\\]/g, "_")
|
|
4381
|
+
.replace(/\s+/g, " ")
|
|
4382
|
+
.trim() || "studio-preview.pdf";
|
|
4383
|
+
|
|
4384
|
+
const headers: Record<string, string> = {
|
|
4385
|
+
"Content-Type": "application/pdf",
|
|
4386
|
+
"Cache-Control": "no-store",
|
|
4387
|
+
"X-Content-Type-Options": "nosniff",
|
|
4388
|
+
"Content-Disposition": `inline; filename="${safeAsciiName}"; filename*=UTF-8''${encodeURIComponent(prepared.filename)}`,
|
|
4389
|
+
"Content-Length": String(prepared.pdf.length),
|
|
4390
|
+
};
|
|
4391
|
+
if (prepared.warning) headers["X-Pi-Studio-Export-Warning"] = prepared.warning;
|
|
4392
|
+
|
|
4393
|
+
res.writeHead(200, headers);
|
|
4394
|
+
res.end(prepared.pdf);
|
|
4395
|
+
};
|
|
4396
|
+
|
|
4108
4397
|
const handleRenderPreviewRequest = async (req: IncomingMessage, res: ServerResponse) => {
|
|
4109
4398
|
let rawBody = "";
|
|
4110
4399
|
try {
|
|
@@ -4153,7 +4442,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
4153
4442
|
: "";
|
|
4154
4443
|
const resourcePath = resolveStudioBaseDir(sourcePath || undefined, userResourceDir || undefined, studioCwd);
|
|
4155
4444
|
const isLatex = /\\documentclass\b|\\begin\{document\}/.test(markdown);
|
|
4156
|
-
const html = await renderStudioMarkdownWithPandoc(markdown, isLatex, resourcePath);
|
|
4445
|
+
const html = await renderStudioMarkdownWithPandoc(markdown, isLatex, resourcePath, sourcePath || undefined);
|
|
4157
4446
|
respondJson(res, 200, { ok: true, html, renderer: "pandoc" });
|
|
4158
4447
|
} catch (error) {
|
|
4159
4448
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -4227,24 +4516,29 @@ export default function (pi: ExtensionAPI) {
|
|
|
4227
4516
|
const filename = sanitizePdfFilename(requestedFilename || (isLatex ? "studio-latex-preview.pdf" : "studio-preview.pdf"));
|
|
4228
4517
|
|
|
4229
4518
|
try {
|
|
4230
|
-
const { pdf, warning } = await renderStudioPdfWithPandoc(markdown, isLatex, resourcePath, editorPdfLanguage);
|
|
4231
|
-
const
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
4519
|
+
const { pdf, warning } = await renderStudioPdfWithPandoc(markdown, isLatex, resourcePath, editorPdfLanguage, sourcePath || undefined);
|
|
4520
|
+
const exportId = storePreparedPdfExport(pdf, filename, warning);
|
|
4521
|
+
const token = serverState?.token ?? "";
|
|
4522
|
+
let openedExternal = false;
|
|
4523
|
+
let openError: string | null = null;
|
|
4524
|
+
try {
|
|
4525
|
+
const prepared = await ensurePreparedPdfExportFile(exportId);
|
|
4526
|
+
if (!prepared?.filePath) {
|
|
4527
|
+
throw new Error("Prepared PDF file was not available for external open.");
|
|
4528
|
+
}
|
|
4529
|
+
await openPathInDefaultViewer(prepared.filePath);
|
|
4530
|
+
openedExternal = true;
|
|
4531
|
+
} catch (viewerError) {
|
|
4532
|
+
openError = viewerError instanceof Error ? viewerError.message : String(viewerError);
|
|
4533
|
+
}
|
|
4534
|
+
respondJson(res, 200, {
|
|
4535
|
+
ok: true,
|
|
4536
|
+
filename,
|
|
4537
|
+
warning: warning ?? null,
|
|
4538
|
+
openedExternal,
|
|
4539
|
+
openError,
|
|
4540
|
+
downloadUrl: `/export-pdf?token=${encodeURIComponent(token)}&id=${encodeURIComponent(exportId)}`,
|
|
4541
|
+
});
|
|
4248
4542
|
} catch (error) {
|
|
4249
4543
|
const message = error instanceof Error ? error.message : String(error);
|
|
4250
4544
|
respondJson(res, 500, { ok: false, error: `PDF export failed: ${message}` });
|
|
@@ -4361,14 +4655,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
4361
4655
|
if (requestUrl.pathname === "/export-pdf") {
|
|
4362
4656
|
const token = requestUrl.searchParams.get("token") ?? "";
|
|
4363
4657
|
if (token !== serverState.token) {
|
|
4364
|
-
|
|
4658
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
4659
|
+
if (method === "GET") {
|
|
4660
|
+
respondText(res, 403, "Invalid or expired studio token. Re-run /studio.");
|
|
4661
|
+
} else {
|
|
4662
|
+
respondJson(res, 403, { ok: false, error: "Invalid or expired studio token. Re-run /studio." });
|
|
4663
|
+
}
|
|
4365
4664
|
return;
|
|
4366
4665
|
}
|
|
4367
4666
|
|
|
4368
4667
|
const method = (req.method ?? "GET").toUpperCase();
|
|
4668
|
+
if (method === "GET") {
|
|
4669
|
+
handlePreparedPdfDownloadRequest(requestUrl, res);
|
|
4670
|
+
return;
|
|
4671
|
+
}
|
|
4369
4672
|
if (method !== "POST") {
|
|
4370
|
-
res.setHeader("Allow", "POST");
|
|
4371
|
-
respondJson(res, 405, { ok: false, error: "Method not allowed. Use POST." });
|
|
4673
|
+
res.setHeader("Allow", "GET, POST");
|
|
4674
|
+
respondJson(res, 405, { ok: false, error: "Method not allowed. Use GET or POST." });
|
|
4372
4675
|
return;
|
|
4373
4676
|
}
|
|
4374
4677
|
|
|
@@ -4534,6 +4837,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
4534
4837
|
if (!serverState) return;
|
|
4535
4838
|
clearActiveRequest();
|
|
4536
4839
|
clearPendingStudioCompletion();
|
|
4840
|
+
clearPreparedPdfExports();
|
|
4537
4841
|
clearCompactionState();
|
|
4538
4842
|
closeAllClients(1001, "Server shutting down");
|
|
4539
4843
|
|
|
@@ -4552,6 +4856,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
4552
4856
|
const rotateToken = () => {
|
|
4553
4857
|
if (!serverState) return;
|
|
4554
4858
|
serverState.token = createSessionToken();
|
|
4859
|
+
clearPreparedPdfExports();
|
|
4555
4860
|
closeAllClients(4001, "Session invalidated");
|
|
4556
4861
|
broadcastState();
|
|
4557
4862
|
};
|
|
@@ -4566,6 +4871,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
4566
4871
|
clearCompactionState();
|
|
4567
4872
|
agentBusy = false;
|
|
4568
4873
|
clearPendingStudioCompletion();
|
|
4874
|
+
clearPreparedPdfExports();
|
|
4569
4875
|
refreshRuntimeMetadata({ cwd: ctx.cwd, model: ctx.model });
|
|
4570
4876
|
refreshContextUsage(ctx);
|
|
4571
4877
|
emitDebugEvent("session_start", {
|
|
@@ -4585,6 +4891,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
4585
4891
|
hydrateLatestAssistant(ctx.sessionManager.getBranch());
|
|
4586
4892
|
agentBusy = false;
|
|
4587
4893
|
clearPendingStudioCompletion();
|
|
4894
|
+
clearPreparedPdfExports();
|
|
4588
4895
|
refreshRuntimeMetadata({ cwd: ctx.cwd, model: ctx.model });
|
|
4589
4896
|
refreshContextUsage(ctx);
|
|
4590
4897
|
emitDebugEvent("session_switch", {
|
|
@@ -4805,6 +5112,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
4805
5112
|
lastCommandCtx = null;
|
|
4806
5113
|
agentBusy = false;
|
|
4807
5114
|
clearPendingStudioCompletion();
|
|
5115
|
+
clearPreparedPdfExports();
|
|
4808
5116
|
clearCompactionState();
|
|
4809
5117
|
setTerminalActivity("idle");
|
|
4810
5118
|
await stopServer();
|