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 +14 -0
- package/client/studio-client.js +59 -9
- package/client/studio.css +32 -0
- package/index.ts +440 -35
- package/package.json +1 -1
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
|
package/client/studio-client.js
CHANGED
|
@@ -163,7 +163,7 @@
|
|
|
163
163
|
};
|
|
164
164
|
let activePane = "left";
|
|
165
165
|
let paneFocusTarget = "off";
|
|
166
|
-
const EDITOR_HIGHLIGHT_MAX_CHARS =
|
|
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:
|
|
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
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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, "&")
|
|
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
|
+
|
|
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
|
|
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 ?
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
4135
|
-
|
|
4136
|
-
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
|
|
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
|
-
|
|
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();
|