pi-studio 0.5.19 → 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 CHANGED
@@ -4,6 +4,20 @@ 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
+
14
+ ## [0.5.20] — 2026-03-19
15
+
16
+ ### Fixed
17
+ - LaTeX preview/PDF export now runs pandoc from the resolved source/working directory, so project-relative `\input{...}` files, shared macros, and similar local assets resolve more reliably for multi-file documents.
18
+ - LaTeX preview/PDF export now also detects basic bibliography directives such as `\bibliography{...}` and `\addbibresource{...}` and passes the resolved `.bib` files to pandoc citeproc, so references show up more often in Studio without a full `latexmk` build.
19
+ - Display-math blocks in preview are now styled to center more naturally, and the raw-editor highlight cutoff is bumped to `100_000` characters so moderately large `.tex` files still get inline syntax colouring.
20
+
7
21
  ## [0.5.19] — 2026-03-19
8
22
 
9
23
  ### Fixed
@@ -163,7 +163,7 @@
163
163
  };
164
164
  let activePane = "left";
165
165
  let paneFocusTarget = "off";
166
- const EDITOR_HIGHLIGHT_MAX_CHARS = 80_000;
166
+ const EDITOR_HIGHLIGHT_MAX_CHARS = 100_000;
167
167
  const EDITOR_HIGHLIGHT_STORAGE_KEY = "piStudio.editorHighlightEnabled";
168
168
  const EDITOR_LANGUAGE_STORAGE_KEY = "piStudio.editorLanguage";
169
169
  // Single source of truth: language -> file extensions (and display label)
@@ -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: sourceState.path || "",
1362
- resourceDir: (!sourceState.path && resourceDirInput) ? resourceDirInput.value.trim() : "",
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 sourcePath = sourceState.path || "";
1448
- const resourceDir = (!sourceState.path && resourceDirInput) ? resourceDirInput.value.trim() : "";
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 (sourceState.path) {
1456
- const baseName = sourceState.path.split(/[\\/]/).pop() || "studio";
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: "blank",
4083
+ source: "upload",
4034
4084
  label: "upload: " + file.name,
4035
4085
  path: null,
4036
4086
  });
package/client/studio.css CHANGED
@@ -758,9 +758,41 @@
758
758
  max-width: 100%;
759
759
  }
760
760
 
761
+ .rendered-markdown math {
762
+ font-family: "STIX Two Math", "Cambria Math", "Latin Modern Math", "STIXGeneral", serif;
763
+ }
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
+
761
792
  .rendered-markdown math[display="block"] {
762
793
  display: block;
763
794
  margin: 1em 0;
795
+ text-align: center;
764
796
  overflow-x: auto;
765
797
  overflow-y: hidden;
766
798
  }
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";
@@ -994,20 +1005,265 @@ function buildStudioSyntheticNewFileDiff(filePath: string, content: string): str
994
1005
  return diffLines.join("\n");
995
1006
  }
996
1007
 
997
- function resolveStudioGitDiffBaseDir(sourcePath: string | undefined, resourceDir: string | undefined, fallbackCwd: string): string {
1008
+ function resolveStudioBaseDir(sourcePath: string | undefined, resourceDir: string | undefined, fallbackCwd: string): string {
998
1009
  const source = typeof sourcePath === "string" ? sourcePath.trim() : "";
999
1010
  if (source) {
1000
- return dirname(source);
1011
+ const expanded = expandHome(source);
1012
+ return dirname(isAbsolute(expanded) ? expanded : resolve(fallbackCwd, expanded));
1001
1013
  }
1002
1014
 
1003
1015
  const resource = typeof resourceDir === "string" ? resourceDir.trim() : "";
1004
1016
  if (resource) {
1005
- return isAbsolute(resource) ? resource : resolve(fallbackCwd, resource);
1017
+ const expanded = expandHome(resource);
1018
+ return isAbsolute(expanded) ? expanded : resolve(fallbackCwd, expanded);
1006
1019
  }
1007
1020
 
1008
1021
  return fallbackCwd;
1009
1022
  }
1010
1023
 
1024
+ function resolveStudioGitDiffBaseDir(sourcePath: string | undefined, resourceDir: string | undefined, fallbackCwd: string): string {
1025
+ return resolveStudioBaseDir(sourcePath, resourceDir, fallbackCwd);
1026
+ }
1027
+
1028
+ function resolveStudioPandocWorkingDir(baseDir: string | undefined): string | undefined {
1029
+ const normalized = typeof baseDir === "string" ? baseDir.trim() : "";
1030
+ if (!normalized) return undefined;
1031
+ try {
1032
+ return statSync(normalized).isDirectory() ? normalized : undefined;
1033
+ } catch {
1034
+ return undefined;
1035
+ }
1036
+ }
1037
+
1038
+ function stripStudioLatexComments(text: string): string {
1039
+ const lines = String(text ?? "").replace(/\r\n/g, "\n").split("\n");
1040
+ return lines.map((line) => {
1041
+ let out = "";
1042
+ let backslashRun = 0;
1043
+ for (let i = 0; i < line.length; i++) {
1044
+ const ch = line[i]!;
1045
+ if (ch === "%" && backslashRun % 2 === 0) break;
1046
+ out += ch;
1047
+ if (ch === "\\") backslashRun++;
1048
+ else backslashRun = 0;
1049
+ }
1050
+ return out;
1051
+ }).join("\n");
1052
+ }
1053
+
1054
+ function collectStudioLatexBibliographyCandidates(markdown: string): string[] {
1055
+ const stripped = stripStudioLatexComments(markdown);
1056
+ const candidates: string[] = [];
1057
+ const seen = new Set<string>();
1058
+ const pushCandidate = (raw: string) => {
1059
+ let candidate = String(raw ?? "").trim().replace(/^file:/i, "").replace(/^['"]|['"]$/g, "");
1060
+ if (!candidate) return;
1061
+ if (!/\.[A-Za-z0-9]+$/.test(candidate)) candidate += ".bib";
1062
+ if (seen.has(candidate)) return;
1063
+ seen.add(candidate);
1064
+ candidates.push(candidate);
1065
+ };
1066
+
1067
+ for (const match of stripped.matchAll(/\\bibliography\s*\{([^}]+)\}/g)) {
1068
+ const rawList = match[1] ?? "";
1069
+ for (const part of rawList.split(",")) {
1070
+ pushCandidate(part);
1071
+ }
1072
+ }
1073
+
1074
+ for (const match of stripped.matchAll(/\\addbibresource(?:\[[^\]]*\])?\s*\{([^}]+)\}/g)) {
1075
+ pushCandidate(match[1] ?? "");
1076
+ }
1077
+
1078
+ return candidates;
1079
+ }
1080
+
1081
+ function resolveStudioLatexBibliographyPaths(markdown: string, baseDir: string | undefined): string[] {
1082
+ const workingDir = resolveStudioPandocWorkingDir(baseDir);
1083
+ if (!workingDir) return [];
1084
+ const resolvedPaths: string[] = [];
1085
+ const seen = new Set<string>();
1086
+
1087
+ for (const candidate of collectStudioLatexBibliographyCandidates(markdown)) {
1088
+ const expanded = expandHome(candidate);
1089
+ const resolvedPath = isAbsolute(expanded) ? expanded : resolve(workingDir, expanded);
1090
+ try {
1091
+ if (!statSync(resolvedPath).isFile()) continue;
1092
+ if (seen.has(resolvedPath)) continue;
1093
+ seen.add(resolvedPath);
1094
+ resolvedPaths.push(resolvedPath);
1095
+ } catch {
1096
+ // Ignore missing bibliography files; pandoc can still render the document body.
1097
+ }
1098
+ }
1099
+
1100
+ return resolvedPaths;
1101
+ }
1102
+
1103
+ function buildStudioPandocBibliographyArgs(markdown: string, isLatex: boolean | undefined, baseDir: string | undefined): string[] {
1104
+ if (!isLatex) return [];
1105
+ const bibliographyPaths = resolveStudioLatexBibliographyPaths(markdown, baseDir);
1106
+ if (bibliographyPaths.length === 0) return [];
1107
+ return [
1108
+ "--citeproc",
1109
+ ...bibliographyPaths.flatMap((path) => ["--bibliography", path]),
1110
+ ];
1111
+ }
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, "&amp;")
1216
+ .replace(/</g, "&lt;")
1217
+ .replace(/>/g, "&gt;")
1218
+ .replace(/"/g, "&quot;")
1219
+ .replace(/'/g, "&#39;");
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
+
1011
1267
  function readStudioGitDiff(baseDir: string):
1012
1268
  | { ok: true; text: string; label: string }
1013
1269
  | { ok: false; level: "info" | "warning" | "error"; message: string } {
@@ -1590,19 +1846,22 @@ async function preprocessStudioMermaidForPdf(markdown: string, workDir: string):
1590
1846
  };
1591
1847
  }
1592
1848
 
1593
- 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> {
1594
1850
  const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
1851
+ const sourceWithResolvedRefs = isLatex ? preprocessStudioLatexReferences(markdown, sourcePath, resourcePath) : markdown;
1595
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";
1596
- const args = ["-f", inputFormat, "-t", "html5", "--mathml", "--wrap=none"];
1853
+ const bibliographyArgs = buildStudioPandocBibliographyArgs(sourceWithResolvedRefs, isLatex, resourcePath);
1854
+ const args = ["-f", inputFormat, "-t", "html5", "--mathml", "--wrap=none", ...bibliographyArgs];
1597
1855
  if (resourcePath) {
1598
1856
  args.push(`--resource-path=${resourcePath}`);
1599
1857
  // Embed images as data URIs so they render in the browser preview
1600
1858
  args.push("--embed-resources", "--standalone");
1601
1859
  }
1602
- const normalizedMarkdown = isLatex ? markdown : normalizeObsidianImages(normalizeMathDelimiters(markdown));
1860
+ const normalizedMarkdown = isLatex ? sourceWithResolvedRefs : normalizeObsidianImages(normalizeMathDelimiters(sourceWithResolvedRefs));
1861
+ const pandocWorkingDir = resolveStudioPandocWorkingDir(resourcePath);
1603
1862
 
1604
1863
  return await new Promise<string>((resolve, reject) => {
1605
- const child = spawn(pandocCommand, args, { stdio: ["pipe", "pipe", "pipe"] });
1864
+ const child = spawn(pandocCommand, args, { stdio: ["pipe", "pipe", "pipe"], cwd: pandocWorkingDir });
1606
1865
  const stdoutChunks: Buffer[] = [];
1607
1866
  const stderrChunks: Buffer[] = [];
1608
1867
  let settled = false;
@@ -1644,6 +1903,9 @@ async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolea
1644
1903
  const bodyMatch = renderedHtml.match(/<body[^>]*>([\s\S]*)<\/body>/i);
1645
1904
  if (bodyMatch) renderedHtml = bodyMatch[1];
1646
1905
  }
1906
+ if (isLatex) {
1907
+ renderedHtml = decorateStudioLatexRenderedHtml(renderedHtml, sourcePath, resourcePath);
1908
+ }
1647
1909
  succeed(stripMathMlAnnotationTags(renderedHtml));
1648
1910
  return;
1649
1911
  }
@@ -1739,10 +2001,16 @@ async function renderStudioPdfWithPandoc(
1739
2001
  isLatex?: boolean,
1740
2002
  resourcePath?: string,
1741
2003
  editorPdfLanguage?: string,
2004
+ sourcePath?: string,
1742
2005
  ): Promise<{ pdf: Buffer; warning?: string }> {
1743
2006
  const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
1744
2007
  const pdfEngine = process.env.PANDOC_PDF_ENGINE?.trim() || "xelatex";
1745
- const effectiveEditorLanguage = inferStudioPdfLanguage(markdown, editorPdfLanguage);
2008
+ const sourceWithResolvedRefs = isLatex
2009
+ ? injectStudioLatexEquationTags(preprocessStudioLatexReferences(markdown, sourcePath, resourcePath), sourcePath, resourcePath)
2010
+ : markdown;
2011
+ const effectiveEditorLanguage = inferStudioPdfLanguage(sourceWithResolvedRefs, editorPdfLanguage);
2012
+ const pandocWorkingDir = resolveStudioPandocWorkingDir(resourcePath);
2013
+ const bibliographyArgs = buildStudioPandocBibliographyArgs(sourceWithResolvedRefs, isLatex, resourcePath);
1746
2014
 
1747
2015
  const runPandocPdfExport = async (
1748
2016
  inputFormat: string,
@@ -1766,12 +2034,13 @@ async function renderStudioPdfWithPandoc(
1766
2034
  "-V", "urlcolor=blue",
1767
2035
  "-V", "linkcolor=blue",
1768
2036
  "--include-in-header", preamblePath,
2037
+ ...bibliographyArgs,
1769
2038
  ];
1770
2039
  if (resourcePath) args.push(`--resource-path=${resourcePath}`);
1771
2040
 
1772
2041
  try {
1773
2042
  await new Promise<void>((resolve, reject) => {
1774
- const child = spawn(pandocCommand, args, { stdio: ["pipe", "pipe", "pipe"] });
2043
+ const child = spawn(pandocCommand, args, { stdio: ["pipe", "pipe", "pipe"], cwd: pandocWorkingDir });
1775
2044
  const stderrChunks: Buffer[] = [];
1776
2045
  let settled = false;
1777
2046
 
@@ -1838,7 +2107,7 @@ async function renderStudioPdfWithPandoc(
1838
2107
  const inputFormat = isLatex
1839
2108
  ? "latex"
1840
2109
  : "markdown+lists_without_preceding_blankline+tex_math_dollars+tex_math_single_backslash+tex_math_double_backslash+autolink_bare_uris+superscript+subscript-raw_html";
1841
- const normalizedMarkdown = prepareStudioPdfMarkdown(markdown, isLatex, effectiveEditorLanguage);
2110
+ const normalizedMarkdown = prepareStudioPdfMarkdown(sourceWithResolvedRefs, isLatex, effectiveEditorLanguage);
1842
2111
 
1843
2112
  const tempDir = join(tmpdir(), `pi-studio-pdf-${Date.now()}-${randomUUID()}`);
1844
2113
  const preamblePath = join(tempDir, "_pdf_preamble.tex");
@@ -1862,12 +2131,13 @@ async function renderStudioPdfWithPandoc(
1862
2131
  "-V", "urlcolor=blue",
1863
2132
  "-V", "linkcolor=blue",
1864
2133
  "--include-in-header", preamblePath,
2134
+ ...bibliographyArgs,
1865
2135
  ];
1866
2136
  if (resourcePath) args.push(`--resource-path=${resourcePath}`);
1867
2137
 
1868
2138
  try {
1869
2139
  await new Promise<void>((resolve, reject) => {
1870
- const child = spawn(pandocCommand, args, { stdio: ["pipe", "pipe", "pipe"] });
2140
+ const child = spawn(pandocCommand, args, { stdio: ["pipe", "pipe", "pipe"], cwd: pandocWorkingDir });
1871
2141
  const stderrChunks: Buffer[] = [];
1872
2142
  let settled = false;
1873
2143
 
@@ -1998,6 +2268,27 @@ function openUrlInDefaultBrowser(url: string): Promise<void> {
1998
2268
  });
1999
2269
  }
2000
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
+
2001
2292
  function detectLensFromText(text: string): Lens {
2002
2293
  const lines = text.split("\n");
2003
2294
  const fencedCodeBlocks = (text.match(/```[\w-]*\n[\s\S]*?```/g) ?? []).length;
@@ -2947,6 +3238,7 @@ export default function (pi: ExtensionAPI) {
2947
3238
  let serverState: StudioServerState | null = null;
2948
3239
  let activeRequest: ActiveStudioRequest | null = null;
2949
3240
  let lastStudioResponse: LastStudioResponse | null = null;
3241
+ let preparedPdfExports = new Map<string, PreparedStudioPdfExport>();
2950
3242
  let initialStudioDocument: InitialStudioDocument | null = null;
2951
3243
  let studioCwd = process.cwd();
2952
3244
  let lastCommandCtx: ExtensionCommandContext | null = null;
@@ -4008,6 +4300,100 @@ export default function (pi: ExtensionAPI) {
4008
4300
  }
4009
4301
  };
4010
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
+
4011
4397
  const handleRenderPreviewRequest = async (req: IncomingMessage, res: ServerResponse) => {
4012
4398
  let rawBody = "";
4013
4399
  try {
@@ -4054,9 +4440,9 @@ export default function (pi: ExtensionAPI) {
4054
4440
  parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { resourceDir?: unknown }).resourceDir === "string"
4055
4441
  ? (parsedBody as { resourceDir: string }).resourceDir
4056
4442
  : "";
4057
- const resourcePath = sourcePath ? dirname(sourcePath) : (userResourceDir || studioCwd || undefined);
4443
+ const resourcePath = resolveStudioBaseDir(sourcePath || undefined, userResourceDir || undefined, studioCwd);
4058
4444
  const isLatex = /\\documentclass\b|\\begin\{document\}/.test(markdown);
4059
- const html = await renderStudioMarkdownWithPandoc(markdown, isLatex, resourcePath);
4445
+ const html = await renderStudioMarkdownWithPandoc(markdown, isLatex, resourcePath, sourcePath || undefined);
4060
4446
  respondJson(res, 200, { ok: true, html, renderer: "pandoc" });
4061
4447
  } catch (error) {
4062
4448
  const message = error instanceof Error ? error.message : String(error);
@@ -4108,7 +4494,7 @@ export default function (pi: ExtensionAPI) {
4108
4494
  parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { resourceDir?: unknown }).resourceDir === "string"
4109
4495
  ? (parsedBody as { resourceDir: string }).resourceDir
4110
4496
  : "";
4111
- const resourcePath = sourcePath ? dirname(sourcePath) : (userResourceDir || studioCwd || undefined);
4497
+ const resourcePath = resolveStudioBaseDir(sourcePath || undefined, userResourceDir || undefined, studioCwd);
4112
4498
  const requestedIsLatex =
4113
4499
  parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { isLatex?: unknown }).isLatex === "boolean"
4114
4500
  ? (parsedBody as { isLatex: boolean }).isLatex
@@ -4130,24 +4516,29 @@ export default function (pi: ExtensionAPI) {
4130
4516
  const filename = sanitizePdfFilename(requestedFilename || (isLatex ? "studio-latex-preview.pdf" : "studio-preview.pdf"));
4131
4517
 
4132
4518
  try {
4133
- const { pdf, warning } = await renderStudioPdfWithPandoc(markdown, isLatex, resourcePath, editorPdfLanguage);
4134
- const safeAsciiName = filename
4135
- .replace(/[\x00-\x1f\x7f]/g, "")
4136
- .replace(/[;"\\]/g, "_")
4137
- .replace(/\s+/g, " ")
4138
- .trim() || "studio-preview.pdf";
4139
-
4140
- const headers: Record<string, string> = {
4141
- "Content-Type": "application/pdf",
4142
- "Cache-Control": "no-store",
4143
- "X-Content-Type-Options": "nosniff",
4144
- "Content-Disposition": `attachment; filename="${safeAsciiName}"; filename*=UTF-8''${encodeURIComponent(filename)}`,
4145
- "Content-Length": String(pdf.length),
4146
- };
4147
- if (warning) headers["X-Pi-Studio-Export-Warning"] = warning;
4148
-
4149
- res.writeHead(200, headers);
4150
- res.end(pdf);
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
+ });
4151
4542
  } catch (error) {
4152
4543
  const message = error instanceof Error ? error.message : String(error);
4153
4544
  respondJson(res, 500, { ok: false, error: `PDF export failed: ${message}` });
@@ -4264,14 +4655,23 @@ export default function (pi: ExtensionAPI) {
4264
4655
  if (requestUrl.pathname === "/export-pdf") {
4265
4656
  const token = requestUrl.searchParams.get("token") ?? "";
4266
4657
  if (token !== serverState.token) {
4267
- respondJson(res, 403, { ok: false, error: "Invalid or expired studio token. Re-run /studio." });
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
+ }
4268
4664
  return;
4269
4665
  }
4270
4666
 
4271
4667
  const method = (req.method ?? "GET").toUpperCase();
4668
+ if (method === "GET") {
4669
+ handlePreparedPdfDownloadRequest(requestUrl, res);
4670
+ return;
4671
+ }
4272
4672
  if (method !== "POST") {
4273
- res.setHeader("Allow", "POST");
4274
- 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." });
4275
4675
  return;
4276
4676
  }
4277
4677
 
@@ -4437,6 +4837,7 @@ export default function (pi: ExtensionAPI) {
4437
4837
  if (!serverState) return;
4438
4838
  clearActiveRequest();
4439
4839
  clearPendingStudioCompletion();
4840
+ clearPreparedPdfExports();
4440
4841
  clearCompactionState();
4441
4842
  closeAllClients(1001, "Server shutting down");
4442
4843
 
@@ -4455,6 +4856,7 @@ export default function (pi: ExtensionAPI) {
4455
4856
  const rotateToken = () => {
4456
4857
  if (!serverState) return;
4457
4858
  serverState.token = createSessionToken();
4859
+ clearPreparedPdfExports();
4458
4860
  closeAllClients(4001, "Session invalidated");
4459
4861
  broadcastState();
4460
4862
  };
@@ -4469,6 +4871,7 @@ export default function (pi: ExtensionAPI) {
4469
4871
  clearCompactionState();
4470
4872
  agentBusy = false;
4471
4873
  clearPendingStudioCompletion();
4874
+ clearPreparedPdfExports();
4472
4875
  refreshRuntimeMetadata({ cwd: ctx.cwd, model: ctx.model });
4473
4876
  refreshContextUsage(ctx);
4474
4877
  emitDebugEvent("session_start", {
@@ -4488,6 +4891,7 @@ export default function (pi: ExtensionAPI) {
4488
4891
  hydrateLatestAssistant(ctx.sessionManager.getBranch());
4489
4892
  agentBusy = false;
4490
4893
  clearPendingStudioCompletion();
4894
+ clearPreparedPdfExports();
4491
4895
  refreshRuntimeMetadata({ cwd: ctx.cwd, model: ctx.model });
4492
4896
  refreshContextUsage(ctx);
4493
4897
  emitDebugEvent("session_switch", {
@@ -4708,6 +5112,7 @@ export default function (pi: ExtensionAPI) {
4708
5112
  lastCommandCtx = null;
4709
5113
  agentBusy = false;
4710
5114
  clearPendingStudioCompletion();
5115
+ clearPreparedPdfExports();
4711
5116
  clearCompactionState();
4712
5117
  setTerminalActivity("idle");
4713
5118
  await stopServer();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.5.19",
3
+ "version": "0.5.21",
4
4
  "description": "Browser GUI for structured critique workflows in pi",
5
5
  "type": "module",
6
6
  "license": "MIT",