@zhachory1/mewrite-markdown-preview 0.9.6
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/README.md +23 -0
- package/client/annotation-helpers.js +543 -0
- package/index.ts +3240 -0
- package/package.json +55 -0
- package/shared/annotation-scanner.js +393 -0
- package/tsconfig.json +13 -0
package/index.ts
ADDED
|
@@ -0,0 +1,3240 @@
|
|
|
1
|
+
import { BorderedLoader, DynamicBorder, keyHint } from "@zhachory1/mewrite-code";
|
|
2
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, Theme } from "@zhachory1/mewrite-code";
|
|
3
|
+
import {
|
|
4
|
+
allocateImageId,
|
|
5
|
+
Container,
|
|
6
|
+
deleteKittyImage,
|
|
7
|
+
getCapabilities,
|
|
8
|
+
Image,
|
|
9
|
+
matchesKey,
|
|
10
|
+
type SelectItem,
|
|
11
|
+
SelectList,
|
|
12
|
+
Spacer,
|
|
13
|
+
Text,
|
|
14
|
+
type TUI,
|
|
15
|
+
} from "@zhachory1/mewrite-tui";
|
|
16
|
+
import { spawn } from "node:child_process";
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
19
|
+
import { mkdir, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promises";
|
|
20
|
+
import { homedir, tmpdir } from "node:os";
|
|
21
|
+
import { basename, dirname, extname, join, resolve as resolvePath } from "node:path";
|
|
22
|
+
import { pathToFileURL } from "node:url";
|
|
23
|
+
import puppeteer from "puppeteer-core";
|
|
24
|
+
import {
|
|
25
|
+
hasMarkdownAnnotationMarkers,
|
|
26
|
+
isAnnotationWordChar,
|
|
27
|
+
normalizeAnnotationText,
|
|
28
|
+
prepareMarkdownForPandocPreview,
|
|
29
|
+
readAnnotationProtectedTokenAt,
|
|
30
|
+
replaceInlineAnnotationMarkers,
|
|
31
|
+
transformMarkdownOutsideFences,
|
|
32
|
+
} from "./shared/annotation-scanner.js";
|
|
33
|
+
|
|
34
|
+
const CACHE_DIR = join(homedir(), ".pi", "cache", "markdown-preview");
|
|
35
|
+
const MERMAID_PDF_CACHE_DIR = join(CACHE_DIR, "mermaid-pdf");
|
|
36
|
+
const PREVIEW_ANNOTATION_PLACEHOLDER_PREFIX = "PIMDPREVIEWANNOT";
|
|
37
|
+
const ANNOTATION_HELPERS_SOURCE = readFileSync(new URL("./client/annotation-helpers.js", import.meta.url), "utf-8");
|
|
38
|
+
const RENDER_VERSION = "v21";
|
|
39
|
+
const VIEWPORT_WIDTH_PX = 1200;
|
|
40
|
+
const PAGE_HEIGHT_PX = 2200;
|
|
41
|
+
const MAX_RENDER_HEIGHT_PX = 66000; // PAGE_HEIGHT_PX * 30
|
|
42
|
+
|
|
43
|
+
type ThemeMode = "dark" | "light";
|
|
44
|
+
type PreviewTarget = "terminal" | "browser" | "pdf";
|
|
45
|
+
|
|
46
|
+
interface PreviewPalette {
|
|
47
|
+
bg: string;
|
|
48
|
+
card: string;
|
|
49
|
+
border: string;
|
|
50
|
+
text: string;
|
|
51
|
+
muted: string;
|
|
52
|
+
codeBg: string;
|
|
53
|
+
link: string;
|
|
54
|
+
syntaxComment: string;
|
|
55
|
+
syntaxKeyword: string;
|
|
56
|
+
syntaxFunction: string;
|
|
57
|
+
syntaxVariable: string;
|
|
58
|
+
syntaxString: string;
|
|
59
|
+
syntaxNumber: string;
|
|
60
|
+
syntaxType: string;
|
|
61
|
+
syntaxOperator: string;
|
|
62
|
+
syntaxPunctuation: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface PreviewStyle {
|
|
66
|
+
themeMode: ThemeMode;
|
|
67
|
+
palette: PreviewPalette;
|
|
68
|
+
cacheKey: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface PreviewPage {
|
|
72
|
+
base64Png: string;
|
|
73
|
+
truncatedHeight: boolean;
|
|
74
|
+
index: number;
|
|
75
|
+
total: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface RenderPreviewResult {
|
|
79
|
+
pages: PreviewPage[];
|
|
80
|
+
themeMode: ThemeMode;
|
|
81
|
+
truncatedPages: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface CachedPage {
|
|
85
|
+
buffer: Buffer;
|
|
86
|
+
truncatedHeight: boolean;
|
|
87
|
+
pageCount?: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface RenderWithLoaderResult {
|
|
91
|
+
preview: RenderPreviewResult;
|
|
92
|
+
supportsCustomUi: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface PreviewAnnotationPlaceholder {
|
|
96
|
+
token: string;
|
|
97
|
+
text: string;
|
|
98
|
+
title: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const DARK_PREVIEW_PALETTE: PreviewPalette = {
|
|
102
|
+
bg: "#0f1117",
|
|
103
|
+
card: "#171b24",
|
|
104
|
+
border: "#2b3343",
|
|
105
|
+
text: "#e6edf3",
|
|
106
|
+
muted: "#9da7b5",
|
|
107
|
+
codeBg: "#13171e",
|
|
108
|
+
link: "#58a6ff",
|
|
109
|
+
syntaxComment: "#6A9955",
|
|
110
|
+
syntaxKeyword: "#569CD6",
|
|
111
|
+
syntaxFunction: "#DCDCAA",
|
|
112
|
+
syntaxVariable: "#9CDCFE",
|
|
113
|
+
syntaxString: "#CE9178",
|
|
114
|
+
syntaxNumber: "#B5CEA8",
|
|
115
|
+
syntaxType: "#4EC9B0",
|
|
116
|
+
syntaxOperator: "#D4D4D4",
|
|
117
|
+
syntaxPunctuation: "#D4D4D4",
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const LIGHT_PREVIEW_PALETTE: PreviewPalette = {
|
|
121
|
+
bg: "#f5f7fb",
|
|
122
|
+
card: "#ffffff",
|
|
123
|
+
border: "#d0d7de",
|
|
124
|
+
text: "#1f2328",
|
|
125
|
+
muted: "#57606a",
|
|
126
|
+
codeBg: "#f7f7f7",
|
|
127
|
+
link: "#0969da",
|
|
128
|
+
syntaxComment: "#008000",
|
|
129
|
+
syntaxKeyword: "#0000FF",
|
|
130
|
+
syntaxFunction: "#795E26",
|
|
131
|
+
syntaxVariable: "#001080",
|
|
132
|
+
syntaxString: "#A31515",
|
|
133
|
+
syntaxNumber: "#098658",
|
|
134
|
+
syntaxType: "#267F99",
|
|
135
|
+
syntaxOperator: "#000000",
|
|
136
|
+
syntaxPunctuation: "#000000",
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
function getThemeMode(theme?: Theme): ThemeMode {
|
|
140
|
+
const name = (theme?.name ?? "").toLowerCase();
|
|
141
|
+
return name.includes("light") ? "light" : "dark";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function toHexByte(value: number): string {
|
|
145
|
+
const clamped = Math.max(0, Math.min(255, Math.round(value)));
|
|
146
|
+
return clamped.toString(16).padStart(2, "0");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function rgbToHex(r: number, g: number, b: number): string {
|
|
150
|
+
return `#${toHexByte(r)}${toHexByte(g)}${toHexByte(b)}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function hexToRgb(hex: string): { r: number; g: number; b: number } | undefined {
|
|
154
|
+
const m = hex.replace("#", "").match(/^([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
|
|
155
|
+
if (!m) return undefined;
|
|
156
|
+
return { r: parseInt(m[1]!, 16), g: parseInt(m[2]!, 16), b: parseInt(m[3]!, 16) };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function adjustCodeBg(cardHex: string, themeMode: ThemeMode): string {
|
|
160
|
+
const rgb = hexToRgb(cardHex);
|
|
161
|
+
if (!rgb) return cardHex;
|
|
162
|
+
if (themeMode === "dark") {
|
|
163
|
+
// Slightly darker than card
|
|
164
|
+
const f = 0.85;
|
|
165
|
+
return rgbToHex(Math.round(rgb.r * f), Math.round(rgb.g * f), Math.round(rgb.b * f));
|
|
166
|
+
}
|
|
167
|
+
// Light: slightly darker than card (towards gray)
|
|
168
|
+
const f = 0.97;
|
|
169
|
+
return rgbToHex(Math.round(rgb.r * f), Math.round(rgb.g * f), Math.round(rgb.b * f));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function xterm256ToHex(index: number): string {
|
|
173
|
+
const basic16 = [
|
|
174
|
+
"#000000",
|
|
175
|
+
"#800000",
|
|
176
|
+
"#008000",
|
|
177
|
+
"#808000",
|
|
178
|
+
"#000080",
|
|
179
|
+
"#800080",
|
|
180
|
+
"#008080",
|
|
181
|
+
"#c0c0c0",
|
|
182
|
+
"#808080",
|
|
183
|
+
"#ff0000",
|
|
184
|
+
"#00ff00",
|
|
185
|
+
"#ffff00",
|
|
186
|
+
"#0000ff",
|
|
187
|
+
"#ff00ff",
|
|
188
|
+
"#00ffff",
|
|
189
|
+
"#ffffff",
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
if (index >= 0 && index < basic16.length) {
|
|
193
|
+
return basic16[index]!;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (index >= 16 && index <= 231) {
|
|
197
|
+
const i = index - 16;
|
|
198
|
+
const r = Math.floor(i / 36);
|
|
199
|
+
const g = Math.floor((i % 36) / 6);
|
|
200
|
+
const b = i % 6;
|
|
201
|
+
const values = [0, 95, 135, 175, 215, 255];
|
|
202
|
+
return rgbToHex(values[r]!, values[g]!, values[b]!);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (index >= 232 && index <= 255) {
|
|
206
|
+
const gray = 8 + (index - 232) * 10;
|
|
207
|
+
return rgbToHex(gray, gray, gray);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return "#000000";
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function ansiColorToCss(ansi: string): string | undefined {
|
|
214
|
+
const trueColorMatch = ansi.match(/\x1b\[(?:38|48);2;(\d{1,3});(\d{1,3});(\d{1,3})m/);
|
|
215
|
+
if (trueColorMatch) {
|
|
216
|
+
return rgbToHex(Number(trueColorMatch[1]), Number(trueColorMatch[2]), Number(trueColorMatch[3]));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const indexedMatch = ansi.match(/\x1b\[(?:38|48);5;(\d{1,3})m/);
|
|
220
|
+
if (indexedMatch) {
|
|
221
|
+
return xterm256ToHex(Number(indexedMatch[1]));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function safeThemeColor(getter: () => string): string | undefined {
|
|
228
|
+
try {
|
|
229
|
+
return ansiColorToCss(getter());
|
|
230
|
+
} catch {
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function getPreviewStyle(theme?: Theme): PreviewStyle {
|
|
236
|
+
const themeMode = getThemeMode(theme);
|
|
237
|
+
const fallback = themeMode === "dark" ? DARK_PREVIEW_PALETTE : LIGHT_PREVIEW_PALETTE;
|
|
238
|
+
|
|
239
|
+
if (!theme) {
|
|
240
|
+
return {
|
|
241
|
+
themeMode,
|
|
242
|
+
palette: fallback,
|
|
243
|
+
cacheKey: `${themeMode}|fallback`,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const card = safeThemeColor(() => theme.getBgAnsi("toolPendingBg")) ?? fallback.card;
|
|
248
|
+
|
|
249
|
+
const palette: PreviewPalette = {
|
|
250
|
+
bg: safeThemeColor(() => theme.getBgAnsi("customMessageBg")) ?? fallback.bg,
|
|
251
|
+
card,
|
|
252
|
+
border: safeThemeColor(() => theme.getFgAnsi("border")) ?? fallback.border,
|
|
253
|
+
text: safeThemeColor(() => theme.getFgAnsi("text")) ?? fallback.text,
|
|
254
|
+
muted: safeThemeColor(() => theme.getFgAnsi("muted")) ?? fallback.muted,
|
|
255
|
+
codeBg: adjustCodeBg(card, themeMode),
|
|
256
|
+
link:
|
|
257
|
+
safeThemeColor(() => theme.getFgAnsi("mdLink")) ?? safeThemeColor(() => theme.getFgAnsi("accent")) ?? fallback.link,
|
|
258
|
+
syntaxComment: safeThemeColor(() => theme.getFgAnsi("syntaxComment")) ?? fallback.syntaxComment,
|
|
259
|
+
syntaxKeyword: safeThemeColor(() => theme.getFgAnsi("syntaxKeyword")) ?? fallback.syntaxKeyword,
|
|
260
|
+
syntaxFunction: safeThemeColor(() => theme.getFgAnsi("syntaxFunction")) ?? fallback.syntaxFunction,
|
|
261
|
+
syntaxVariable: safeThemeColor(() => theme.getFgAnsi("syntaxVariable")) ?? fallback.syntaxVariable,
|
|
262
|
+
syntaxString: safeThemeColor(() => theme.getFgAnsi("syntaxString")) ?? fallback.syntaxString,
|
|
263
|
+
syntaxNumber: safeThemeColor(() => theme.getFgAnsi("syntaxNumber")) ?? fallback.syntaxNumber,
|
|
264
|
+
syntaxType: safeThemeColor(() => theme.getFgAnsi("syntaxType")) ?? fallback.syntaxType,
|
|
265
|
+
syntaxOperator: safeThemeColor(() => theme.getFgAnsi("syntaxOperator")) ?? fallback.syntaxOperator,
|
|
266
|
+
syntaxPunctuation: safeThemeColor(() => theme.getFgAnsi("syntaxPunctuation")) ?? fallback.syntaxPunctuation,
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const cacheKey = [
|
|
270
|
+
themeMode,
|
|
271
|
+
palette.bg,
|
|
272
|
+
palette.card,
|
|
273
|
+
palette.border,
|
|
274
|
+
palette.text,
|
|
275
|
+
palette.muted,
|
|
276
|
+
palette.codeBg,
|
|
277
|
+
palette.link,
|
|
278
|
+
palette.syntaxComment,
|
|
279
|
+
palette.syntaxKeyword,
|
|
280
|
+
palette.syntaxFunction,
|
|
281
|
+
palette.syntaxVariable,
|
|
282
|
+
palette.syntaxString,
|
|
283
|
+
palette.syntaxNumber,
|
|
284
|
+
palette.syntaxType,
|
|
285
|
+
palette.syntaxOperator,
|
|
286
|
+
palette.syntaxPunctuation,
|
|
287
|
+
].join("|");
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
themeMode,
|
|
291
|
+
palette,
|
|
292
|
+
cacheKey,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
interface AssistantMessage {
|
|
297
|
+
index: number;
|
|
298
|
+
markdown: string;
|
|
299
|
+
preview: string;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function getAssistantMessages(ctx: ExtensionCommandContext): AssistantMessage[] {
|
|
303
|
+
const branch = ctx.sessionManager.getBranch();
|
|
304
|
+
const messages: AssistantMessage[] = [];
|
|
305
|
+
let messageIndex = 0;
|
|
306
|
+
|
|
307
|
+
for (const entry of branch) {
|
|
308
|
+
if (entry.type !== "message") continue;
|
|
309
|
+
|
|
310
|
+
const msg = entry.message;
|
|
311
|
+
if (!("role" in msg) || msg.role !== "assistant") continue;
|
|
312
|
+
|
|
313
|
+
const textBlocks = msg.content.filter((c): c is { type: "text"; text: string } => c.type === "text" && !!c.text.trim());
|
|
314
|
+
if (textBlocks.length === 0) continue;
|
|
315
|
+
|
|
316
|
+
const markdown = textBlocks.map((c) => c.text).join("\n\n");
|
|
317
|
+
const firstLine = markdown.split("\n").find((l) => l.trim().length > 0) ?? "";
|
|
318
|
+
const preview = firstLine.replace(/^#+\s*/, "").slice(0, 80);
|
|
319
|
+
messages.push({ index: messageIndex, markdown, preview });
|
|
320
|
+
messageIndex++;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return messages;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function getLastAssistantMarkdown(ctx: ExtensionCommandContext): string | undefined {
|
|
327
|
+
const messages = getAssistantMessages(ctx);
|
|
328
|
+
return messages.length > 0 ? messages[messages.length - 1]!.markdown : undefined;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function isLikelyMathExpression(expr: string): boolean {
|
|
332
|
+
const content = expr.trim();
|
|
333
|
+
if (content.length === 0) return false;
|
|
334
|
+
|
|
335
|
+
if (/\\[a-zA-Z]+/.test(content)) return true; // LaTeX commands like \frac, \alpha
|
|
336
|
+
if (/[0-9]/.test(content)) return true;
|
|
337
|
+
if (/[=+\-*/^_<>≤≥±×÷]/u.test(content)) return true;
|
|
338
|
+
if (/[{}]/.test(content)) return true;
|
|
339
|
+
if (/[α-ωΑ-Ω]/u.test(content)) return true;
|
|
340
|
+
if (/^[A-Za-z]$/.test(content)) return true; // single-variable forms like \(x\)
|
|
341
|
+
|
|
342
|
+
// Plain words (e.g. escaped markdown like \[not a link\]) are not math.
|
|
343
|
+
if (/^[A-Za-z][A-Za-z\s'".,:;!?-]*[A-Za-z]$/.test(content)) return false;
|
|
344
|
+
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function collapseDisplayMathContent(expr: string): string {
|
|
349
|
+
let content = expr.trim();
|
|
350
|
+
if (/\\begin\{[^}]+\}|\\end\{[^}]+\}/.test(content)) {
|
|
351
|
+
return content;
|
|
352
|
+
}
|
|
353
|
+
if (content.includes("\\\\") || content.includes("\n")) {
|
|
354
|
+
content = content.replace(/\\\\\s*/g, " ");
|
|
355
|
+
content = content.replace(/\s*\n\s*/g, " ");
|
|
356
|
+
content = content.replace(/\s{2,}/g, " ").trim();
|
|
357
|
+
}
|
|
358
|
+
return content;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function normalizeMathDelimitersInSegment(markdown: string): string {
|
|
362
|
+
let normalized = markdown.replace(/\\\[\s*([\s\S]*?)\s*\\\]/g, (match, expr: string) => {
|
|
363
|
+
const content = expr.trim();
|
|
364
|
+
if (!isLikelyMathExpression(content)) return match;
|
|
365
|
+
return content.length > 0 ? `$$\n${content}\n$$` : "$$\n$$";
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
normalized = normalized.replace(/\\\(([\s\S]*?)\\\)/g, (match, expr: string) => {
|
|
369
|
+
if (!isLikelyMathExpression(expr)) return match;
|
|
370
|
+
return `$${expr}$`;
|
|
371
|
+
});
|
|
372
|
+
return normalized;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function normalizeMathDelimiters(markdown: string): string {
|
|
376
|
+
const lines = markdown.split("\n");
|
|
377
|
+
const out: string[] = [];
|
|
378
|
+
let plainBuffer: string[] = [];
|
|
379
|
+
let inFence = false;
|
|
380
|
+
let fenceChar: "`" | "~" | undefined;
|
|
381
|
+
let fenceLength = 0;
|
|
382
|
+
|
|
383
|
+
const flushPlain = () => {
|
|
384
|
+
if (plainBuffer.length === 0) return;
|
|
385
|
+
out.push(normalizeMathDelimitersInSegment(plainBuffer.join("\n")));
|
|
386
|
+
plainBuffer = [];
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
for (const line of lines) {
|
|
390
|
+
const trimmed = line.trimStart();
|
|
391
|
+
const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/);
|
|
392
|
+
|
|
393
|
+
if (fenceMatch) {
|
|
394
|
+
const marker = fenceMatch[1]!;
|
|
395
|
+
const markerChar = marker[0] as "`" | "~";
|
|
396
|
+
const markerLength = marker.length;
|
|
397
|
+
|
|
398
|
+
if (!inFence) {
|
|
399
|
+
flushPlain();
|
|
400
|
+
inFence = true;
|
|
401
|
+
fenceChar = markerChar;
|
|
402
|
+
fenceLength = markerLength;
|
|
403
|
+
out.push(line);
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (fenceChar === markerChar && markerLength >= fenceLength) {
|
|
408
|
+
inFence = false;
|
|
409
|
+
fenceChar = undefined;
|
|
410
|
+
fenceLength = 0;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
out.push(line);
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (inFence) {
|
|
418
|
+
out.push(line);
|
|
419
|
+
} else {
|
|
420
|
+
plainBuffer.push(line);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
flushPlain();
|
|
425
|
+
return out.join("\n");
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function normalizeSubSupTagsInSegment(markdown: string): string {
|
|
429
|
+
let normalized = markdown.replace(/<sub>([^<\n]+)<\/sub>/gi, (_match, content: string) => `~${content}~`);
|
|
430
|
+
normalized = normalized.replace(/<sup>([^<\n]+)<\/sup>/gi, (_match, content: string) => `^${content}^`);
|
|
431
|
+
return normalized;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function normalizeSubSupTags(markdown: string): string {
|
|
435
|
+
const lines = markdown.split("\n");
|
|
436
|
+
const out: string[] = [];
|
|
437
|
+
let plainBuffer: string[] = [];
|
|
438
|
+
let inFence = false;
|
|
439
|
+
let fenceChar: "`" | "~" | undefined;
|
|
440
|
+
let fenceLength = 0;
|
|
441
|
+
|
|
442
|
+
const flushPlain = () => {
|
|
443
|
+
if (plainBuffer.length === 0) return;
|
|
444
|
+
out.push(normalizeSubSupTagsInSegment(plainBuffer.join("\n")));
|
|
445
|
+
plainBuffer = [];
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
for (const line of lines) {
|
|
449
|
+
const trimmed = line.trimStart();
|
|
450
|
+
const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/);
|
|
451
|
+
|
|
452
|
+
if (fenceMatch) {
|
|
453
|
+
const marker = fenceMatch[1]!;
|
|
454
|
+
const markerChar = marker[0] as "`" | "~";
|
|
455
|
+
const markerLength = marker.length;
|
|
456
|
+
|
|
457
|
+
if (!inFence) {
|
|
458
|
+
flushPlain();
|
|
459
|
+
inFence = true;
|
|
460
|
+
fenceChar = markerChar;
|
|
461
|
+
fenceLength = markerLength;
|
|
462
|
+
out.push(line);
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (fenceChar === markerChar && markerLength >= fenceLength) {
|
|
467
|
+
inFence = false;
|
|
468
|
+
fenceChar = undefined;
|
|
469
|
+
fenceLength = 0;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
out.push(line);
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (inFence) {
|
|
477
|
+
out.push(line);
|
|
478
|
+
} else {
|
|
479
|
+
plainBuffer.push(line);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
flushPlain();
|
|
484
|
+
return out.join("\n");
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function escapeLatexTextFragment(text: string): string {
|
|
488
|
+
return String(text ?? "")
|
|
489
|
+
.replace(/\\/g, "\\textbackslash{}")
|
|
490
|
+
.replace(/([{}%#$&_])/g, "\\$1")
|
|
491
|
+
.replace(/~/g, "\\textasciitilde{}")
|
|
492
|
+
.replace(/\^/g, "\\textasciicircum{}");
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function getMathPattern(): RegExp {
|
|
496
|
+
return /\\\(([\s\S]*?)\\\)|\\\[([\s\S]*?)\\\]|\$\$([\s\S]*?)\$\$|\$([^$\n]+?)\$/g;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function normalizeLatexAnnotationText(text: string): string {
|
|
500
|
+
return normalizeAnnotationText(text);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function escapeLatexText(text: string): string {
|
|
504
|
+
const normalized = normalizeLatexAnnotationText(text);
|
|
505
|
+
if (!normalized) return "";
|
|
506
|
+
|
|
507
|
+
const mathPattern = getMathPattern();
|
|
508
|
+
let out = "";
|
|
509
|
+
let lastIndex = 0;
|
|
510
|
+
let match: RegExpExecArray | null;
|
|
511
|
+
|
|
512
|
+
while ((match = mathPattern.exec(normalized)) !== null) {
|
|
513
|
+
const token = match[0] ?? "";
|
|
514
|
+
const start = match.index;
|
|
515
|
+
if (start > lastIndex) {
|
|
516
|
+
out += escapeLatexTextFragment(normalized.slice(lastIndex, start));
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const inlineParenExpr = match[1];
|
|
520
|
+
const displayBracketExpr = match[2];
|
|
521
|
+
const displayDollarExpr = match[3];
|
|
522
|
+
const inlineDollarExpr = match[4];
|
|
523
|
+
let mathLatex = "";
|
|
524
|
+
|
|
525
|
+
if (typeof inlineParenExpr === "string" && isLikelyMathExpression(inlineParenExpr)) {
|
|
526
|
+
const content = inlineParenExpr.trim();
|
|
527
|
+
mathLatex = content ? `\\(${content}\\)` : "";
|
|
528
|
+
} else if (typeof displayBracketExpr === "string" && isLikelyMathExpression(displayBracketExpr)) {
|
|
529
|
+
const content = collapseDisplayMathContent(displayBracketExpr);
|
|
530
|
+
mathLatex = content ? `\\(${content}\\)` : "";
|
|
531
|
+
} else if (typeof displayDollarExpr === "string" && isLikelyMathExpression(displayDollarExpr)) {
|
|
532
|
+
const content = collapseDisplayMathContent(displayDollarExpr);
|
|
533
|
+
mathLatex = content ? `\\(${content}\\)` : "";
|
|
534
|
+
} else if (typeof inlineDollarExpr === "string" && isLikelyMathExpression(inlineDollarExpr)) {
|
|
535
|
+
const content = inlineDollarExpr.trim();
|
|
536
|
+
mathLatex = content ? `\\(${content}\\)` : "";
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
out += mathLatex || escapeLatexTextFragment(token);
|
|
540
|
+
lastIndex = start + token.length;
|
|
541
|
+
if (token.length === 0) {
|
|
542
|
+
mathPattern.lastIndex += 1;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (lastIndex < normalized.length) {
|
|
547
|
+
out += escapeLatexTextFragment(normalized.slice(lastIndex));
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
return out.trim();
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function renderAnnotationCodeSpanPdfLatex(rawToken: string): string {
|
|
554
|
+
const raw = String(rawToken ?? "");
|
|
555
|
+
if (!raw || raw[0] !== "`") return escapeLatexTextFragment(raw);
|
|
556
|
+
|
|
557
|
+
let fenceLength = 1;
|
|
558
|
+
while (raw[fenceLength] === "`") fenceLength += 1;
|
|
559
|
+
const fence = "`".repeat(fenceLength);
|
|
560
|
+
if (raw.length < fenceLength * 2 || raw.slice(raw.length - fenceLength) !== fence) {
|
|
561
|
+
return escapeLatexTextFragment(raw);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
return `\\texttt{${escapeLatexTextFragment(raw.slice(fenceLength, raw.length - fenceLength))}}`;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function canOpenAnnotationEmphasisDelimiter(source: string, startIndex: number, delimiter: string): boolean {
|
|
568
|
+
if (source.slice(startIndex, startIndex + delimiter.length) !== delimiter) return false;
|
|
569
|
+
const prev = startIndex > 0 ? source[startIndex - 1] ?? "" : "";
|
|
570
|
+
const next = source[startIndex + delimiter.length] ?? "";
|
|
571
|
+
if (!next || /\s/.test(next)) return false;
|
|
572
|
+
return !isAnnotationWordChar(prev);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function canCloseAnnotationEmphasisDelimiter(source: string, startIndex: number, delimiter: string): boolean {
|
|
576
|
+
if (source.slice(startIndex, startIndex + delimiter.length) !== delimiter) return false;
|
|
577
|
+
const prev = startIndex > 0 ? source[startIndex - 1] ?? "" : "";
|
|
578
|
+
const next = source[startIndex + delimiter.length] ?? "";
|
|
579
|
+
if (!prev || /\s/.test(prev)) return false;
|
|
580
|
+
return !isAnnotationWordChar(next);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function renderAnnotationPdfLatexContent(text: string): string {
|
|
584
|
+
const source = String(text ?? "");
|
|
585
|
+
let out = "";
|
|
586
|
+
let plainStart = 0;
|
|
587
|
+
let index = 0;
|
|
588
|
+
|
|
589
|
+
while (index < source.length) {
|
|
590
|
+
const token = readAnnotationProtectedTokenAt(source, index);
|
|
591
|
+
if (!token) {
|
|
592
|
+
index += 1;
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (index > plainStart) {
|
|
597
|
+
out += renderAnnotationPlainTextPdfLatex(source.slice(plainStart, index));
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (token.type === "code") {
|
|
601
|
+
out += renderAnnotationCodeSpanPdfLatex(token.raw);
|
|
602
|
+
} else if (token.type === "math") {
|
|
603
|
+
out += escapeLatexText(token.raw);
|
|
604
|
+
} else {
|
|
605
|
+
out += escapeLatexTextFragment(token.raw);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
index = token.end;
|
|
609
|
+
plainStart = index;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
if (plainStart < source.length) {
|
|
613
|
+
out += renderAnnotationPlainTextPdfLatex(source.slice(plainStart));
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
return out;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function readAnnotationPdfEmphasisSpanAt(source: string, startIndex: number, delimiter: string, commandName: string): { end: number; latex: string } | null {
|
|
620
|
+
if (!canOpenAnnotationEmphasisDelimiter(source, startIndex, delimiter)) return null;
|
|
621
|
+
|
|
622
|
+
let index = startIndex + delimiter.length;
|
|
623
|
+
while (index < source.length) {
|
|
624
|
+
if (source[index] === "\\") {
|
|
625
|
+
index = Math.min(source.length, index + 2);
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const protectedToken = readAnnotationProtectedTokenAt(source, index);
|
|
630
|
+
if (protectedToken) {
|
|
631
|
+
index = protectedToken.end;
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
if (canCloseAnnotationEmphasisDelimiter(source, index, delimiter)) {
|
|
636
|
+
const inner = source.slice(startIndex + delimiter.length, index);
|
|
637
|
+
return {
|
|
638
|
+
end: index + delimiter.length,
|
|
639
|
+
latex: `\\${commandName}{${renderAnnotationPdfLatexContent(inner)}}`,
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
index += 1;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
return null;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function renderAnnotationPlainTextPdfLatex(text: string): string {
|
|
650
|
+
const source = String(text ?? "");
|
|
651
|
+
let out = "";
|
|
652
|
+
let index = 0;
|
|
653
|
+
|
|
654
|
+
while (index < source.length) {
|
|
655
|
+
const strongMatch = readAnnotationPdfEmphasisSpanAt(source, index, "**", "textbf")
|
|
656
|
+
?? readAnnotationPdfEmphasisSpanAt(source, index, "__", "textbf");
|
|
657
|
+
if (strongMatch) {
|
|
658
|
+
out += strongMatch.latex;
|
|
659
|
+
index = strongMatch.end;
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const emphasisMatch = readAnnotationPdfEmphasisSpanAt(source, index, "*", "emph")
|
|
664
|
+
?? readAnnotationPdfEmphasisSpanAt(source, index, "_", "emph");
|
|
665
|
+
if (emphasisMatch) {
|
|
666
|
+
out += emphasisMatch.latex;
|
|
667
|
+
index = emphasisMatch.end;
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
out += escapeLatexTextFragment(source[index] ?? "");
|
|
672
|
+
index += 1;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
return out;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function renderAnnotationPdfLatex(text: string): string {
|
|
679
|
+
const normalized = normalizeAnnotationText(text);
|
|
680
|
+
if (!normalized) return "";
|
|
681
|
+
return renderAnnotationPdfLatexContent(normalized).trim();
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function replaceAnnotationMarkersForPdfInSegment(text: string): string {
|
|
685
|
+
return replaceInlineAnnotationMarkers(
|
|
686
|
+
String(text ?? ""),
|
|
687
|
+
(marker: { body: string }) => {
|
|
688
|
+
const cleaned = renderAnnotationPdfLatex(marker.body);
|
|
689
|
+
if (!cleaned) return "";
|
|
690
|
+
return `\\piannotation{${cleaned}}`;
|
|
691
|
+
},
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function highlightAnnotationMarkersForPdf(markdown: string): string {
|
|
696
|
+
if (!hasMarkdownAnnotationMarkers(markdown)) return String(markdown ?? "");
|
|
697
|
+
return transformMarkdownOutsideFences(markdown, (segment: string) => replaceAnnotationMarkersForPdfInSegment(segment));
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function formatMarkdownImageDestination(rawPath: string): string {
|
|
701
|
+
const path = rawPath.trim();
|
|
702
|
+
if (!path) return "";
|
|
703
|
+
const unwrapped = path.startsWith("<") && path.endsWith(">") ? path.slice(1, -1).trim() : path;
|
|
704
|
+
// Angle brackets keep markdown image destinations valid for spaces/parentheses.
|
|
705
|
+
if (/[\s<>()]/.test(unwrapped)) return `<${unwrapped}>`;
|
|
706
|
+
return unwrapped;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function normalizeObsidianImages(markdown: string): string {
|
|
710
|
+
// Convert ![[path|alt]] and ![[path]] to standard markdown 
|
|
711
|
+
return markdown
|
|
712
|
+
.replace(/!\[\[([^|\]]+)\|([^\]]+)\]\]/g, (_match, path: string, alt: string) => {
|
|
713
|
+
return `})`;
|
|
714
|
+
})
|
|
715
|
+
.replace(/!\[\[([^\]]+)\]\]/g, (_match, path: string) => {
|
|
716
|
+
return `})`;
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function extractLikelyImageDestination(rawDestination: string): string {
|
|
721
|
+
const trimmed = rawDestination.trim();
|
|
722
|
+
if (!trimmed) return "";
|
|
723
|
+
if (trimmed.startsWith("<")) {
|
|
724
|
+
const close = trimmed.indexOf(">");
|
|
725
|
+
if (close > 0) return trimmed.slice(1, close).trim();
|
|
726
|
+
}
|
|
727
|
+
const firstWhitespace = trimmed.search(/\s/);
|
|
728
|
+
return firstWhitespace === -1 ? trimmed : trimmed.slice(0, firstWhitespace);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function isLikelyRelativeLocalImageDestination(destination: string): boolean {
|
|
732
|
+
if (!destination) return false;
|
|
733
|
+
if (destination.startsWith("/") || destination.startsWith("#")) return false;
|
|
734
|
+
if (destination.startsWith("\\\\")) return false;
|
|
735
|
+
if (/^[A-Za-z]:[\\/]/.test(destination)) return false;
|
|
736
|
+
|
|
737
|
+
const lower = destination.toLowerCase();
|
|
738
|
+
if (
|
|
739
|
+
lower.startsWith("http://")
|
|
740
|
+
|| lower.startsWith("https://")
|
|
741
|
+
|| lower.startsWith("data:")
|
|
742
|
+
|| lower.startsWith("file:")
|
|
743
|
+
|| lower.startsWith("blob:")
|
|
744
|
+
|| lower.startsWith("about:")
|
|
745
|
+
) {
|
|
746
|
+
return false;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
return true;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function hasLikelyRelativeLocalImages(markdown: string): boolean {
|
|
753
|
+
const normalized = normalizeObsidianImages(markdown);
|
|
754
|
+
const imageRegex = /!\[[^\]]*]\(([^)]+)\)/g;
|
|
755
|
+
let match: RegExpExecArray | null;
|
|
756
|
+
while ((match = imageRegex.exec(normalized)) !== null) {
|
|
757
|
+
const destination = extractLikelyImageDestination(match[1] ?? "");
|
|
758
|
+
if (isLikelyRelativeLocalImageDestination(destination)) {
|
|
759
|
+
return true;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
return false;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const MARKDOWN_EXTENSIONS = new Set(["md", "markdown", "mdx", "rmd", "qmd"]);
|
|
766
|
+
|
|
767
|
+
const EXT_TO_LANG: Record<string, string> = {
|
|
768
|
+
ts: "typescript", tsx: "typescript", mts: "typescript", cts: "typescript",
|
|
769
|
+
js: "javascript", jsx: "javascript", mjs: "javascript", cjs: "javascript",
|
|
770
|
+
py: "python", pyw: "python",
|
|
771
|
+
rb: "ruby",
|
|
772
|
+
rs: "rust",
|
|
773
|
+
go: "go",
|
|
774
|
+
java: "java",
|
|
775
|
+
kt: "kotlin", kts: "kotlin",
|
|
776
|
+
swift: "swift",
|
|
777
|
+
c: "c", h: "c",
|
|
778
|
+
cpp: "cpp", cxx: "cpp", cc: "cpp", hpp: "cpp", hxx: "cpp",
|
|
779
|
+
cs: "csharp",
|
|
780
|
+
php: "php",
|
|
781
|
+
sh: "bash", bash: "bash", zsh: "bash",
|
|
782
|
+
fish: "fish",
|
|
783
|
+
ps1: "powershell",
|
|
784
|
+
sql: "sql",
|
|
785
|
+
html: "html", htm: "html",
|
|
786
|
+
css: "css", scss: "scss", sass: "sass", less: "less",
|
|
787
|
+
json: "json", jsonc: "json", json5: "json",
|
|
788
|
+
yaml: "yaml", yml: "yaml",
|
|
789
|
+
toml: "toml",
|
|
790
|
+
xml: "xml",
|
|
791
|
+
dockerfile: "dockerfile",
|
|
792
|
+
makefile: "makefile",
|
|
793
|
+
cmake: "cmake",
|
|
794
|
+
lua: "lua",
|
|
795
|
+
perl: "perl", pl: "perl",
|
|
796
|
+
r: "r",
|
|
797
|
+
jl: "julia",
|
|
798
|
+
scala: "scala",
|
|
799
|
+
clj: "clojure",
|
|
800
|
+
ex: "elixir", exs: "elixir",
|
|
801
|
+
erl: "erlang",
|
|
802
|
+
hs: "haskell",
|
|
803
|
+
ml: "ocaml",
|
|
804
|
+
vim: "vim",
|
|
805
|
+
graphql: "graphql",
|
|
806
|
+
proto: "protobuf",
|
|
807
|
+
tf: "hcl", hcl: "hcl",
|
|
808
|
+
tex: "latex", latex: "latex",
|
|
809
|
+
qmd: "markdown",
|
|
810
|
+
diff: "diff", patch: "diff",
|
|
811
|
+
f90: "fortran", f95: "fortran", f03: "fortran", f: "fortran", for: "fortran",
|
|
812
|
+
m: "matlab",
|
|
813
|
+
};
|
|
814
|
+
|
|
815
|
+
function detectLanguageFromPath(filePath: string): string | undefined {
|
|
816
|
+
const ext = extname(filePath).replace(/^\./, "").toLowerCase();
|
|
817
|
+
if (ext) return EXT_TO_LANG[ext];
|
|
818
|
+
|
|
819
|
+
const baseLower = basename(filePath).toLowerCase();
|
|
820
|
+
if (baseLower === "dockerfile") return "dockerfile";
|
|
821
|
+
if (baseLower === "makefile") return "makefile";
|
|
822
|
+
return undefined;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function isMarkdownFile(filePath: string): boolean {
|
|
826
|
+
const ext = extname(filePath).replace(/^\./, "").toLowerCase();
|
|
827
|
+
return MARKDOWN_EXTENSIONS.has(ext);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const LATEX_EXTENSIONS = new Set(["tex", "latex"]);
|
|
831
|
+
|
|
832
|
+
function isLatexFile(filePath: string): boolean {
|
|
833
|
+
const ext = extname(filePath).replace(/^\./, "").toLowerCase();
|
|
834
|
+
return LATEX_EXTENSIONS.has(ext);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function normalizeFenceLanguage(language: string | undefined): string | undefined {
|
|
838
|
+
const trimmed = typeof language === "string" ? language.trim().toLowerCase() : "";
|
|
839
|
+
if (!trimmed) return undefined;
|
|
840
|
+
if (trimmed === "patch" || trimmed === "udiff") return "diff";
|
|
841
|
+
return trimmed;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function getLongestFenceRun(text: string, fenceChar: "`" | "~"): number {
|
|
845
|
+
const regex = fenceChar === "`" ? /`+/g : /~+/g;
|
|
846
|
+
let max = 0;
|
|
847
|
+
let match: RegExpExecArray | null;
|
|
848
|
+
while ((match = regex.exec(text)) !== null) {
|
|
849
|
+
max = Math.max(max, match[0].length);
|
|
850
|
+
}
|
|
851
|
+
return max;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function wrapCodeAsMarkdown(code: string, lang?: string, filePath?: string): string {
|
|
855
|
+
const header = filePath ? `# ${basename(filePath)}\n\n` : "";
|
|
856
|
+
const source = String(code ?? "").replace(/\r\n/g, "\n").trimEnd();
|
|
857
|
+
const language = normalizeFenceLanguage(lang) ?? "";
|
|
858
|
+
const maxBackticks = getLongestFenceRun(source, "`");
|
|
859
|
+
const maxTildes = getLongestFenceRun(source, "~");
|
|
860
|
+
|
|
861
|
+
let markerChar: "`" | "~" = "`";
|
|
862
|
+
if (maxBackticks === 0 && maxTildes === 0) {
|
|
863
|
+
markerChar = "`";
|
|
864
|
+
} else if (maxTildes < maxBackticks) {
|
|
865
|
+
markerChar = "~";
|
|
866
|
+
} else if (maxBackticks < maxTildes) {
|
|
867
|
+
markerChar = "`";
|
|
868
|
+
} else {
|
|
869
|
+
markerChar = maxBackticks > 0 ? "~" : "`";
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
const markerLength = Math.max(3, (markerChar === "`" ? maxBackticks : maxTildes) + 1);
|
|
873
|
+
const marker = markerChar.repeat(markerLength);
|
|
874
|
+
return `${header}${marker}${language}\n${source}\n${marker}`;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function extractFenceInfoLanguage(info: string): string | undefined {
|
|
878
|
+
const firstToken = String(info ?? "").trim().split(/\s+/)[0]?.replace(/^\./, "") ?? "";
|
|
879
|
+
return normalizeFenceLanguage(firstToken || undefined);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function normalizeMarkdownFencedBlocks(markdown: string): string {
|
|
883
|
+
const lines = String(markdown ?? "").replace(/\r\n/g, "\n").split("\n");
|
|
884
|
+
const out: string[] = [];
|
|
885
|
+
|
|
886
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
887
|
+
const line = lines[index] ?? "";
|
|
888
|
+
const openingMatch = line.match(/^(\s{0,3})(`{3,}|~{3,})([^\n]*)$/);
|
|
889
|
+
if (!openingMatch) {
|
|
890
|
+
out.push(line);
|
|
891
|
+
continue;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
const indent = openingMatch[1] ?? "";
|
|
895
|
+
const openingFence = openingMatch[2]!;
|
|
896
|
+
const openingSuffix = openingMatch[3] ?? "";
|
|
897
|
+
const fenceChar = openingFence[0] as "`" | "~";
|
|
898
|
+
const fenceLength = openingFence.length;
|
|
899
|
+
|
|
900
|
+
let closingIndex = -1;
|
|
901
|
+
for (let innerIndex = index + 1; innerIndex < lines.length; innerIndex += 1) {
|
|
902
|
+
const innerLine = lines[innerIndex] ?? "";
|
|
903
|
+
const closingMatch = innerLine.match(/^\s{0,3}(`{3,}|~{3,})\s*$/);
|
|
904
|
+
if (!closingMatch) continue;
|
|
905
|
+
const closingFence = closingMatch[1]!;
|
|
906
|
+
if (closingFence[0] !== fenceChar || closingFence.length < fenceLength) continue;
|
|
907
|
+
closingIndex = innerIndex;
|
|
908
|
+
break;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
if (closingIndex === -1) {
|
|
912
|
+
out.push(line);
|
|
913
|
+
continue;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
const contentLines = lines.slice(index + 1, closingIndex);
|
|
917
|
+
const content = contentLines.join("\n");
|
|
918
|
+
const maxBackticks = getLongestFenceRun(content, "`");
|
|
919
|
+
const maxTildes = getLongestFenceRun(content, "~");
|
|
920
|
+
const currentMaxRun = fenceChar === "`" ? maxBackticks : maxTildes;
|
|
921
|
+
|
|
922
|
+
if (currentMaxRun < fenceLength) {
|
|
923
|
+
out.push(line, ...contentLines, lines[closingIndex] ?? "");
|
|
924
|
+
index = closingIndex;
|
|
925
|
+
continue;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const neededBackticks = Math.max(3, maxBackticks + 1);
|
|
929
|
+
const neededTildes = Math.max(3, maxTildes + 1);
|
|
930
|
+
let markerChar: "`" | "~" = fenceChar;
|
|
931
|
+
|
|
932
|
+
if (neededBackticks < neededTildes) {
|
|
933
|
+
markerChar = "`";
|
|
934
|
+
} else if (neededTildes < neededBackticks) {
|
|
935
|
+
markerChar = "~";
|
|
936
|
+
} else if (fenceChar === "`") {
|
|
937
|
+
markerChar = "~";
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
const markerLength = markerChar === "`" ? neededBackticks : neededTildes;
|
|
941
|
+
const marker = markerChar.repeat(markerLength);
|
|
942
|
+
out.push(`${indent}${marker}${openingSuffix}`, ...contentLines, `${indent}${marker}`);
|
|
943
|
+
index = closingIndex;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
return out.join("\n");
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
function hasMarkdownDiffFence(markdown: string): boolean {
|
|
950
|
+
const lines = String(markdown ?? "").replace(/\r\n/g, "\n").split("\n");
|
|
951
|
+
|
|
952
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
953
|
+
const line = lines[index] ?? "";
|
|
954
|
+
const openingMatch = line.match(/^\s{0,3}(`{3,}|~{3,})([^\n]*)$/);
|
|
955
|
+
if (!openingMatch) continue;
|
|
956
|
+
|
|
957
|
+
const openingFence = openingMatch[1]!;
|
|
958
|
+
const infoLanguage = extractFenceInfoLanguage(openingMatch[2] ?? "");
|
|
959
|
+
if (infoLanguage !== "diff") continue;
|
|
960
|
+
|
|
961
|
+
const fenceChar = openingFence[0];
|
|
962
|
+
const fenceLength = openingFence.length;
|
|
963
|
+
for (let innerIndex = index + 1; innerIndex < lines.length; innerIndex += 1) {
|
|
964
|
+
const innerLine = lines[innerIndex] ?? "";
|
|
965
|
+
const closingMatch = innerLine.match(/^\s{0,3}(`{3,}|~{3,})\s*$/);
|
|
966
|
+
if (!closingMatch) continue;
|
|
967
|
+
const closingFence = closingMatch[1]!;
|
|
968
|
+
if (closingFence[0] !== fenceChar || closingFence.length < fenceLength) continue;
|
|
969
|
+
return true;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
return false;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
function getBrowserCandidates(): string[] {
|
|
977
|
+
if (process.platform === "darwin") {
|
|
978
|
+
return [
|
|
979
|
+
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
980
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
981
|
+
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
|
982
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
983
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
984
|
+
];
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
if (process.platform === "win32") {
|
|
988
|
+
return [
|
|
989
|
+
"C:/Program Files/Google/Chrome/Application/chrome.exe",
|
|
990
|
+
"C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
|
|
991
|
+
"C:/Program Files/Microsoft/Edge/Application/msedge.exe",
|
|
992
|
+
"C:/Program Files/BraveSoftware/Brave-Browser/Application/brave.exe",
|
|
993
|
+
];
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
return [
|
|
997
|
+
"/usr/bin/google-chrome",
|
|
998
|
+
"/usr/bin/google-chrome-stable",
|
|
999
|
+
"/usr/bin/chromium",
|
|
1000
|
+
"/usr/bin/chromium-browser",
|
|
1001
|
+
"/snap/bin/chromium",
|
|
1002
|
+
];
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
function findBrowserExecutable(): string | undefined {
|
|
1006
|
+
const envPath = process.env.PUPPETEER_EXECUTABLE_PATH || process.env.CHROME_PATH || process.env.BROWSER;
|
|
1007
|
+
if (envPath && existsSync(envPath)) {
|
|
1008
|
+
return envPath;
|
|
1009
|
+
}
|
|
1010
|
+
return getBrowserCandidates().find((candidate) => existsSync(candidate));
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function getCachePaths(markdownPage: string, styleKey: string) {
|
|
1014
|
+
const hash = createHash("sha256")
|
|
1015
|
+
.update(RENDER_VERSION)
|
|
1016
|
+
.update("\u0000")
|
|
1017
|
+
.update(styleKey)
|
|
1018
|
+
.update("\u0000")
|
|
1019
|
+
.update(markdownPage)
|
|
1020
|
+
.digest("hex");
|
|
1021
|
+
return {
|
|
1022
|
+
pngPath: join(CACHE_DIR, `${hash}.png`),
|
|
1023
|
+
metaPath: join(CACHE_DIR, `${hash}.json`),
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
function buildRenderCacheKey(styleKey: string, resourcePath?: string, isLatex?: boolean): string {
|
|
1028
|
+
const format = isLatex ? "latex" : "markdown";
|
|
1029
|
+
const resolvedResourcePath = resourcePath ? resolvePath(resourcePath) : "";
|
|
1030
|
+
return `${styleKey}\u0000${format}\u0000${resolvedResourcePath}`;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
async function readCachedPage(markdownPage: string, styleKey: string): Promise<CachedPage | undefined> {
|
|
1034
|
+
const { pngPath, metaPath } = getCachePaths(markdownPage, styleKey);
|
|
1035
|
+
if (!existsSync(pngPath)) {
|
|
1036
|
+
return undefined;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
try {
|
|
1040
|
+
const buffer = await readFile(pngPath);
|
|
1041
|
+
let truncatedHeight = false;
|
|
1042
|
+
let pageCount: number | undefined;
|
|
1043
|
+
if (existsSync(metaPath)) {
|
|
1044
|
+
const meta = JSON.parse(await readFile(metaPath, "utf-8")) as { truncatedHeight?: boolean; pageCount?: number };
|
|
1045
|
+
truncatedHeight = meta.truncatedHeight === true;
|
|
1046
|
+
pageCount = meta.pageCount;
|
|
1047
|
+
}
|
|
1048
|
+
return { buffer, truncatedHeight, pageCount };
|
|
1049
|
+
} catch {
|
|
1050
|
+
return undefined;
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
async function writeCachedPage(markdownPage: string, styleKey: string, page: CachedPage): Promise<void> {
|
|
1055
|
+
const { pngPath, metaPath } = getCachePaths(markdownPage, styleKey);
|
|
1056
|
+
await mkdir(CACHE_DIR, { recursive: true });
|
|
1057
|
+
await writeFile(pngPath, page.buffer);
|
|
1058
|
+
const meta: Record<string, unknown> = { truncatedHeight: page.truncatedHeight };
|
|
1059
|
+
if (page.pageCount != null) meta.pageCount = page.pageCount;
|
|
1060
|
+
await writeFile(metaPath, JSON.stringify(meta), "utf-8");
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
async function waitForPageRenderReady(page: puppeteer.Page): Promise<void> {
|
|
1064
|
+
await page.evaluate(async () => {
|
|
1065
|
+
if ("fonts" in document) {
|
|
1066
|
+
await (document as Document & { fonts?: { ready: Promise<unknown> } }).fonts?.ready;
|
|
1067
|
+
}
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function prepareBrowserPreviewMarkdown(markdown: string, isLatex?: boolean): {
|
|
1072
|
+
normalizedMarkdown: string;
|
|
1073
|
+
pandocMarkdown: string;
|
|
1074
|
+
annotationPlaceholders: PreviewAnnotationPlaceholder[];
|
|
1075
|
+
} {
|
|
1076
|
+
const normalizedMarkdown = isLatex ? markdown : normalizeMarkdownFencedBlocks(normalizeObsidianImages(normalizeMathDelimiters(markdown)));
|
|
1077
|
+
if (isLatex || !hasMarkdownAnnotationMarkers(normalizedMarkdown)) {
|
|
1078
|
+
return { normalizedMarkdown, pandocMarkdown: normalizedMarkdown, annotationPlaceholders: [] };
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
const prepared = prepareMarkdownForPandocPreview(normalizedMarkdown, PREVIEW_ANNOTATION_PLACEHOLDER_PREFIX) as {
|
|
1082
|
+
markdown?: string;
|
|
1083
|
+
placeholders?: PreviewAnnotationPlaceholder[];
|
|
1084
|
+
};
|
|
1085
|
+
return {
|
|
1086
|
+
normalizedMarkdown,
|
|
1087
|
+
pandocMarkdown: typeof prepared.markdown === "string" ? prepared.markdown : normalizedMarkdown,
|
|
1088
|
+
annotationPlaceholders: Array.isArray(prepared.placeholders) ? prepared.placeholders : [],
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
async function renderPreview(markdown: string, style: PreviewStyle, signal?: AbortSignal, resourcePath?: string, skipCache?: boolean, isLatex?: boolean): Promise<RenderPreviewResult> {
|
|
1093
|
+
const { normalizedMarkdown, pandocMarkdown, annotationPlaceholders } = prepareBrowserPreviewMarkdown(markdown, isLatex);
|
|
1094
|
+
const cacheKey = buildRenderCacheKey(style.cacheKey, resourcePath, isLatex);
|
|
1095
|
+
|
|
1096
|
+
// Check cache for the full render (keyed on full markdown content).
|
|
1097
|
+
const cached = skipCache ? undefined : await readCachedPage(normalizedMarkdown, cacheKey);
|
|
1098
|
+
if (cached) {
|
|
1099
|
+
// Cached result stores page count in meta; individual page PNGs are stored separately.
|
|
1100
|
+
const meta = cached as CachedPage & { pageCount?: number };
|
|
1101
|
+
const pageCount = meta.pageCount ?? 1;
|
|
1102
|
+
const pages: PreviewPage[] = [];
|
|
1103
|
+
for (let i = 0; i < pageCount; i++) {
|
|
1104
|
+
const pageKey = `${normalizedMarkdown}\u0000page${i}`;
|
|
1105
|
+
const pageCached = i === 0 ? cached : await readCachedPage(pageKey, cacheKey);
|
|
1106
|
+
if (!pageCached) {
|
|
1107
|
+
// Cache is incomplete; re-render.
|
|
1108
|
+
return renderPreview(markdown, style, signal, resourcePath, true, isLatex);
|
|
1109
|
+
}
|
|
1110
|
+
pages.push({
|
|
1111
|
+
base64Png: pageCached.buffer.toString("base64"),
|
|
1112
|
+
truncatedHeight: pageCached.truncatedHeight,
|
|
1113
|
+
index: i,
|
|
1114
|
+
total: pageCount,
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
return { pages, themeMode: style.themeMode, truncatedPages: false };
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
await mkdir(CACHE_DIR, { recursive: true });
|
|
1121
|
+
|
|
1122
|
+
const fragmentHtml = await renderMarkdownToHtmlWithPandoc(pandocMarkdown, resourcePath, isLatex);
|
|
1123
|
+
const html = buildBrowserHtmlFromPandocFragment(fragmentHtml, style, resourcePath, annotationPlaceholders);
|
|
1124
|
+
|
|
1125
|
+
let browser: puppeteer.Browser | undefined;
|
|
1126
|
+
let browserPage: puppeteer.Page | undefined;
|
|
1127
|
+
let tempHtmlPath: string | undefined;
|
|
1128
|
+
|
|
1129
|
+
try {
|
|
1130
|
+
if (signal?.aborted) throw new Error("Preview rendering cancelled.");
|
|
1131
|
+
|
|
1132
|
+
const executablePath = findBrowserExecutable();
|
|
1133
|
+
if (!executablePath) {
|
|
1134
|
+
throw new Error(
|
|
1135
|
+
"No Chromium-based browser was found. Set PUPPETEER_EXECUTABLE_PATH to your Chrome/Edge/Chromium binary.",
|
|
1136
|
+
);
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
const args = ["--disable-gpu", "--font-render-hinting=medium"];
|
|
1140
|
+
if (process.platform === "linux") {
|
|
1141
|
+
args.push("--no-sandbox", "--disable-setuid-sandbox");
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
browser = await puppeteer.launch({ headless: true, executablePath, args });
|
|
1145
|
+
browserPage = await browser.newPage();
|
|
1146
|
+
|
|
1147
|
+
const loadHtml = async (height: number) => {
|
|
1148
|
+
await browserPage!.setViewport({
|
|
1149
|
+
width: VIEWPORT_WIDTH_PX,
|
|
1150
|
+
height,
|
|
1151
|
+
deviceScaleFactor: 2,
|
|
1152
|
+
});
|
|
1153
|
+
if (!tempHtmlPath) {
|
|
1154
|
+
tempHtmlPath = join(CACHE_DIR, `_render_tmp_${Date.now()}.html`);
|
|
1155
|
+
await writeFile(tempHtmlPath, html, "utf-8");
|
|
1156
|
+
}
|
|
1157
|
+
await browserPage!.goto(pathToFileURL(tempHtmlPath).href, { waitUntil: "domcontentloaded" });
|
|
1158
|
+
await waitForPageRenderReady(browserPage!);
|
|
1159
|
+
await browserPage!.waitForFunction(
|
|
1160
|
+
"window.__mermaidDone === true",
|
|
1161
|
+
{ timeout: 15000 }
|
|
1162
|
+
).catch(() => {});
|
|
1163
|
+
};
|
|
1164
|
+
|
|
1165
|
+
// First pass: measure content height.
|
|
1166
|
+
await loadHtml(900);
|
|
1167
|
+
const contentHeight = await browserPage.evaluate(() => {
|
|
1168
|
+
const root = document.getElementById("preview-root");
|
|
1169
|
+
if (!root) return 900;
|
|
1170
|
+
const rect = root.getBoundingClientRect();
|
|
1171
|
+
return Math.ceil(rect.height + 40);
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
if (signal?.aborted) throw new Error("Preview rendering cancelled.");
|
|
1175
|
+
|
|
1176
|
+
// Clamp to maximum render height.
|
|
1177
|
+
const renderHeight = Math.max(500, Math.min(MAX_RENDER_HEIGHT_PX, contentHeight));
|
|
1178
|
+
const truncatedPages = contentHeight > MAX_RENDER_HEIGHT_PX;
|
|
1179
|
+
|
|
1180
|
+
// Second pass: render at full height.
|
|
1181
|
+
if (renderHeight !== 900) {
|
|
1182
|
+
await loadHtml(renderHeight);
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// Take full screenshot and slice into pages.
|
|
1186
|
+
const fullScreenshot = (await browserPage.screenshot({ type: "png" })) as Buffer;
|
|
1187
|
+
|
|
1188
|
+
if (tempHtmlPath) await unlink(tempHtmlPath).catch(() => {});
|
|
1189
|
+
tempHtmlPath = undefined;
|
|
1190
|
+
|
|
1191
|
+
// Import sharp-like slicing via puppeteer clip regions, or slice the
|
|
1192
|
+
// full PNG by re-screenshotting with clip. Since we already have the
|
|
1193
|
+
// full page loaded, clip is simplest.
|
|
1194
|
+
const pageCount = Math.max(1, Math.ceil(renderHeight / PAGE_HEIGHT_PX));
|
|
1195
|
+
const pages: PreviewPage[] = [];
|
|
1196
|
+
|
|
1197
|
+
if (pageCount === 1) {
|
|
1198
|
+
// Single page — use the full screenshot directly.
|
|
1199
|
+
pages.push({
|
|
1200
|
+
base64Png: fullScreenshot.toString("base64"),
|
|
1201
|
+
truncatedHeight: false,
|
|
1202
|
+
index: 0,
|
|
1203
|
+
total: 1,
|
|
1204
|
+
});
|
|
1205
|
+
await writeCachedPage(normalizedMarkdown, cacheKey, {
|
|
1206
|
+
buffer: fullScreenshot,
|
|
1207
|
+
truncatedHeight: false,
|
|
1208
|
+
pageCount: 1,
|
|
1209
|
+
}).catch(() => {});
|
|
1210
|
+
} else {
|
|
1211
|
+
// Multiple pages — use clip regions.
|
|
1212
|
+
for (let i = 0; i < pageCount; i++) {
|
|
1213
|
+
if (signal?.aborted) throw new Error("Preview rendering cancelled.");
|
|
1214
|
+
|
|
1215
|
+
const y = i * PAGE_HEIGHT_PX;
|
|
1216
|
+
const height = Math.min(PAGE_HEIGHT_PX, renderHeight - y);
|
|
1217
|
+
|
|
1218
|
+
const pageScreenshot = (await browserPage.screenshot({
|
|
1219
|
+
type: "png",
|
|
1220
|
+
clip: {
|
|
1221
|
+
x: 0,
|
|
1222
|
+
y,
|
|
1223
|
+
width: VIEWPORT_WIDTH_PX,
|
|
1224
|
+
height,
|
|
1225
|
+
},
|
|
1226
|
+
})) as Buffer;
|
|
1227
|
+
|
|
1228
|
+
pages.push({
|
|
1229
|
+
base64Png: pageScreenshot.toString("base64"),
|
|
1230
|
+
truncatedHeight: false,
|
|
1231
|
+
index: i,
|
|
1232
|
+
total: pageCount,
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
// Cache each page slice.
|
|
1236
|
+
const pageKey = i === 0 ? normalizedMarkdown : `${normalizedMarkdown}\u0000page${i}`;
|
|
1237
|
+
await writeCachedPage(pageKey, cacheKey, {
|
|
1238
|
+
buffer: pageScreenshot,
|
|
1239
|
+
truncatedHeight: false,
|
|
1240
|
+
pageCount: i === 0 ? pageCount : undefined,
|
|
1241
|
+
}).catch(() => {});
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
return { pages, themeMode: style.themeMode, truncatedPages };
|
|
1246
|
+
} finally {
|
|
1247
|
+
if (tempHtmlPath) await unlink(tempHtmlPath).catch(() => {});
|
|
1248
|
+
if (browserPage) await browserPage.close().catch(() => {});
|
|
1249
|
+
if (browser) await browser.close().catch(() => {});
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
|
|
1254
|
+
class MarkdownPreviewOverlay {
|
|
1255
|
+
private container = new Container();
|
|
1256
|
+
private pageIndex = 0;
|
|
1257
|
+
private statusLine: string | undefined;
|
|
1258
|
+
private isRefreshing = false;
|
|
1259
|
+
private isOpeningBrowser = false;
|
|
1260
|
+
private imageIdsByPage = new Map<number, number>();
|
|
1261
|
+
private readonly useKittyImageDeletion = getCapabilities().images === "kitty";
|
|
1262
|
+
|
|
1263
|
+
constructor(
|
|
1264
|
+
private tui: TUI,
|
|
1265
|
+
private theme: Theme,
|
|
1266
|
+
private preview: RenderPreviewResult,
|
|
1267
|
+
private done: () => void,
|
|
1268
|
+
private refresh: () => Promise<RenderPreviewResult>,
|
|
1269
|
+
private openInBrowser: () => Promise<void>,
|
|
1270
|
+
) {
|
|
1271
|
+
this.rebuild();
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
private currentPage(): PreviewPage {
|
|
1275
|
+
return this.preview.pages[this.pageIndex]!;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
private getImageIdForPage(pageIndex: number): number | undefined {
|
|
1279
|
+
if (!this.useKittyImageDeletion) return undefined;
|
|
1280
|
+
const existing = this.imageIdsByPage.get(pageIndex);
|
|
1281
|
+
if (existing !== undefined) return existing;
|
|
1282
|
+
const created = allocateImageId();
|
|
1283
|
+
this.imageIdsByPage.set(pageIndex, created);
|
|
1284
|
+
return created;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
private clearRenderedImages(): void {
|
|
1288
|
+
if (!this.useKittyImageDeletion) return;
|
|
1289
|
+
for (const imageId of this.imageIdsByPage.values()) {
|
|
1290
|
+
try {
|
|
1291
|
+
this.tui.terminal.write(deleteKittyImage(imageId));
|
|
1292
|
+
} catch {
|
|
1293
|
+
// no-op
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
this.imageIdsByPage.clear();
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
private rebuild(): void {
|
|
1300
|
+
this.container.clear();
|
|
1301
|
+
|
|
1302
|
+
const title = `${this.theme.bold("Markdown preview")} ${this.theme.fg("dim", `(${this.pageIndex + 1}/${this.preview.pages.length})`)}`;
|
|
1303
|
+
this.container.addChild(new Text(this.theme.fg("accent", title), 0, 0));
|
|
1304
|
+
|
|
1305
|
+
const controls: string[] = [];
|
|
1306
|
+
if (this.preview.pages.length > 1) controls.push("←/→ page");
|
|
1307
|
+
controls.push(`${keyHint("tui.select.cancel", "close")}`, "r refresh", "o open browser");
|
|
1308
|
+
this.container.addChild(new Text(this.theme.fg("dim", controls.join(" • ")), 0, 0));
|
|
1309
|
+
|
|
1310
|
+
const page = this.currentPage();
|
|
1311
|
+
if (this.preview.truncatedPages || page.truncatedHeight) {
|
|
1312
|
+
const notes: string[] = [];
|
|
1313
|
+
if (this.preview.truncatedPages) notes.push("message split into max preview pages");
|
|
1314
|
+
if (page.truncatedHeight) notes.push("current page clipped for terminal preview");
|
|
1315
|
+
this.container.addChild(new Text(this.theme.fg("warning", `Note: ${notes.join("; ")}.`), 0, 0));
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
if (this.statusLine) {
|
|
1319
|
+
this.container.addChild(new Text(this.statusLine, 0, 0));
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
this.container.addChild(new Spacer(1));
|
|
1323
|
+
this.container.addChild(
|
|
1324
|
+
new Image(
|
|
1325
|
+
page.base64Png,
|
|
1326
|
+
"image/png",
|
|
1327
|
+
{ fallbackColor: (str) => this.theme.fg("muted", str) },
|
|
1328
|
+
{ maxWidthCells: 280, imageId: this.getImageIdForPage(page.index) },
|
|
1329
|
+
),
|
|
1330
|
+
);
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
handleInput(data: string): void {
|
|
1334
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
|
1335
|
+
this.clearRenderedImages();
|
|
1336
|
+
this.done();
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
if (matchesKey(data, "left") && this.pageIndex > 0) {
|
|
1341
|
+
this.clearRenderedImages();
|
|
1342
|
+
this.pageIndex--;
|
|
1343
|
+
this.statusLine = undefined;
|
|
1344
|
+
this.rebuild();
|
|
1345
|
+
this.tui.requestRender();
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
if (matchesKey(data, "right") && this.pageIndex < this.preview.pages.length - 1) {
|
|
1350
|
+
this.clearRenderedImages();
|
|
1351
|
+
this.pageIndex++;
|
|
1352
|
+
this.statusLine = undefined;
|
|
1353
|
+
this.rebuild();
|
|
1354
|
+
this.tui.requestRender();
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
if (matchesKey(data, "o") && !this.isOpeningBrowser) {
|
|
1359
|
+
this.isOpeningBrowser = true;
|
|
1360
|
+
this.statusLine = this.theme.fg("warning", "Opening browser preview...");
|
|
1361
|
+
this.rebuild();
|
|
1362
|
+
this.tui.requestRender();
|
|
1363
|
+
|
|
1364
|
+
void this.openInBrowser()
|
|
1365
|
+
.then(() => {
|
|
1366
|
+
this.statusLine = this.theme.fg("success", "Opened preview in browser.");
|
|
1367
|
+
})
|
|
1368
|
+
.catch((error) => {
|
|
1369
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1370
|
+
this.statusLine = this.theme.fg("error", `Browser open failed: ${message}`);
|
|
1371
|
+
})
|
|
1372
|
+
.finally(() => {
|
|
1373
|
+
this.isOpeningBrowser = false;
|
|
1374
|
+
this.rebuild();
|
|
1375
|
+
this.tui.requestRender();
|
|
1376
|
+
});
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
if (matchesKey(data, "r") && !this.isRefreshing) {
|
|
1381
|
+
this.isRefreshing = true;
|
|
1382
|
+
this.statusLine = this.theme.fg("warning", "Refreshing preview for current theme...");
|
|
1383
|
+
this.rebuild();
|
|
1384
|
+
this.tui.requestRender();
|
|
1385
|
+
|
|
1386
|
+
void this.refresh()
|
|
1387
|
+
.then((preview) => {
|
|
1388
|
+
this.clearRenderedImages();
|
|
1389
|
+
this.preview = preview;
|
|
1390
|
+
this.pageIndex = Math.min(this.pageIndex, Math.max(0, preview.pages.length - 1));
|
|
1391
|
+
this.statusLine = this.theme.fg("success", `Refreshed (${preview.themeMode} mode).`);
|
|
1392
|
+
})
|
|
1393
|
+
.catch((error) => {
|
|
1394
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1395
|
+
this.statusLine = this.theme.fg("error", `Refresh failed: ${message}`);
|
|
1396
|
+
})
|
|
1397
|
+
.finally(() => {
|
|
1398
|
+
this.isRefreshing = false;
|
|
1399
|
+
this.rebuild();
|
|
1400
|
+
this.tui.requestRender();
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
render(width: number): string[] {
|
|
1406
|
+
return this.container.render(width);
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
invalidate(): void {
|
|
1410
|
+
this.container.invalidate();
|
|
1411
|
+
this.rebuild();
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
dispose(): void {
|
|
1415
|
+
this.clearRenderedImages();
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
async function renderWithLoader(ctx: ExtensionCommandContext, markdown: string, resourcePath?: string, isLatex?: boolean): Promise<RenderWithLoaderResult | null> {
|
|
1420
|
+
type LoaderResult = { ok: true; preview: RenderPreviewResult } | { ok: false; error: string } | { ok: false; cancelled: true };
|
|
1421
|
+
|
|
1422
|
+
const result = await ctx.ui.custom<LoaderResult>((tui, theme, _kb, done) => {
|
|
1423
|
+
const loader = new BorderedLoader(tui, theme, "Rendering markdown + LaTeX preview...");
|
|
1424
|
+
let settled = false;
|
|
1425
|
+
const resolve = (value: LoaderResult) => {
|
|
1426
|
+
if (settled) return;
|
|
1427
|
+
settled = true;
|
|
1428
|
+
done(value);
|
|
1429
|
+
};
|
|
1430
|
+
|
|
1431
|
+
loader.onAbort = () => resolve({ ok: false, cancelled: true });
|
|
1432
|
+
|
|
1433
|
+
void (async () => {
|
|
1434
|
+
try {
|
|
1435
|
+
const style = getPreviewStyle(ctx.ui.theme);
|
|
1436
|
+
const preview = await renderPreview(markdown, style, loader.signal, resourcePath, undefined, isLatex);
|
|
1437
|
+
if (loader.signal.aborted) {
|
|
1438
|
+
resolve({ ok: false, cancelled: true });
|
|
1439
|
+
return;
|
|
1440
|
+
}
|
|
1441
|
+
resolve({ ok: true, preview });
|
|
1442
|
+
} catch (error) {
|
|
1443
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1444
|
+
resolve({ ok: false, error: message });
|
|
1445
|
+
}
|
|
1446
|
+
})();
|
|
1447
|
+
|
|
1448
|
+
return loader;
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1451
|
+
if (!result) {
|
|
1452
|
+
try {
|
|
1453
|
+
const style = getPreviewStyle(ctx.ui.theme);
|
|
1454
|
+
const preview = await renderPreview(markdown, style, undefined, resourcePath, undefined, isLatex);
|
|
1455
|
+
return { preview, supportsCustomUi: false };
|
|
1456
|
+
} catch (error) {
|
|
1457
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1458
|
+
ctx.ui.notify(`Preview failed: ${message}`, "error");
|
|
1459
|
+
return null;
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
if (!result.ok) {
|
|
1464
|
+
if ("cancelled" in result && result.cancelled) {
|
|
1465
|
+
ctx.ui.notify("Preview cancelled.", "info");
|
|
1466
|
+
return null;
|
|
1467
|
+
}
|
|
1468
|
+
if ("error" in result) {
|
|
1469
|
+
ctx.ui.notify(`Preview failed: ${result.error}`, "error");
|
|
1470
|
+
return null;
|
|
1471
|
+
}
|
|
1472
|
+
ctx.ui.notify("Preview failed.", "error");
|
|
1473
|
+
return null;
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
return {
|
|
1477
|
+
preview: result.preview,
|
|
1478
|
+
supportsCustomUi: true,
|
|
1479
|
+
};
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
async function pickAssistantMessage(ctx: ExtensionCommandContext): Promise<string | null> {
|
|
1483
|
+
const messages = getAssistantMessages(ctx);
|
|
1484
|
+
|
|
1485
|
+
if (messages.length === 0) {
|
|
1486
|
+
ctx.ui.notify("No assistant messages found in the current branch.", "warning");
|
|
1487
|
+
return null;
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
if (messages.length === 1) {
|
|
1491
|
+
return messages[0]!.markdown;
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
const items: SelectItem[] = messages.map((msg, i) => ({
|
|
1495
|
+
value: String(i),
|
|
1496
|
+
label: `Response ${msg.index + 1}`,
|
|
1497
|
+
description: msg.preview,
|
|
1498
|
+
}));
|
|
1499
|
+
|
|
1500
|
+
const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
|
1501
|
+
const container = new Container();
|
|
1502
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
1503
|
+
container.addChild(new Text(theme.fg("accent", theme.bold("Select Response to Preview")), 1, 0));
|
|
1504
|
+
|
|
1505
|
+
const selectList = new SelectList(items, Math.min(items.length, 10), {
|
|
1506
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
1507
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
1508
|
+
description: (text) => theme.fg("muted", text),
|
|
1509
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
1510
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
1511
|
+
});
|
|
1512
|
+
|
|
1513
|
+
// Start with the last (most recent) item selected
|
|
1514
|
+
for (let i = 0; i < items.length - 1; i++) {
|
|
1515
|
+
selectList.handleInput("\x1b[B"); // simulate down arrow
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
selectList.onSelect = (item) => done(item.value);
|
|
1519
|
+
selectList.onCancel = () => done(null);
|
|
1520
|
+
container.addChild(selectList);
|
|
1521
|
+
|
|
1522
|
+
container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc cancel"), 1, 0));
|
|
1523
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
1524
|
+
|
|
1525
|
+
return {
|
|
1526
|
+
render(width: number) {
|
|
1527
|
+
return container.render(width);
|
|
1528
|
+
},
|
|
1529
|
+
invalidate() {
|
|
1530
|
+
container.invalidate();
|
|
1531
|
+
},
|
|
1532
|
+
handleInput(data: string) {
|
|
1533
|
+
selectList.handleInput(data);
|
|
1534
|
+
tui.requestRender();
|
|
1535
|
+
},
|
|
1536
|
+
};
|
|
1537
|
+
});
|
|
1538
|
+
|
|
1539
|
+
if (result === null) return null;
|
|
1540
|
+
const selected = messages[Number(result)];
|
|
1541
|
+
return selected ? selected.markdown : null;
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
async function openPreview(ctx: ExtensionCommandContext, markdownOverride?: string, resourcePath?: string, isLatex?: boolean): Promise<void> {
|
|
1545
|
+
const markdown = markdownOverride ?? getLastAssistantMarkdown(ctx);
|
|
1546
|
+
if (!markdown) {
|
|
1547
|
+
ctx.ui.notify("No assistant markdown found in the current branch.", "warning");
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
const rendered = await renderWithLoader(ctx, markdown, resourcePath, isLatex);
|
|
1552
|
+
if (!rendered) return;
|
|
1553
|
+
|
|
1554
|
+
const { preview: initialPreview, supportsCustomUi } = rendered;
|
|
1555
|
+
if (!supportsCustomUi) {
|
|
1556
|
+
const pageCount = initialPreview.pages.length;
|
|
1557
|
+
ctx.ui.notify(
|
|
1558
|
+
`Preview rendered (${pageCount} page${pageCount === 1 ? "" : "s"}), but interactive preview display isn't available in this mode.`,
|
|
1559
|
+
"info",
|
|
1560
|
+
);
|
|
1561
|
+
return;
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
// NOTE: Keep this in non-overlay mode.
|
|
1565
|
+
// Overlay compositing currently truncates terminal image protocol sequences
|
|
1566
|
+
// (kitty/iTerm), which causes raw image payload fragments to appear instead
|
|
1567
|
+
// of the rendered preview.
|
|
1568
|
+
await ctx.ui.custom<void>((tui, theme, _kb, done) =>
|
|
1569
|
+
new MarkdownPreviewOverlay(
|
|
1570
|
+
tui,
|
|
1571
|
+
theme,
|
|
1572
|
+
initialPreview,
|
|
1573
|
+
done,
|
|
1574
|
+
async () => {
|
|
1575
|
+
const style = getPreviewStyle(ctx.ui.theme);
|
|
1576
|
+
const refreshed = await renderPreview(markdown, style, undefined, resourcePath, true, isLatex);
|
|
1577
|
+
return refreshed;
|
|
1578
|
+
},
|
|
1579
|
+
async () => {
|
|
1580
|
+
await openPreviewInBrowser(ctx, markdown, resourcePath, isLatex);
|
|
1581
|
+
},
|
|
1582
|
+
),
|
|
1583
|
+
);
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
async function openFileInDefaultBrowser(filePath: string): Promise<void> {
|
|
1587
|
+
const target = pathToFileURL(filePath).href;
|
|
1588
|
+
const openCommand =
|
|
1589
|
+
process.platform === "darwin"
|
|
1590
|
+
? { command: "open", args: [target] }
|
|
1591
|
+
: process.platform === "win32"
|
|
1592
|
+
? { command: "cmd", args: ["/c", "start", "", target] }
|
|
1593
|
+
: { command: "xdg-open", args: [target] };
|
|
1594
|
+
|
|
1595
|
+
await new Promise<void>((resolve, reject) => {
|
|
1596
|
+
const child = spawn(openCommand.command, openCommand.args, {
|
|
1597
|
+
stdio: "ignore",
|
|
1598
|
+
detached: true,
|
|
1599
|
+
});
|
|
1600
|
+
child.once("error", reject);
|
|
1601
|
+
child.once("spawn", () => {
|
|
1602
|
+
child.unref();
|
|
1603
|
+
resolve();
|
|
1604
|
+
});
|
|
1605
|
+
});
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
async function renderMarkdownToHtmlWithPandoc(markdown: string, resourcePath?: string, isLatex?: boolean): Promise<string> {
|
|
1609
|
+
const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
|
|
1610
|
+
const pandocInput = isLatex ? markdown : normalizeMarkdownFencedBlocks(markdown);
|
|
1611
|
+
const inputFormat = isLatex ? "latex" : "markdown+lists_without_preceding_blankline-blank_before_blockquote-blank_before_header+tex_math_dollars+autolink_bare_uris-raw_html";
|
|
1612
|
+
const args = ["-f", inputFormat, "-t", "html5", "--mathml", "--wrap=none"];
|
|
1613
|
+
if (resourcePath) args.push(`--resource-path=${resourcePath}`);
|
|
1614
|
+
|
|
1615
|
+
return await new Promise<string>((resolve, reject) => {
|
|
1616
|
+
const child = spawn(pandocCommand, args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
1617
|
+
const stdoutChunks: Buffer[] = [];
|
|
1618
|
+
const stderrChunks: Buffer[] = [];
|
|
1619
|
+
let settled = false;
|
|
1620
|
+
|
|
1621
|
+
const fail = (error: Error) => {
|
|
1622
|
+
if (settled) return;
|
|
1623
|
+
settled = true;
|
|
1624
|
+
reject(error);
|
|
1625
|
+
};
|
|
1626
|
+
const succeed = (html: string) => {
|
|
1627
|
+
if (settled) return;
|
|
1628
|
+
settled = true;
|
|
1629
|
+
resolve(html);
|
|
1630
|
+
};
|
|
1631
|
+
|
|
1632
|
+
child.stdout.on("data", (chunk: Buffer | string) => {
|
|
1633
|
+
stdoutChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
1634
|
+
});
|
|
1635
|
+
child.stderr.on("data", (chunk: Buffer | string) => {
|
|
1636
|
+
stderrChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
1637
|
+
});
|
|
1638
|
+
|
|
1639
|
+
child.once("error", (error) => {
|
|
1640
|
+
const errno = error as NodeJS.ErrnoException;
|
|
1641
|
+
if (errno.code === "ENOENT") {
|
|
1642
|
+
fail(
|
|
1643
|
+
new Error(
|
|
1644
|
+
`pandoc was not found. Install pandoc or set PANDOC_PATH to the pandoc binary.`,
|
|
1645
|
+
),
|
|
1646
|
+
);
|
|
1647
|
+
return;
|
|
1648
|
+
}
|
|
1649
|
+
fail(error);
|
|
1650
|
+
});
|
|
1651
|
+
|
|
1652
|
+
child.once("close", (code) => {
|
|
1653
|
+
if (settled) return;
|
|
1654
|
+
if (code === 0) {
|
|
1655
|
+
succeed(Buffer.concat(stdoutChunks).toString("utf-8"));
|
|
1656
|
+
return;
|
|
1657
|
+
}
|
|
1658
|
+
const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
|
|
1659
|
+
fail(new Error(`pandoc failed with exit code ${code}${stderr ? `: ${stderr}` : ""}`));
|
|
1660
|
+
});
|
|
1661
|
+
|
|
1662
|
+
child.stdin.end(pandocInput);
|
|
1663
|
+
});
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
const PDF_PREAMBLE = `\\usepackage{titlesec}
|
|
1667
|
+
\\titleformat{\\section}{\\Large\\bfseries\\sffamily}{}{0pt}{}[\\vspace{2pt}\\titlerule]
|
|
1668
|
+
\\titleformat{\\subsection}{\\large\\bfseries\\sffamily}{}{0pt}{}
|
|
1669
|
+
\\titleformat{\\subsubsection}{\\normalsize\\bfseries\\sffamily}{}{0pt}{}
|
|
1670
|
+
\\titlespacing*{\\section}{0pt}{1.5ex plus 0.5ex minus 0.2ex}{1ex plus 0.2ex}
|
|
1671
|
+
\\titlespacing*{\\subsection}{0pt}{1.2ex plus 0.4ex minus 0.2ex}{0.6ex plus 0.1ex}
|
|
1672
|
+
\\usepackage{enumitem}
|
|
1673
|
+
\\setlist[itemize]{nosep, leftmargin=1.5em}
|
|
1674
|
+
\\setlist[enumerate]{nosep, leftmargin=1.5em}
|
|
1675
|
+
\\usepackage{parskip}
|
|
1676
|
+
\\usepackage{xcolor}
|
|
1677
|
+
\\usepackage{varwidth}
|
|
1678
|
+
\\definecolor{PiAnnotationBg}{HTML}{EAF3FF}
|
|
1679
|
+
\\definecolor{PiAnnotationBorder}{HTML}{8CB8FF}
|
|
1680
|
+
\\definecolor{PiAnnotationText}{HTML}{1F5FBF}
|
|
1681
|
+
\\definecolor{PiDiffAddText}{HTML}{1A7F37}
|
|
1682
|
+
\\definecolor{PiDiffDelText}{HTML}{CF222E}
|
|
1683
|
+
\\definecolor{PiDiffMetaText}{HTML}{57606A}
|
|
1684
|
+
\\definecolor{PiDiffHunkText}{HTML}{0969DA}
|
|
1685
|
+
\\newcommand{\\piannotation}[1]{\\begingroup\\setlength{\\fboxsep}{1.5pt}\\fcolorbox{PiAnnotationBorder}{PiAnnotationBg}{\\begin{varwidth}{\\dimexpr\\linewidth-2\\fboxsep-2\\fboxrule\\relax}\\raggedright\\textcolor{PiAnnotationText}{\\sffamily\\strut #1}\\end{varwidth}}\\endgroup}
|
|
1686
|
+
\\newcommand{\\PiDiffAddTok}[1]{\\textcolor{PiDiffAddText}{#1}}
|
|
1687
|
+
\\newcommand{\\PiDiffDelTok}[1]{\\textcolor{PiDiffDelText}{#1}}
|
|
1688
|
+
\\newcommand{\\PiDiffMetaTok}[1]{\\textcolor{PiDiffMetaText}{#1}}
|
|
1689
|
+
\\newcommand{\\PiDiffHunkTok}[1]{\\textcolor{PiDiffHunkText}{#1}}
|
|
1690
|
+
\\newcommand{\\PiDiffHeaderTok}[1]{\\textcolor{PiDiffHunkText}{\\textbf{#1}}}
|
|
1691
|
+
\\usepackage{fvextra}
|
|
1692
|
+
\\makeatletter
|
|
1693
|
+
\\@ifundefined{Highlighting}{%
|
|
1694
|
+
\\DefineVerbatimEnvironment{Highlighting}{Verbatim}{commandchars=\\\\\\{\\},breaklines,breakanywhere}%
|
|
1695
|
+
}{%
|
|
1696
|
+
\\RecustomVerbatimEnvironment{Highlighting}{Verbatim}{commandchars=\\\\\\{\\},breaklines,breakanywhere}%
|
|
1697
|
+
}
|
|
1698
|
+
\\makeatother
|
|
1699
|
+
`;
|
|
1700
|
+
|
|
1701
|
+
const PDF_PREAMBLE_PATH = join(CACHE_DIR, "_pdf_preamble.tex");
|
|
1702
|
+
|
|
1703
|
+
async function ensurePdfPreamble(): Promise<string> {
|
|
1704
|
+
await mkdir(CACHE_DIR, { recursive: true });
|
|
1705
|
+
await writeFile(PDF_PREAMBLE_PATH, PDF_PREAMBLE, "utf-8");
|
|
1706
|
+
return PDF_PREAMBLE_PATH;
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
async function compileLatexToPdf(latexSource: string, outputPath: string, resourcePath?: string): Promise<void> {
|
|
1710
|
+
const engine = process.env.PANDOC_PDF_ENGINE?.trim() || "xelatex";
|
|
1711
|
+
const tmpDir = join(CACHE_DIR, `_latex_${Date.now()}`);
|
|
1712
|
+
await mkdir(tmpDir, { recursive: true });
|
|
1713
|
+
|
|
1714
|
+
const texPath = join(tmpDir, "input.tex");
|
|
1715
|
+
await writeFile(texPath, latexSource, "utf-8");
|
|
1716
|
+
|
|
1717
|
+
// Symlink resource directory contents so \includegraphics can find figures
|
|
1718
|
+
if (resourcePath) {
|
|
1719
|
+
const { readdirSync } = await import("node:fs");
|
|
1720
|
+
try {
|
|
1721
|
+
for (const entry of readdirSync(resourcePath)) {
|
|
1722
|
+
const src = join(resourcePath, entry);
|
|
1723
|
+
const dest = join(tmpDir, entry);
|
|
1724
|
+
try { await import("node:fs/promises").then(fs => fs.symlink(src, dest)); } catch { /* ignore collisions */ }
|
|
1725
|
+
}
|
|
1726
|
+
} catch { /* resource dir unreadable, skip */ }
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
return await new Promise<void>((resolve, reject) => {
|
|
1730
|
+
// Run twice for cross-references (\ref, \eqref, \label)
|
|
1731
|
+
const runLatex = (pass: number) => {
|
|
1732
|
+
const child = spawn(engine, [
|
|
1733
|
+
"-interaction=nonstopmode",
|
|
1734
|
+
"-halt-on-error",
|
|
1735
|
+
"-output-directory", tmpDir,
|
|
1736
|
+
texPath,
|
|
1737
|
+
], { stdio: ["pipe", "pipe", "pipe"], cwd: tmpDir });
|
|
1738
|
+
|
|
1739
|
+
const stderrChunks: Buffer[] = [];
|
|
1740
|
+
const stdoutChunks: Buffer[] = [];
|
|
1741
|
+
child.stdout.on("data", (chunk: Buffer | string) => {
|
|
1742
|
+
stdoutChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
1743
|
+
});
|
|
1744
|
+
child.stderr.on("data", (chunk: Buffer | string) => {
|
|
1745
|
+
stderrChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
1746
|
+
});
|
|
1747
|
+
|
|
1748
|
+
child.once("error", (error) => {
|
|
1749
|
+
const errno = error as NodeJS.ErrnoException;
|
|
1750
|
+
if (errno.code === "ENOENT") {
|
|
1751
|
+
reject(new Error(
|
|
1752
|
+
`${engine} was not found. Install TeX Live (brew install --cask mactex) or set PANDOC_PDF_ENGINE.`,
|
|
1753
|
+
));
|
|
1754
|
+
return;
|
|
1755
|
+
}
|
|
1756
|
+
reject(error);
|
|
1757
|
+
});
|
|
1758
|
+
|
|
1759
|
+
child.once("close", (code) => {
|
|
1760
|
+
if (code !== 0 && pass === 2) {
|
|
1761
|
+
const log = Buffer.concat(stdoutChunks).toString("utf-8");
|
|
1762
|
+
// Extract the first LaTeX error line for a useful message
|
|
1763
|
+
const errorMatch = log.match(/^! .+$/m);
|
|
1764
|
+
const hint = errorMatch ? errorMatch[0] : "";
|
|
1765
|
+
reject(new Error(`${engine} failed (exit ${code})${hint ? `: ${hint}` : ""}`));
|
|
1766
|
+
return;
|
|
1767
|
+
}
|
|
1768
|
+
if (pass === 1) {
|
|
1769
|
+
runLatex(2);
|
|
1770
|
+
} else {
|
|
1771
|
+
// Copy PDF to output path
|
|
1772
|
+
const generatedPdf = join(tmpDir, "input.pdf");
|
|
1773
|
+
import("node:fs/promises").then(fs =>
|
|
1774
|
+
fs.copyFile(generatedPdf, outputPath).then(() => resolve())
|
|
1775
|
+
).catch(reject);
|
|
1776
|
+
}
|
|
1777
|
+
});
|
|
1778
|
+
|
|
1779
|
+
child.stdin.end();
|
|
1780
|
+
};
|
|
1781
|
+
|
|
1782
|
+
runLatex(1);
|
|
1783
|
+
});
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
async function renderMarkdownToPdf(markdown: string, outputPath: string, resourcePath?: string): Promise<void> {
|
|
1787
|
+
const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
|
|
1788
|
+
const pandocInput = normalizeMarkdownFencedBlocks(markdown);
|
|
1789
|
+
const pdfEngine = process.env.PANDOC_PDF_ENGINE?.trim() || "xelatex";
|
|
1790
|
+
const preamblePath = await ensurePdfPreamble();
|
|
1791
|
+
const args = [
|
|
1792
|
+
"-f", "markdown+lists_without_preceding_blankline-blank_before_blockquote-blank_before_header+tex_math_dollars+autolink_bare_uris+superscript+subscript-raw_html",
|
|
1793
|
+
"-o", outputPath,
|
|
1794
|
+
`--pdf-engine=${pdfEngine}`,
|
|
1795
|
+
"-V", "geometry:margin=2.2cm",
|
|
1796
|
+
"-V", "fontsize=11pt",
|
|
1797
|
+
"-V", "linestretch=1.25",
|
|
1798
|
+
"-V", "urlcolor=blue",
|
|
1799
|
+
"-V", "linkcolor=blue",
|
|
1800
|
+
"--include-in-header", preamblePath,
|
|
1801
|
+
];
|
|
1802
|
+
if (resourcePath) args.push(`--resource-path=${resourcePath}`);
|
|
1803
|
+
|
|
1804
|
+
return await new Promise<void>((resolve, reject) => {
|
|
1805
|
+
const child = spawn(pandocCommand, args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
1806
|
+
const stderrChunks: Buffer[] = [];
|
|
1807
|
+
let settled = false;
|
|
1808
|
+
|
|
1809
|
+
const fail = (error: Error) => {
|
|
1810
|
+
if (settled) return;
|
|
1811
|
+
settled = true;
|
|
1812
|
+
reject(error);
|
|
1813
|
+
};
|
|
1814
|
+
|
|
1815
|
+
child.stderr.on("data", (chunk: Buffer | string) => {
|
|
1816
|
+
stderrChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
1817
|
+
});
|
|
1818
|
+
|
|
1819
|
+
child.once("error", (error) => {
|
|
1820
|
+
const errno = error as NodeJS.ErrnoException;
|
|
1821
|
+
if (errno.code === "ENOENT") {
|
|
1822
|
+
fail(
|
|
1823
|
+
new Error(
|
|
1824
|
+
`pandoc was not found. Install pandoc or set PANDOC_PATH to the pandoc binary.`,
|
|
1825
|
+
),
|
|
1826
|
+
);
|
|
1827
|
+
return;
|
|
1828
|
+
}
|
|
1829
|
+
fail(error);
|
|
1830
|
+
});
|
|
1831
|
+
|
|
1832
|
+
child.once("close", (code) => {
|
|
1833
|
+
if (settled) return;
|
|
1834
|
+
settled = true;
|
|
1835
|
+
if (code === 0) {
|
|
1836
|
+
resolve();
|
|
1837
|
+
return;
|
|
1838
|
+
}
|
|
1839
|
+
const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
|
|
1840
|
+
const hint = stderr.includes("not found") || stderr.includes("pdflatex") || stderr.includes("xelatex")
|
|
1841
|
+
? "\nPDF export requires a LaTeX engine. Install TeX Live (brew install --cask mactex / apt install texlive) or set PANDOC_PDF_ENGINE to your preferred engine."
|
|
1842
|
+
: "";
|
|
1843
|
+
fail(new Error(`pandoc PDF export failed with exit code ${code}${stderr ? `: ${stderr}` : ""}${hint}`));
|
|
1844
|
+
});
|
|
1845
|
+
|
|
1846
|
+
child.stdin.end(pandocInput);
|
|
1847
|
+
});
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
function isGeneratedDiffHighlightingBlock(lines: string[]): boolean {
|
|
1851
|
+
const body = lines.join("\n");
|
|
1852
|
+
const hasAdditionOrDeletion = /\\VariableTok\{\+|\\StringTok\{\{-\}/.test(body);
|
|
1853
|
+
const hasDiffStructure = /\\DataTypeTok\{@@|\\NormalTok\{diff \{-\}\{-\}git |\\KeywordTok\{\{-\}\{-\}\{-\}|\\DataTypeTok\{\+\+\+/.test(body);
|
|
1854
|
+
return hasAdditionOrDeletion && hasDiffStructure;
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
function decodeGeneratedLatexCodeText(text: string): string {
|
|
1858
|
+
return String(text ?? "")
|
|
1859
|
+
.replace(/\\textbackslash\{\}/g, "\\")
|
|
1860
|
+
.replace(/\\textasciigrave\{\}/g, "`")
|
|
1861
|
+
.replace(/\\textasciitilde\{\}/g, "~")
|
|
1862
|
+
.replace(/\\textasciicircum\{\}/g, "^")
|
|
1863
|
+
.replace(/\\\^\{\}/g, "^")
|
|
1864
|
+
.replace(/\\~\{\}/g, "~")
|
|
1865
|
+
.replace(/\\([{}_#$%&])/g, "$1");
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
function readVerbatimMathOperand(expr: string, startIndex: number): { operand: string; nextIndex: number } | null {
|
|
1869
|
+
if (startIndex >= expr.length) return null;
|
|
1870
|
+
const first = expr[startIndex]!;
|
|
1871
|
+
|
|
1872
|
+
if (first === "{") {
|
|
1873
|
+
let depth = 1;
|
|
1874
|
+
let index = startIndex + 1;
|
|
1875
|
+
while (index < expr.length) {
|
|
1876
|
+
const char = expr[index]!;
|
|
1877
|
+
if (char === "{") {
|
|
1878
|
+
depth += 1;
|
|
1879
|
+
} else if (char === "}") {
|
|
1880
|
+
depth -= 1;
|
|
1881
|
+
if (depth === 0) {
|
|
1882
|
+
return {
|
|
1883
|
+
operand: expr.slice(startIndex + 1, index),
|
|
1884
|
+
nextIndex: index + 1,
|
|
1885
|
+
};
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
index += 1;
|
|
1889
|
+
}
|
|
1890
|
+
return {
|
|
1891
|
+
operand: expr.slice(startIndex + 1),
|
|
1892
|
+
nextIndex: expr.length,
|
|
1893
|
+
};
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
if (first === "\\") {
|
|
1897
|
+
let index = startIndex + 1;
|
|
1898
|
+
while (index < expr.length && /[A-Za-z]/.test(expr[index]!)) {
|
|
1899
|
+
index += 1;
|
|
1900
|
+
}
|
|
1901
|
+
if (index === startIndex + 1 && index < expr.length) {
|
|
1902
|
+
index += 1;
|
|
1903
|
+
}
|
|
1904
|
+
return {
|
|
1905
|
+
operand: expr.slice(startIndex, index),
|
|
1906
|
+
nextIndex: index,
|
|
1907
|
+
};
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
return {
|
|
1911
|
+
operand: first,
|
|
1912
|
+
nextIndex: startIndex + 1,
|
|
1913
|
+
};
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
function makeHighlightingMathScriptsVerbatimSafe(text: string): string {
|
|
1917
|
+
const rewriteExpr = (expr: string): string => {
|
|
1918
|
+
let out = "";
|
|
1919
|
+
for (let index = 0; index < expr.length; index += 1) {
|
|
1920
|
+
const char = expr[index]!;
|
|
1921
|
+
if (char !== "_" && char !== "^") {
|
|
1922
|
+
out += char;
|
|
1923
|
+
continue;
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
const operand = readVerbatimMathOperand(expr, index + 1);
|
|
1927
|
+
if (!operand || !operand.operand) {
|
|
1928
|
+
out += char;
|
|
1929
|
+
continue;
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
out += char === "_" ? `\\sb{${operand.operand}}` : `\\sp{${operand.operand}}`;
|
|
1933
|
+
index = operand.nextIndex - 1;
|
|
1934
|
+
}
|
|
1935
|
+
return out;
|
|
1936
|
+
};
|
|
1937
|
+
|
|
1938
|
+
return String(text ?? "")
|
|
1939
|
+
.replace(/\\\(([\s\S]*?)\\\)/g, (_match, expr: string) => `\\(${rewriteExpr(expr)}\\)`)
|
|
1940
|
+
.replace(/\\\[([\s\S]*?)\\\]/g, (_match, expr: string) => `\\[${rewriteExpr(expr)}\\]`)
|
|
1941
|
+
.replace(/\$\$([\s\S]*?)\$\$/g, (_match, expr: string) => `$$${rewriteExpr(expr)}$$`)
|
|
1942
|
+
.replace(/\$([^$\n]+?)\$/g, (_match, expr: string) => `$${rewriteExpr(expr)}$`);
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
function replaceAnnotationMarkersInDiffTokenLine(line: string, macroName: string): string {
|
|
1946
|
+
const tokenMatch = line.match(new RegExp(`^\\\\${macroName}\\{([\\s\\S]*)\\}$`));
|
|
1947
|
+
if (!tokenMatch) return line;
|
|
1948
|
+
|
|
1949
|
+
const body = tokenMatch[1] ?? "";
|
|
1950
|
+
const wrapText = (text: string): string => text ? `\\${macroName}{${text}}` : "";
|
|
1951
|
+
const rewritten = replaceInlineAnnotationMarkers(
|
|
1952
|
+
body,
|
|
1953
|
+
(marker: { body: string }) => {
|
|
1954
|
+
const markerText = decodeGeneratedLatexCodeText(normalizeAnnotationText(marker.body));
|
|
1955
|
+
const cleaned = makeHighlightingMathScriptsVerbatimSafe(renderAnnotationPdfLatex(markerText));
|
|
1956
|
+
if (!cleaned) return "";
|
|
1957
|
+
return `\\piannotation{${cleaned}}`;
|
|
1958
|
+
},
|
|
1959
|
+
(segment: string) => wrapText(segment),
|
|
1960
|
+
);
|
|
1961
|
+
|
|
1962
|
+
return rewritten === body ? line : (rewritten || wrapText(body));
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
function rewriteGeneratedDiffHighlighting(latex: string): string {
|
|
1966
|
+
const lines = String(latex ?? "").split("\n");
|
|
1967
|
+
const out: string[] = [];
|
|
1968
|
+
|
|
1969
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
1970
|
+
const line = lines[index] ?? "";
|
|
1971
|
+
if (!/^\\begin\{Highlighting\}/.test(line)) {
|
|
1972
|
+
out.push(line);
|
|
1973
|
+
continue;
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
let closingIndex = -1;
|
|
1977
|
+
for (let innerIndex = index + 1; innerIndex < lines.length; innerIndex += 1) {
|
|
1978
|
+
if (/^\\end\{Highlighting\}/.test(lines[innerIndex] ?? "")) {
|
|
1979
|
+
closingIndex = innerIndex;
|
|
1980
|
+
break;
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
if (closingIndex === -1) {
|
|
1985
|
+
out.push(line);
|
|
1986
|
+
continue;
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
const blockLines = lines.slice(index, closingIndex + 1);
|
|
1990
|
+
if (!isGeneratedDiffHighlightingBlock(blockLines)) {
|
|
1991
|
+
out.push(...blockLines);
|
|
1992
|
+
index = closingIndex;
|
|
1993
|
+
continue;
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
const rewrittenBlock = blockLines.map((blockLine) => {
|
|
1997
|
+
if (/^\\VariableTok\{/.test(blockLine)) {
|
|
1998
|
+
return replaceAnnotationMarkersInDiffTokenLine(
|
|
1999
|
+
blockLine.replace(/^\\VariableTok\{/, "\\PiDiffAddTok{"),
|
|
2000
|
+
"PiDiffAddTok",
|
|
2001
|
+
);
|
|
2002
|
+
}
|
|
2003
|
+
if (/^\\StringTok\{/.test(blockLine)) {
|
|
2004
|
+
return replaceAnnotationMarkersInDiffTokenLine(
|
|
2005
|
+
blockLine.replace(/^\\StringTok\{/, "\\PiDiffDelTok{"),
|
|
2006
|
+
"PiDiffDelTok",
|
|
2007
|
+
);
|
|
2008
|
+
}
|
|
2009
|
+
if (/^\\DataTypeTok\{@@/.test(blockLine)) return blockLine.replace(/^\\DataTypeTok\{/, "\\PiDiffHunkTok{");
|
|
2010
|
+
if (/^\\DataTypeTok\{\+\+\+/.test(blockLine)) return blockLine.replace(/^\\DataTypeTok\{/, "\\PiDiffHeaderTok{");
|
|
2011
|
+
if (/^\\KeywordTok\{\{-\}\{-\}\{-\}/.test(blockLine)) return blockLine.replace(/^\\KeywordTok\{/, "\\PiDiffHeaderTok{");
|
|
2012
|
+
if (/^\\NormalTok\{(?:diff \{-\}\{-\}git |index |new file mode |deleted file mode |similarity index |rename from |rename to |Binary files )/.test(blockLine)) {
|
|
2013
|
+
return replaceAnnotationMarkersInDiffTokenLine(
|
|
2014
|
+
blockLine.replace(/^\\NormalTok\{/, "\\PiDiffMetaTok{"),
|
|
2015
|
+
"PiDiffMetaTok",
|
|
2016
|
+
);
|
|
2017
|
+
}
|
|
2018
|
+
return blockLine;
|
|
2019
|
+
});
|
|
2020
|
+
|
|
2021
|
+
out.push(...rewrittenBlock);
|
|
2022
|
+
index = closingIndex;
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
return out.join("\n");
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
async function renderMarkdownToPdfViaGeneratedLatex(markdown: string, outputPath: string, resourcePath?: string): Promise<void> {
|
|
2029
|
+
const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
|
|
2030
|
+
const pandocInput = normalizeMarkdownFencedBlocks(markdown);
|
|
2031
|
+
const preamblePath = await ensurePdfPreamble();
|
|
2032
|
+
const args = [
|
|
2033
|
+
"-f", "markdown+lists_without_preceding_blankline-blank_before_blockquote-blank_before_header+tex_math_dollars+autolink_bare_uris+superscript+subscript-raw_html",
|
|
2034
|
+
"-t", "latex",
|
|
2035
|
+
"-s",
|
|
2036
|
+
"-V", "geometry:margin=2.2cm",
|
|
2037
|
+
"-V", "fontsize=11pt",
|
|
2038
|
+
"-V", "linestretch=1.25",
|
|
2039
|
+
"-V", "urlcolor=blue",
|
|
2040
|
+
"-V", "linkcolor=blue",
|
|
2041
|
+
"--include-in-header", preamblePath,
|
|
2042
|
+
];
|
|
2043
|
+
if (resourcePath) args.push(`--resource-path=${resourcePath}`);
|
|
2044
|
+
|
|
2045
|
+
const generatedLatex = await new Promise<string>((resolve, reject) => {
|
|
2046
|
+
const child = spawn(pandocCommand, args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
2047
|
+
const stdoutChunks: Buffer[] = [];
|
|
2048
|
+
const stderrChunks: Buffer[] = [];
|
|
2049
|
+
let settled = false;
|
|
2050
|
+
|
|
2051
|
+
const fail = (error: Error) => {
|
|
2052
|
+
if (settled) return;
|
|
2053
|
+
settled = true;
|
|
2054
|
+
reject(error);
|
|
2055
|
+
};
|
|
2056
|
+
|
|
2057
|
+
child.stdout.on("data", (chunk: Buffer | string) => {
|
|
2058
|
+
stdoutChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
2059
|
+
});
|
|
2060
|
+
child.stderr.on("data", (chunk: Buffer | string) => {
|
|
2061
|
+
stderrChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
2062
|
+
});
|
|
2063
|
+
|
|
2064
|
+
child.once("error", (error) => {
|
|
2065
|
+
const errno = error as NodeJS.ErrnoException;
|
|
2066
|
+
if (errno.code === "ENOENT") {
|
|
2067
|
+
fail(
|
|
2068
|
+
new Error(
|
|
2069
|
+
`pandoc was not found. Install pandoc or set PANDOC_PATH to the pandoc binary.`,
|
|
2070
|
+
),
|
|
2071
|
+
);
|
|
2072
|
+
return;
|
|
2073
|
+
}
|
|
2074
|
+
fail(error);
|
|
2075
|
+
});
|
|
2076
|
+
|
|
2077
|
+
child.once("close", (code) => {
|
|
2078
|
+
if (settled) return;
|
|
2079
|
+
if (code === 0) {
|
|
2080
|
+
settled = true;
|
|
2081
|
+
resolve(Buffer.concat(stdoutChunks).toString("utf-8"));
|
|
2082
|
+
return;
|
|
2083
|
+
}
|
|
2084
|
+
const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
|
|
2085
|
+
fail(new Error(`pandoc LaTeX generation failed with exit code ${code}${stderr ? `: ${stderr}` : ""}`));
|
|
2086
|
+
});
|
|
2087
|
+
|
|
2088
|
+
child.stdin.end(pandocInput);
|
|
2089
|
+
});
|
|
2090
|
+
|
|
2091
|
+
await compileLatexToPdf(rewriteGeneratedDiffHighlighting(generatedLatex), outputPath, resourcePath);
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
class MermaidCliMissingError extends Error {}
|
|
2095
|
+
|
|
2096
|
+
interface MermaidPdfPreprocessResult {
|
|
2097
|
+
markdown: string;
|
|
2098
|
+
found: number;
|
|
2099
|
+
replaced: number;
|
|
2100
|
+
failed: number;
|
|
2101
|
+
missingCli: boolean;
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
function getMermaidPdfTheme(): "default" | "forest" | "dark" | "neutral" {
|
|
2105
|
+
const requested = process.env.MERMAID_PDF_THEME?.trim().toLowerCase();
|
|
2106
|
+
if (requested === "default" || requested === "forest" || requested === "dark" || requested === "neutral") {
|
|
2107
|
+
return requested;
|
|
2108
|
+
}
|
|
2109
|
+
return "default";
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
async function renderMermaidDiagramForPdf(source: string, outputPath: string): Promise<void> {
|
|
2113
|
+
const mermaidCommand = process.env.MERMAID_CLI_PATH?.trim() || "mmdc";
|
|
2114
|
+
const mermaidTheme = getMermaidPdfTheme();
|
|
2115
|
+
const tempDir = await mkdtemp(join(tmpdir(), "pi-markdown-preview-mermaid-"));
|
|
2116
|
+
const inputPath = join(tempDir, "diagram.mmd");
|
|
2117
|
+
|
|
2118
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
2119
|
+
|
|
2120
|
+
try {
|
|
2121
|
+
await writeFile(inputPath, source, "utf-8");
|
|
2122
|
+
await new Promise<void>((resolve, reject) => {
|
|
2123
|
+
const args = ["-i", inputPath, "-o", outputPath, "-t", mermaidTheme, "-f"];
|
|
2124
|
+
const child = spawn(mermaidCommand, args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
2125
|
+
const stderrChunks: Buffer[] = [];
|
|
2126
|
+
let settled = false;
|
|
2127
|
+
|
|
2128
|
+
const fail = (error: Error) => {
|
|
2129
|
+
if (settled) return;
|
|
2130
|
+
settled = true;
|
|
2131
|
+
reject(error);
|
|
2132
|
+
};
|
|
2133
|
+
|
|
2134
|
+
child.stderr.on("data", (chunk: Buffer | string) => {
|
|
2135
|
+
stderrChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
2136
|
+
});
|
|
2137
|
+
|
|
2138
|
+
child.once("error", (error) => {
|
|
2139
|
+
const errno = error as NodeJS.ErrnoException;
|
|
2140
|
+
if (errno.code === "ENOENT") {
|
|
2141
|
+
fail(
|
|
2142
|
+
new MermaidCliMissingError(
|
|
2143
|
+
"Mermaid CLI (mmdc) not found. Install with `npm install -g @mermaid-js/mermaid-cli` or set MERMAID_CLI_PATH.",
|
|
2144
|
+
),
|
|
2145
|
+
);
|
|
2146
|
+
return;
|
|
2147
|
+
}
|
|
2148
|
+
fail(error);
|
|
2149
|
+
});
|
|
2150
|
+
|
|
2151
|
+
child.once("close", (code) => {
|
|
2152
|
+
if (settled) return;
|
|
2153
|
+
settled = true;
|
|
2154
|
+
if (code === 0) {
|
|
2155
|
+
resolve();
|
|
2156
|
+
return;
|
|
2157
|
+
}
|
|
2158
|
+
const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
|
|
2159
|
+
reject(new Error(`Mermaid CLI failed with exit code ${code}${stderr ? `: ${stderr}` : ""}`));
|
|
2160
|
+
});
|
|
2161
|
+
});
|
|
2162
|
+
} finally {
|
|
2163
|
+
await rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
async function preprocessMermaidForPdf(markdown: string): Promise<MermaidPdfPreprocessResult> {
|
|
2168
|
+
const mermaidRegex = /```mermaid[^\n]*\n([\s\S]*?)```/gi;
|
|
2169
|
+
const matches: Array<{ start: number; end: number; raw: string; source: string; number: number }> = [];
|
|
2170
|
+
let match: RegExpExecArray | null;
|
|
2171
|
+
let blockNumber = 1;
|
|
2172
|
+
|
|
2173
|
+
while ((match = mermaidRegex.exec(markdown)) !== null) {
|
|
2174
|
+
const raw = match[0]!;
|
|
2175
|
+
const source = (match[1] ?? "").trimEnd();
|
|
2176
|
+
matches.push({
|
|
2177
|
+
start: match.index,
|
|
2178
|
+
end: match.index + raw.length,
|
|
2179
|
+
raw,
|
|
2180
|
+
source,
|
|
2181
|
+
number: blockNumber++,
|
|
2182
|
+
});
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
if (matches.length === 0) {
|
|
2186
|
+
return {
|
|
2187
|
+
markdown,
|
|
2188
|
+
found: 0,
|
|
2189
|
+
replaced: 0,
|
|
2190
|
+
failed: 0,
|
|
2191
|
+
missingCli: false,
|
|
2192
|
+
};
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
await mkdir(MERMAID_PDF_CACHE_DIR, { recursive: true });
|
|
2196
|
+
|
|
2197
|
+
const renderedBySource = new Map<string, string | null>();
|
|
2198
|
+
let missingCli = false;
|
|
2199
|
+
const mermaidTheme = getMermaidPdfTheme();
|
|
2200
|
+
|
|
2201
|
+
for (const block of matches) {
|
|
2202
|
+
if (renderedBySource.has(block.source)) continue;
|
|
2203
|
+
|
|
2204
|
+
const hash = createHash("sha256")
|
|
2205
|
+
.update(RENDER_VERSION)
|
|
2206
|
+
.update("\u0000")
|
|
2207
|
+
.update("pdf-mermaid")
|
|
2208
|
+
.update("\u0000")
|
|
2209
|
+
.update(mermaidTheme)
|
|
2210
|
+
.update("\u0000")
|
|
2211
|
+
.update(block.source)
|
|
2212
|
+
.digest("hex");
|
|
2213
|
+
const outputPath = join(MERMAID_PDF_CACHE_DIR, `${hash}.pdf`);
|
|
2214
|
+
|
|
2215
|
+
if (existsSync(outputPath)) {
|
|
2216
|
+
renderedBySource.set(block.source, outputPath);
|
|
2217
|
+
continue;
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
if (missingCli) {
|
|
2221
|
+
renderedBySource.set(block.source, null);
|
|
2222
|
+
continue;
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
try {
|
|
2226
|
+
await renderMermaidDiagramForPdf(block.source, outputPath);
|
|
2227
|
+
renderedBySource.set(block.source, outputPath);
|
|
2228
|
+
} catch (error) {
|
|
2229
|
+
if (error instanceof MermaidCliMissingError) {
|
|
2230
|
+
missingCli = true;
|
|
2231
|
+
}
|
|
2232
|
+
renderedBySource.set(block.source, null);
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
let transformed = "";
|
|
2237
|
+
let cursor = 0;
|
|
2238
|
+
let replaced = 0;
|
|
2239
|
+
let failed = 0;
|
|
2240
|
+
|
|
2241
|
+
for (const block of matches) {
|
|
2242
|
+
transformed += markdown.slice(cursor, block.start);
|
|
2243
|
+
const renderedPath = renderedBySource.get(block.source) ?? null;
|
|
2244
|
+
if (renderedPath) {
|
|
2245
|
+
replaced++;
|
|
2246
|
+
const imageRef = pathToFileURL(renderedPath).href;
|
|
2247
|
+
transformed += `\n\n`;
|
|
2248
|
+
} else {
|
|
2249
|
+
failed++;
|
|
2250
|
+
transformed += block.raw;
|
|
2251
|
+
}
|
|
2252
|
+
cursor = block.end;
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
transformed += markdown.slice(cursor);
|
|
2256
|
+
|
|
2257
|
+
return {
|
|
2258
|
+
markdown: transformed,
|
|
2259
|
+
found: matches.length,
|
|
2260
|
+
replaced,
|
|
2261
|
+
failed,
|
|
2262
|
+
missingCli,
|
|
2263
|
+
};
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
async function exportPdf(ctx: ExtensionCommandContext, markdownOverride?: string, resourcePath?: string, isLatex?: boolean): Promise<void> {
|
|
2267
|
+
const markdown = markdownOverride ?? getLastAssistantMarkdown(ctx);
|
|
2268
|
+
if (!markdown) {
|
|
2269
|
+
ctx.ui.notify("No assistant markdown found in the current branch.", "warning");
|
|
2270
|
+
return;
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
const normalizedMarkdown = isLatex
|
|
2274
|
+
? markdown
|
|
2275
|
+
: normalizeSubSupTags(normalizeMarkdownFencedBlocks(normalizeObsidianImages(normalizeMathDelimiters(markdown))));
|
|
2276
|
+
const mermaidPrepared = isLatex ? { markdown: normalizedMarkdown, found: 0, replaced: 0, failed: 0, missingCli: false } : await preprocessMermaidForPdf(normalizedMarkdown);
|
|
2277
|
+
|
|
2278
|
+
if (mermaidPrepared.missingCli) {
|
|
2279
|
+
ctx.ui.notify(
|
|
2280
|
+
"Mermaid CLI (mmdc) not found; Mermaid blocks are kept as code in PDF. Install @mermaid-js/mermaid-cli or set MERMAID_CLI_PATH.",
|
|
2281
|
+
"warning",
|
|
2282
|
+
);
|
|
2283
|
+
} else if (mermaidPrepared.failed > 0) {
|
|
2284
|
+
ctx.ui.notify(
|
|
2285
|
+
`Failed to render ${mermaidPrepared.failed} Mermaid block${mermaidPrepared.failed === 1 ? "" : "s"} for PDF. Unrendered blocks are kept as code.`,
|
|
2286
|
+
"warning",
|
|
2287
|
+
);
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
const markdownForPdf = isLatex ? mermaidPrepared.markdown : highlightAnnotationMarkersForPdf(mermaidPrepared.markdown);
|
|
2291
|
+
const hash = createHash("sha256")
|
|
2292
|
+
.update(RENDER_VERSION)
|
|
2293
|
+
.update("\u0000")
|
|
2294
|
+
.update("pdf")
|
|
2295
|
+
.update("\u0000")
|
|
2296
|
+
.update(markdownForPdf)
|
|
2297
|
+
.digest("hex");
|
|
2298
|
+
const pdfPath = join(CACHE_DIR, `${hash}.pdf`);
|
|
2299
|
+
|
|
2300
|
+
await mkdir(CACHE_DIR, { recursive: true });
|
|
2301
|
+
if (isLatex) {
|
|
2302
|
+
await compileLatexToPdf(markdownForPdf, pdfPath, resourcePath);
|
|
2303
|
+
} else if (hasMarkdownDiffFence(markdownForPdf)) {
|
|
2304
|
+
await renderMarkdownToPdfViaGeneratedLatex(markdownForPdf, pdfPath, resourcePath);
|
|
2305
|
+
} else {
|
|
2306
|
+
await renderMarkdownToPdf(markdownForPdf, pdfPath, resourcePath);
|
|
2307
|
+
}
|
|
2308
|
+
await openFileInDefaultBrowser(pdfPath);
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
function buildBrowserHtmlFromPandocFragment(
|
|
2312
|
+
fragmentHtml: string,
|
|
2313
|
+
style: PreviewStyle,
|
|
2314
|
+
resourcePath?: string,
|
|
2315
|
+
annotationPlaceholders: PreviewAnnotationPlaceholder[] = [],
|
|
2316
|
+
): string {
|
|
2317
|
+
const palette = style.palette;
|
|
2318
|
+
const baseTag = resourcePath ? `\n<base href="${pathToFileURL(resourcePath + "/").href}" />` : "";
|
|
2319
|
+
const annotationHelpersScript = ANNOTATION_HELPERS_SOURCE.replace(/<\/script/gi, "<\\/script");
|
|
2320
|
+
const annotationPlaceholdersJson = JSON.stringify(annotationPlaceholders).replace(/</g, "\\u003c");
|
|
2321
|
+
return `<!doctype html>
|
|
2322
|
+
<html>
|
|
2323
|
+
<head>
|
|
2324
|
+
<meta charset="utf-8" />${baseTag}
|
|
2325
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
2326
|
+
<title>Markdown Preview</title>
|
|
2327
|
+
<style>
|
|
2328
|
+
:root {
|
|
2329
|
+
--bg: ${palette.bg};
|
|
2330
|
+
--card: ${palette.card};
|
|
2331
|
+
--border: ${palette.border};
|
|
2332
|
+
--text: ${palette.text};
|
|
2333
|
+
--muted: ${palette.muted};
|
|
2334
|
+
--code-bg: ${palette.codeBg};
|
|
2335
|
+
--link: ${palette.link};
|
|
2336
|
+
--syntax-keyword: ${palette.syntaxKeyword};
|
|
2337
|
+
--syntax-function: ${palette.syntaxFunction};
|
|
2338
|
+
--syntax-variable: ${palette.syntaxVariable};
|
|
2339
|
+
--syntax-string: ${palette.syntaxString};
|
|
2340
|
+
--syntax-number: ${palette.syntaxNumber};
|
|
2341
|
+
--syntax-type: ${palette.syntaxType};
|
|
2342
|
+
--syntax-comment: ${palette.syntaxComment};
|
|
2343
|
+
--syntax-operator: ${palette.syntaxOperator};
|
|
2344
|
+
--syntax-punctuation: ${palette.syntaxPunctuation};
|
|
2345
|
+
--syntax-error: ${style.themeMode === "dark" ? "#ff7b72" : "#cf222e"};
|
|
2346
|
+
--annotation-bg: ${style.themeMode === "dark" ? "rgba(88, 166, 255, 0.22)" : "rgba(9, 105, 218, 0.14)"};
|
|
2347
|
+
--annotation-border: ${style.themeMode === "dark" ? "rgba(88, 166, 255, 0.62)" : "rgba(9, 105, 218, 0.40)"};
|
|
2348
|
+
--annotation-text: ${style.themeMode === "dark" ? "#e6edf3" : "#1f2328"};
|
|
2349
|
+
--diff-add-bg: ${style.themeMode === "dark" ? "rgba(46, 160, 67, 0.18)" : "rgba(26, 127, 55, 0.12)"};
|
|
2350
|
+
--diff-add-text: ${style.themeMode === "dark" ? "#3fb950" : "#1a7f37"};
|
|
2351
|
+
--diff-del-bg: ${style.themeMode === "dark" ? "rgba(248, 81, 73, 0.18)" : "rgba(207, 34, 46, 0.12)"};
|
|
2352
|
+
--diff-del-text: ${style.themeMode === "dark" ? "#ff7b72" : "#cf222e"};
|
|
2353
|
+
--diff-meta-text: ${style.themeMode === "dark" ? "#9da7b5" : "#57606a"};
|
|
2354
|
+
--diff-header-bg: ${style.themeMode === "dark" ? "rgba(88, 166, 255, 0.10)" : "rgba(9, 105, 218, 0.08)"};
|
|
2355
|
+
--diff-header-text: ${style.themeMode === "dark" ? "#79c0ff" : "#0969da"};
|
|
2356
|
+
--diff-hunk-bg: ${style.themeMode === "dark" ? "rgba(88, 166, 255, 0.16)" : "rgba(9, 105, 218, 0.12)"};
|
|
2357
|
+
--diff-hunk-text: ${style.themeMode === "dark" ? "#79c0ff" : "#0969da"};
|
|
2358
|
+
}
|
|
2359
|
+
* { box-sizing: border-box; }
|
|
2360
|
+
html, body {
|
|
2361
|
+
margin: 0;
|
|
2362
|
+
padding: 0;
|
|
2363
|
+
background: var(--bg);
|
|
2364
|
+
color: var(--text);
|
|
2365
|
+
}
|
|
2366
|
+
body {
|
|
2367
|
+
min-height: 100vh;
|
|
2368
|
+
padding: 28px;
|
|
2369
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
|
2370
|
+
}
|
|
2371
|
+
#preview-root {
|
|
2372
|
+
width: min(1100px, 100%);
|
|
2373
|
+
margin: 0 auto;
|
|
2374
|
+
background: var(--card);
|
|
2375
|
+
border: 1px solid var(--border);
|
|
2376
|
+
border-radius: 10px;
|
|
2377
|
+
padding: 24px 28px;
|
|
2378
|
+
overflow-wrap: anywhere;
|
|
2379
|
+
line-height: 1.58;
|
|
2380
|
+
font-size: 16px;
|
|
2381
|
+
}
|
|
2382
|
+
#preview-root h1, #preview-root h2, #preview-root h3, #preview-root h4, #preview-root h5, #preview-root h6 {
|
|
2383
|
+
margin-top: 1.2em;
|
|
2384
|
+
margin-bottom: 0.5em;
|
|
2385
|
+
line-height: 1.25;
|
|
2386
|
+
}
|
|
2387
|
+
#preview-root h1 { font-size: 2em; border-bottom: 1px solid var(--border); padding-bottom: 0.3em; }
|
|
2388
|
+
#preview-root h2 { font-size: 1.5em; border-bottom: 1px solid var(--border); padding-bottom: 0.25em; }
|
|
2389
|
+
#preview-root p, #preview-root ul, #preview-root ol, #preview-root blockquote, #preview-root table {
|
|
2390
|
+
margin-top: 0;
|
|
2391
|
+
margin-bottom: 1em;
|
|
2392
|
+
}
|
|
2393
|
+
#preview-root a { color: var(--link); text-decoration: none; }
|
|
2394
|
+
#preview-root a:hover { text-decoration: underline; }
|
|
2395
|
+
#preview-root blockquote {
|
|
2396
|
+
margin-left: 0;
|
|
2397
|
+
padding: 0 1em;
|
|
2398
|
+
border-left: 0.25em solid var(--border);
|
|
2399
|
+
color: var(--muted);
|
|
2400
|
+
}
|
|
2401
|
+
#preview-root pre {
|
|
2402
|
+
background: var(--code-bg);
|
|
2403
|
+
border: 1px solid var(--border);
|
|
2404
|
+
border-radius: 8px;
|
|
2405
|
+
padding: 12px 14px;
|
|
2406
|
+
overflow: auto;
|
|
2407
|
+
}
|
|
2408
|
+
#preview-root code {
|
|
2409
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
2410
|
+
font-size: 0.9em;
|
|
2411
|
+
}
|
|
2412
|
+
#preview-root :not(pre) > code {
|
|
2413
|
+
background: var(--code-bg);
|
|
2414
|
+
border: 1px solid var(--border);
|
|
2415
|
+
border-radius: 6px;
|
|
2416
|
+
padding: 0.12em 0.35em;
|
|
2417
|
+
}
|
|
2418
|
+
#preview-root .annotation-marker {
|
|
2419
|
+
display: inline;
|
|
2420
|
+
border-radius: 4px;
|
|
2421
|
+
border: 1px solid var(--annotation-border);
|
|
2422
|
+
background: var(--annotation-bg);
|
|
2423
|
+
color: var(--annotation-text);
|
|
2424
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
|
2425
|
+
padding: 0 0.28em;
|
|
2426
|
+
}
|
|
2427
|
+
#preview-root .annotation-marker mjx-container {
|
|
2428
|
+
margin: 0;
|
|
2429
|
+
}
|
|
2430
|
+
#preview-root pre.sourceCode.diff code > .diff-line {
|
|
2431
|
+
display: block;
|
|
2432
|
+
margin: 0 -4px;
|
|
2433
|
+
padding: 0 4px;
|
|
2434
|
+
border-radius: 4px;
|
|
2435
|
+
}
|
|
2436
|
+
#preview-root pre.sourceCode.diff code > .diff-add-line {
|
|
2437
|
+
background: var(--diff-add-bg);
|
|
2438
|
+
color: var(--diff-add-text);
|
|
2439
|
+
}
|
|
2440
|
+
#preview-root pre.sourceCode.diff code > .diff-del-line {
|
|
2441
|
+
background: var(--diff-del-bg);
|
|
2442
|
+
color: var(--diff-del-text);
|
|
2443
|
+
}
|
|
2444
|
+
#preview-root pre.sourceCode.diff code > .diff-meta-line {
|
|
2445
|
+
color: var(--diff-meta-text);
|
|
2446
|
+
}
|
|
2447
|
+
#preview-root pre.sourceCode.diff code > .diff-header-line {
|
|
2448
|
+
background: var(--diff-header-bg);
|
|
2449
|
+
color: var(--diff-header-text);
|
|
2450
|
+
font-weight: 600;
|
|
2451
|
+
}
|
|
2452
|
+
#preview-root pre.sourceCode.diff code > .diff-hunk-line {
|
|
2453
|
+
background: var(--diff-hunk-bg);
|
|
2454
|
+
color: var(--diff-hunk-text);
|
|
2455
|
+
}
|
|
2456
|
+
#preview-root pre.sourceCode.diff code > .diff-line .kw,
|
|
2457
|
+
#preview-root pre.sourceCode.diff code > .diff-line .dt,
|
|
2458
|
+
#preview-root pre.sourceCode.diff code > .diff-line .st,
|
|
2459
|
+
#preview-root pre.sourceCode.diff code > .diff-line .va {
|
|
2460
|
+
color: inherit;
|
|
2461
|
+
font-weight: inherit;
|
|
2462
|
+
}
|
|
2463
|
+
#preview-root code span.kw,
|
|
2464
|
+
#preview-root code span.cf,
|
|
2465
|
+
#preview-root code span.im {
|
|
2466
|
+
color: var(--syntax-keyword);
|
|
2467
|
+
font-weight: 600;
|
|
2468
|
+
}
|
|
2469
|
+
#preview-root code span.dt {
|
|
2470
|
+
color: var(--syntax-type);
|
|
2471
|
+
font-weight: 600;
|
|
2472
|
+
}
|
|
2473
|
+
#preview-root code span.fu,
|
|
2474
|
+
#preview-root code span.bu {
|
|
2475
|
+
color: var(--syntax-function);
|
|
2476
|
+
}
|
|
2477
|
+
#preview-root code span.va,
|
|
2478
|
+
#preview-root code span.ot {
|
|
2479
|
+
color: var(--syntax-variable);
|
|
2480
|
+
}
|
|
2481
|
+
#preview-root code span.st,
|
|
2482
|
+
#preview-root code span.ss,
|
|
2483
|
+
#preview-root code span.sc,
|
|
2484
|
+
#preview-root code span.ch {
|
|
2485
|
+
color: var(--syntax-string);
|
|
2486
|
+
}
|
|
2487
|
+
#preview-root code span.dv,
|
|
2488
|
+
#preview-root code span.bn,
|
|
2489
|
+
#preview-root code span.fl {
|
|
2490
|
+
color: var(--syntax-number);
|
|
2491
|
+
}
|
|
2492
|
+
#preview-root code span.co {
|
|
2493
|
+
color: var(--syntax-comment);
|
|
2494
|
+
font-style: italic;
|
|
2495
|
+
}
|
|
2496
|
+
#preview-root code span.op {
|
|
2497
|
+
color: var(--syntax-operator);
|
|
2498
|
+
}
|
|
2499
|
+
#preview-root code span.er,
|
|
2500
|
+
#preview-root code span.al {
|
|
2501
|
+
color: var(--syntax-error);
|
|
2502
|
+
font-weight: 600;
|
|
2503
|
+
}
|
|
2504
|
+
#preview-root table {
|
|
2505
|
+
border-collapse: collapse;
|
|
2506
|
+
display: block;
|
|
2507
|
+
max-width: 100%;
|
|
2508
|
+
overflow: auto;
|
|
2509
|
+
}
|
|
2510
|
+
#preview-root th, #preview-root td {
|
|
2511
|
+
border: 1px solid var(--border);
|
|
2512
|
+
padding: 6px 12px;
|
|
2513
|
+
}
|
|
2514
|
+
#preview-root hr {
|
|
2515
|
+
border: 0;
|
|
2516
|
+
border-top: 1px solid var(--border);
|
|
2517
|
+
margin: 1.25em 0;
|
|
2518
|
+
}
|
|
2519
|
+
#preview-root img { max-width: 100%; }
|
|
2520
|
+
#preview-root math[display="block"] {
|
|
2521
|
+
display: block;
|
|
2522
|
+
margin: 1em 0;
|
|
2523
|
+
overflow-x: auto;
|
|
2524
|
+
overflow-y: hidden;
|
|
2525
|
+
}
|
|
2526
|
+
#preview-root mjx-container[display="true"] {
|
|
2527
|
+
display: block;
|
|
2528
|
+
margin: 1em 0;
|
|
2529
|
+
overflow-x: auto;
|
|
2530
|
+
overflow-y: hidden;
|
|
2531
|
+
}
|
|
2532
|
+
#preview-root .mermaid-container {
|
|
2533
|
+
text-align: center;
|
|
2534
|
+
margin: 1em 0;
|
|
2535
|
+
overflow-x: auto;
|
|
2536
|
+
}
|
|
2537
|
+
#preview-root .mermaid-container svg {
|
|
2538
|
+
max-width: 100%;
|
|
2539
|
+
height: auto;
|
|
2540
|
+
}
|
|
2541
|
+
</style>
|
|
2542
|
+
</head>
|
|
2543
|
+
<body>
|
|
2544
|
+
<article id="preview-root">${fragmentHtml}</article>
|
|
2545
|
+
<script>
|
|
2546
|
+
${annotationHelpersScript}
|
|
2547
|
+
</script>
|
|
2548
|
+
<script type="module">
|
|
2549
|
+
(async () => {
|
|
2550
|
+
const annotationHelpers = window.PiMarkdownPreviewAnnotationHelpers || null;
|
|
2551
|
+
const previewAnnotationPlaceholders = ${annotationPlaceholdersJson};
|
|
2552
|
+
const DIFF_META_LINE_REGEX = /^(diff --git |index |new file mode |deleted file mode |similarity index |rename from |rename to |Binary files )/;
|
|
2553
|
+
|
|
2554
|
+
const escapeRegExp = (text) => {
|
|
2555
|
+
const backslash = String.fromCharCode(92);
|
|
2556
|
+
const specials = '.+*?^' + '$' + '{}|[]' + backslash;
|
|
2557
|
+
return Array.from(String(text || '')).map((ch) => specials.includes(ch) ? backslash + ch : ch).join('');
|
|
2558
|
+
};
|
|
2559
|
+
|
|
2560
|
+
const setAnnotationMarkerContent = (marker, text) => {
|
|
2561
|
+
if (!(marker instanceof HTMLElement)) return;
|
|
2562
|
+
const rendered = annotationHelpers && typeof annotationHelpers.renderPreviewAnnotationHtml === 'function'
|
|
2563
|
+
? annotationHelpers.renderPreviewAnnotationHtml(text)
|
|
2564
|
+
: String(text || '');
|
|
2565
|
+
marker.innerHTML = rendered;
|
|
2566
|
+
};
|
|
2567
|
+
|
|
2568
|
+
const replaceAnnotationTextNode = (textNode) => {
|
|
2569
|
+
if (!annotationHelpers || typeof annotationHelpers.collectInlineAnnotationMarkers !== 'function') return;
|
|
2570
|
+
const text = typeof textNode.nodeValue === 'string' ? textNode.nodeValue : '';
|
|
2571
|
+
if (!text || text.toLowerCase().indexOf('[an:') === -1) return;
|
|
2572
|
+
|
|
2573
|
+
const markers = annotationHelpers.collectInlineAnnotationMarkers(text);
|
|
2574
|
+
if (!Array.isArray(markers) || markers.length === 0) return;
|
|
2575
|
+
|
|
2576
|
+
const fragment = document.createDocumentFragment();
|
|
2577
|
+
let lastIndex = 0;
|
|
2578
|
+
markers.forEach((markerInfo) => {
|
|
2579
|
+
const token = markerInfo && typeof markerInfo.raw === 'string' ? markerInfo.raw : '';
|
|
2580
|
+
const start = markerInfo && typeof markerInfo.start === 'number' ? markerInfo.start : lastIndex;
|
|
2581
|
+
const end = markerInfo && typeof markerInfo.end === 'number' ? markerInfo.end : start;
|
|
2582
|
+
if (start > lastIndex) {
|
|
2583
|
+
fragment.appendChild(document.createTextNode(text.slice(lastIndex, start)));
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
const markerText = annotationHelpers && typeof annotationHelpers.normalizePreviewAnnotationLabel === 'function'
|
|
2587
|
+
? annotationHelpers.normalizePreviewAnnotationLabel(markerInfo.body)
|
|
2588
|
+
: String(markerInfo && markerInfo.body || '').trim();
|
|
2589
|
+
if (markerText) {
|
|
2590
|
+
const markerEl = document.createElement('span');
|
|
2591
|
+
markerEl.className = 'annotation-marker';
|
|
2592
|
+
markerEl.title = token || markerText;
|
|
2593
|
+
setAnnotationMarkerContent(markerEl, markerText);
|
|
2594
|
+
fragment.appendChild(markerEl);
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
lastIndex = end;
|
|
2598
|
+
});
|
|
2599
|
+
|
|
2600
|
+
if (lastIndex < text.length) {
|
|
2601
|
+
fragment.appendChild(document.createTextNode(text.slice(lastIndex)));
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
if (textNode.parentNode) {
|
|
2605
|
+
textNode.parentNode.replaceChild(fragment, textNode);
|
|
2606
|
+
}
|
|
2607
|
+
};
|
|
2608
|
+
|
|
2609
|
+
const applyPreviewAnnotationPlaceholders = (root) => {
|
|
2610
|
+
if (!root || !Array.isArray(previewAnnotationPlaceholders) || previewAnnotationPlaceholders.length === 0) return;
|
|
2611
|
+
const placeholderMap = new Map();
|
|
2612
|
+
const placeholderTokens = [];
|
|
2613
|
+
previewAnnotationPlaceholders.forEach((entry) => {
|
|
2614
|
+
const token = entry && typeof entry.token === 'string' ? entry.token : '';
|
|
2615
|
+
if (!token) return;
|
|
2616
|
+
placeholderMap.set(token, entry);
|
|
2617
|
+
placeholderTokens.push(token);
|
|
2618
|
+
});
|
|
2619
|
+
if (placeholderTokens.length === 0) return;
|
|
2620
|
+
|
|
2621
|
+
const placeholderPattern = new RegExp(placeholderTokens.map(escapeRegExp).join('|'), 'g');
|
|
2622
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
2623
|
+
const textNodes = [];
|
|
2624
|
+
let node = walker.nextNode();
|
|
2625
|
+
while (node) {
|
|
2626
|
+
const textNode = node;
|
|
2627
|
+
const value = typeof textNode.nodeValue === 'string' ? textNode.nodeValue : '';
|
|
2628
|
+
if (value && value.indexOf('${PREVIEW_ANNOTATION_PLACEHOLDER_PREFIX}') !== -1) {
|
|
2629
|
+
const parent = textNode.parentElement;
|
|
2630
|
+
const tag = parent && parent.tagName ? parent.tagName.toUpperCase() : '';
|
|
2631
|
+
if (tag !== 'CODE' && tag !== 'PRE' && tag !== 'SCRIPT' && tag !== 'STYLE' && tag !== 'TEXTAREA') {
|
|
2632
|
+
textNodes.push(textNode);
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
2635
|
+
node = walker.nextNode();
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2638
|
+
textNodes.forEach((textNode) => {
|
|
2639
|
+
const text = typeof textNode.nodeValue === 'string' ? textNode.nodeValue : '';
|
|
2640
|
+
if (!text) return;
|
|
2641
|
+
placeholderPattern.lastIndex = 0;
|
|
2642
|
+
if (!placeholderPattern.test(text)) return;
|
|
2643
|
+
placeholderPattern.lastIndex = 0;
|
|
2644
|
+
|
|
2645
|
+
const fragment = document.createDocumentFragment();
|
|
2646
|
+
let lastIndex = 0;
|
|
2647
|
+
let match;
|
|
2648
|
+
while ((match = placeholderPattern.exec(text)) !== null) {
|
|
2649
|
+
const token = match[0] || '';
|
|
2650
|
+
const entry = placeholderMap.get(token);
|
|
2651
|
+
const start = typeof match.index === 'number' ? match.index : 0;
|
|
2652
|
+
if (start > lastIndex) {
|
|
2653
|
+
fragment.appendChild(document.createTextNode(text.slice(lastIndex, start)));
|
|
2654
|
+
}
|
|
2655
|
+
if (entry) {
|
|
2656
|
+
const markerEl = document.createElement('span');
|
|
2657
|
+
markerEl.className = 'annotation-marker';
|
|
2658
|
+
const markerText = typeof entry.text === 'string' ? entry.text : token;
|
|
2659
|
+
markerEl.title = typeof entry.title === 'string' ? entry.title : markerText;
|
|
2660
|
+
setAnnotationMarkerContent(markerEl, markerText);
|
|
2661
|
+
fragment.appendChild(markerEl);
|
|
2662
|
+
} else {
|
|
2663
|
+
fragment.appendChild(document.createTextNode(token));
|
|
2664
|
+
}
|
|
2665
|
+
lastIndex = start + token.length;
|
|
2666
|
+
if (token.length === 0) {
|
|
2667
|
+
placeholderPattern.lastIndex += 1;
|
|
2668
|
+
}
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
if (lastIndex < text.length) {
|
|
2672
|
+
fragment.appendChild(document.createTextNode(text.slice(lastIndex)));
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
if (textNode.parentNode) {
|
|
2676
|
+
textNode.parentNode.replaceChild(fragment, textNode);
|
|
2677
|
+
}
|
|
2678
|
+
});
|
|
2679
|
+
};
|
|
2680
|
+
|
|
2681
|
+
const decorateDiffCodeBlocks = (root) => {
|
|
2682
|
+
if (!root) return;
|
|
2683
|
+
const diffBlocks = Array.from(root.querySelectorAll('pre.sourceCode.diff code'));
|
|
2684
|
+
|
|
2685
|
+
diffBlocks.forEach((codeBlock) => {
|
|
2686
|
+
const lineElements = Array.from(codeBlock.children).filter((child) => child instanceof HTMLElement);
|
|
2687
|
+
lineElements.forEach((lineEl) => {
|
|
2688
|
+
const text = typeof lineEl.textContent === 'string' ? lineEl.textContent : '';
|
|
2689
|
+
if (!text) return;
|
|
2690
|
+
|
|
2691
|
+
if (/^\\+(?!\\+\\+)/.test(text)) {
|
|
2692
|
+
lineEl.classList.add('diff-line', 'diff-add-line');
|
|
2693
|
+
} else if (/^-(?!--)/.test(text)) {
|
|
2694
|
+
lineEl.classList.add('diff-line', 'diff-del-line');
|
|
2695
|
+
} else if (/^@@/.test(text)) {
|
|
2696
|
+
lineEl.classList.add('diff-line', 'diff-hunk-line');
|
|
2697
|
+
} else if (/^(?:\\+\\+\\+ |--- )/.test(text)) {
|
|
2698
|
+
lineEl.classList.add('diff-line', 'diff-header-line');
|
|
2699
|
+
} else if (DIFF_META_LINE_REGEX.test(text)) {
|
|
2700
|
+
lineEl.classList.add('diff-line', 'diff-meta-line');
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2703
|
+
const walker = document.createTreeWalker(lineEl, NodeFilter.SHOW_TEXT);
|
|
2704
|
+
const matches = [];
|
|
2705
|
+
let node = walker.nextNode();
|
|
2706
|
+
while (node) {
|
|
2707
|
+
const textNode = node;
|
|
2708
|
+
const value = typeof textNode.nodeValue === 'string' ? textNode.nodeValue : '';
|
|
2709
|
+
const parent = textNode.parentElement;
|
|
2710
|
+
if (value && value.toLowerCase().indexOf('[an:') !== -1 && parent && !parent.closest('a, .annotation-marker')) {
|
|
2711
|
+
matches.push(textNode);
|
|
2712
|
+
}
|
|
2713
|
+
node = walker.nextNode();
|
|
2714
|
+
}
|
|
2715
|
+
|
|
2716
|
+
matches.forEach(replaceAnnotationTextNode);
|
|
2717
|
+
});
|
|
2718
|
+
});
|
|
2719
|
+
};
|
|
2720
|
+
|
|
2721
|
+
const MATHJAX_CDN_URL = 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js';
|
|
2722
|
+
|
|
2723
|
+
const waitForFonts = async () => {
|
|
2724
|
+
if ('fonts' in document) {
|
|
2725
|
+
try {
|
|
2726
|
+
await document.fonts.ready;
|
|
2727
|
+
} catch {}
|
|
2728
|
+
}
|
|
2729
|
+
};
|
|
2730
|
+
|
|
2731
|
+
const waitForPaint = async () => {
|
|
2732
|
+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
2733
|
+
};
|
|
2734
|
+
|
|
2735
|
+
const extractMathFallbackTex = (text, displayMode) => {
|
|
2736
|
+
const source = typeof text === 'string' ? text.trim() : '';
|
|
2737
|
+
if (!source) return '';
|
|
2738
|
+
|
|
2739
|
+
if (displayMode) {
|
|
2740
|
+
if (source.startsWith('$$') && source.endsWith('$$') && source.length >= 4) {
|
|
2741
|
+
return source.slice(2, -2).trim();
|
|
2742
|
+
}
|
|
2743
|
+
if (source.startsWith('\\\\[') && source.endsWith('\\\\]') && source.length >= 4) {
|
|
2744
|
+
return source.slice(2, -2).trim();
|
|
2745
|
+
}
|
|
2746
|
+
return source;
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2749
|
+
if (source.startsWith('\\\\(') && source.endsWith('\\\\)') && source.length >= 4) {
|
|
2750
|
+
return source.slice(2, -2).trim();
|
|
2751
|
+
}
|
|
2752
|
+
if (source.startsWith('$') && source.endsWith('$') && source.length >= 2) {
|
|
2753
|
+
return source.slice(1, -1).trim();
|
|
2754
|
+
}
|
|
2755
|
+
return source;
|
|
2756
|
+
};
|
|
2757
|
+
|
|
2758
|
+
const collectMathFallbackTargets = (root) => {
|
|
2759
|
+
if (!root) return [];
|
|
2760
|
+
const nodes = Array.from(root.querySelectorAll('.math.display, .math.inline'));
|
|
2761
|
+
const targets = [];
|
|
2762
|
+
const seenTargets = new Set();
|
|
2763
|
+
|
|
2764
|
+
nodes.forEach((node) => {
|
|
2765
|
+
const displayMode = node.classList.contains('display');
|
|
2766
|
+
const rawText = typeof node.textContent === 'string' ? node.textContent : '';
|
|
2767
|
+
const tex = extractMathFallbackTex(rawText, displayMode);
|
|
2768
|
+
if (!tex) return;
|
|
2769
|
+
|
|
2770
|
+
let renderTarget = node;
|
|
2771
|
+
if (displayMode) {
|
|
2772
|
+
const parent = node.parentElement;
|
|
2773
|
+
const parentText = parent && typeof parent.textContent === 'string' ? parent.textContent.trim() : '';
|
|
2774
|
+
if (parent && parent.tagName === 'P' && parentText === rawText.trim()) {
|
|
2775
|
+
renderTarget = parent;
|
|
2776
|
+
}
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2779
|
+
if (seenTargets.has(renderTarget)) return;
|
|
2780
|
+
seenTargets.add(renderTarget);
|
|
2781
|
+
targets.push({ renderTarget, displayMode, tex });
|
|
2782
|
+
});
|
|
2783
|
+
|
|
2784
|
+
return targets;
|
|
2785
|
+
};
|
|
2786
|
+
|
|
2787
|
+
let mathJaxPromise = null;
|
|
2788
|
+
const ensureMathJax = () => {
|
|
2789
|
+
if (window.MathJax && typeof window.MathJax.typesetPromise === 'function') {
|
|
2790
|
+
return Promise.resolve(window.MathJax);
|
|
2791
|
+
}
|
|
2792
|
+
if (mathJaxPromise) return mathJaxPromise;
|
|
2793
|
+
|
|
2794
|
+
mathJaxPromise = new Promise((resolve, reject) => {
|
|
2795
|
+
window.MathJax = {
|
|
2796
|
+
loader: { load: ['[tex]/ams', '[tex]/noerrors', '[tex]/noundefined'] },
|
|
2797
|
+
tex: {
|
|
2798
|
+
inlineMath: [['\\\\(', '\\\\)'], ['$', '$']],
|
|
2799
|
+
displayMath: [['\\\\[', '\\\\]'], ['$$', '$$']],
|
|
2800
|
+
packages: { '[+]': ['ams', 'noerrors', 'noundefined'] },
|
|
2801
|
+
},
|
|
2802
|
+
options: {
|
|
2803
|
+
skipHtmlTags: ['script', 'noscript', 'style', 'textarea', 'pre', 'code'],
|
|
2804
|
+
},
|
|
2805
|
+
startup: { typeset: false },
|
|
2806
|
+
};
|
|
2807
|
+
|
|
2808
|
+
const script = document.createElement('script');
|
|
2809
|
+
script.src = MATHJAX_CDN_URL;
|
|
2810
|
+
script.async = true;
|
|
2811
|
+
script.onload = () => {
|
|
2812
|
+
const api = window.MathJax;
|
|
2813
|
+
if (api && api.startup && api.startup.promise && typeof api.startup.promise.then === 'function') {
|
|
2814
|
+
api.startup.promise.then(() => resolve(api)).catch(reject);
|
|
2815
|
+
return;
|
|
2816
|
+
}
|
|
2817
|
+
if (api && typeof api.typesetPromise === 'function') {
|
|
2818
|
+
resolve(api);
|
|
2819
|
+
return;
|
|
2820
|
+
}
|
|
2821
|
+
reject(new Error('MathJax did not initialize.'));
|
|
2822
|
+
};
|
|
2823
|
+
script.onerror = () => reject(new Error('Failed to load MathJax.'));
|
|
2824
|
+
document.head.appendChild(script);
|
|
2825
|
+
}).catch((error) => {
|
|
2826
|
+
mathJaxPromise = null;
|
|
2827
|
+
throw error;
|
|
2828
|
+
});
|
|
2829
|
+
|
|
2830
|
+
return mathJaxPromise;
|
|
2831
|
+
};
|
|
2832
|
+
|
|
2833
|
+
const markerNeedsMath = (text) => {
|
|
2834
|
+
const source = typeof text === 'string' ? text : '';
|
|
2835
|
+
if (!source) return false;
|
|
2836
|
+
const backslash = String.fromCharCode(92);
|
|
2837
|
+
if (source.includes(backslash + '(') || source.includes(backslash + '[') || source.includes('$$')) return true;
|
|
2838
|
+
for (let index = 0; index < source.length - 1; index += 1) {
|
|
2839
|
+
const char = source[index];
|
|
2840
|
+
const next = source[index + 1] || '';
|
|
2841
|
+
if (char === '$' && next.trim() !== '') return true;
|
|
2842
|
+
if (char === backslash && /[A-Za-z]/.test(next)) return true;
|
|
2843
|
+
}
|
|
2844
|
+
return false;
|
|
2845
|
+
};
|
|
2846
|
+
|
|
2847
|
+
const renderAnnotationMarkerMath = async (root) => {
|
|
2848
|
+
if (!root) return;
|
|
2849
|
+
const markers = Array.from(root.querySelectorAll('.annotation-marker')).filter((marker) => {
|
|
2850
|
+
if (!(marker instanceof HTMLElement)) return false;
|
|
2851
|
+
if (marker.querySelector('math, mjx-container')) return false;
|
|
2852
|
+
const text = typeof marker.textContent === 'string' ? marker.textContent : '';
|
|
2853
|
+
return markerNeedsMath(text);
|
|
2854
|
+
});
|
|
2855
|
+
if (markers.length === 0) return;
|
|
2856
|
+
|
|
2857
|
+
let mathJax;
|
|
2858
|
+
try {
|
|
2859
|
+
mathJax = await ensureMathJax();
|
|
2860
|
+
} catch (e) {
|
|
2861
|
+
console.error('MathJax load failed:', e);
|
|
2862
|
+
return;
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2865
|
+
try {
|
|
2866
|
+
await mathJax.typesetPromise(markers);
|
|
2867
|
+
} catch (e) {
|
|
2868
|
+
console.error('Annotation math render failed:', e);
|
|
2869
|
+
}
|
|
2870
|
+
};
|
|
2871
|
+
|
|
2872
|
+
const renderMathFallback = async (root) => {
|
|
2873
|
+
const fallbackTargets = collectMathFallbackTargets(root);
|
|
2874
|
+
if (fallbackTargets.length === 0) return;
|
|
2875
|
+
|
|
2876
|
+
let mathJax;
|
|
2877
|
+
try {
|
|
2878
|
+
mathJax = await ensureMathJax();
|
|
2879
|
+
} catch (e) {
|
|
2880
|
+
console.error('MathJax load failed:', e);
|
|
2881
|
+
return;
|
|
2882
|
+
}
|
|
2883
|
+
|
|
2884
|
+
fallbackTargets.forEach(({ renderTarget, displayMode, tex }) => {
|
|
2885
|
+
renderTarget.textContent = displayMode ? '\\\\[\\n' + tex + '\\n\\\\]' : '\\\\(' + tex + '\\\\)';
|
|
2886
|
+
});
|
|
2887
|
+
|
|
2888
|
+
try {
|
|
2889
|
+
await mathJax.typesetPromise(fallbackTargets.map(({ renderTarget }) => renderTarget));
|
|
2890
|
+
} catch (e) {
|
|
2891
|
+
console.error('MathJax render failed:', e);
|
|
2892
|
+
}
|
|
2893
|
+
};
|
|
2894
|
+
|
|
2895
|
+
const renderMermaid = async () => {
|
|
2896
|
+
const mermaidBlocks = document.querySelectorAll('pre.mermaid');
|
|
2897
|
+
if (mermaidBlocks.length === 0) return;
|
|
2898
|
+
|
|
2899
|
+
try {
|
|
2900
|
+
const { default: mermaid } = await import('https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs');
|
|
2901
|
+
mermaid.initialize({
|
|
2902
|
+
startOnLoad: false,
|
|
2903
|
+
theme: '${style.themeMode === "dark" ? "dark" : "default"}',
|
|
2904
|
+
});
|
|
2905
|
+
mermaidBlocks.forEach(pre => {
|
|
2906
|
+
const code = pre.querySelector('code');
|
|
2907
|
+
const src = code ? code.textContent : pre.textContent;
|
|
2908
|
+
const wrapper = document.createElement('div');
|
|
2909
|
+
wrapper.className = 'mermaid-container';
|
|
2910
|
+
const div = document.createElement('div');
|
|
2911
|
+
div.className = 'mermaid';
|
|
2912
|
+
div.textContent = src;
|
|
2913
|
+
wrapper.appendChild(div);
|
|
2914
|
+
pre.replaceWith(wrapper);
|
|
2915
|
+
});
|
|
2916
|
+
await mermaid.run();
|
|
2917
|
+
} catch (e) {
|
|
2918
|
+
console.error('Mermaid render failed:', e);
|
|
2919
|
+
}
|
|
2920
|
+
};
|
|
2921
|
+
|
|
2922
|
+
const root = document.getElementById('preview-root');
|
|
2923
|
+
try {
|
|
2924
|
+
await renderMermaid();
|
|
2925
|
+
applyPreviewAnnotationPlaceholders(root);
|
|
2926
|
+
decorateDiffCodeBlocks(root);
|
|
2927
|
+
await renderAnnotationMarkerMath(root);
|
|
2928
|
+
await renderMathFallback(root);
|
|
2929
|
+
await waitForFonts();
|
|
2930
|
+
await waitForPaint();
|
|
2931
|
+
} finally {
|
|
2932
|
+
window.__mermaidDone = true;
|
|
2933
|
+
}
|
|
2934
|
+
})();
|
|
2935
|
+
</script>
|
|
2936
|
+
</body>
|
|
2937
|
+
</html>`;
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
async function openPreviewInBrowser(ctx: ExtensionCommandContext, markdownOverride?: string, resourcePath?: string, isLatex?: boolean): Promise<void> {
|
|
2941
|
+
const markdown = markdownOverride ?? getLastAssistantMarkdown(ctx);
|
|
2942
|
+
if (!markdown) {
|
|
2943
|
+
throw new Error("No assistant markdown found in the current branch.");
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
const style = getPreviewStyle(ctx.ui.theme);
|
|
2947
|
+
const { normalizedMarkdown, pandocMarkdown, annotationPlaceholders } = prepareBrowserPreviewMarkdown(markdown, isLatex);
|
|
2948
|
+
const fragmentHtml = await renderMarkdownToHtmlWithPandoc(pandocMarkdown, resourcePath, isLatex);
|
|
2949
|
+
const html = buildBrowserHtmlFromPandocFragment(fragmentHtml, style, resourcePath, annotationPlaceholders);
|
|
2950
|
+
const hash = createHash("sha256")
|
|
2951
|
+
.update(RENDER_VERSION)
|
|
2952
|
+
.update("\u0000")
|
|
2953
|
+
.update("browser-native")
|
|
2954
|
+
.update("\u0000")
|
|
2955
|
+
.update(style.cacheKey)
|
|
2956
|
+
.update("\u0000")
|
|
2957
|
+
.update(normalizedMarkdown)
|
|
2958
|
+
.digest("hex");
|
|
2959
|
+
const htmlPath = join(CACHE_DIR, `${hash}.html`);
|
|
2960
|
+
|
|
2961
|
+
await mkdir(CACHE_DIR, { recursive: true });
|
|
2962
|
+
await writeFile(htmlPath, html, "utf-8");
|
|
2963
|
+
await openFileInDefaultBrowser(htmlPath);
|
|
2964
|
+
}
|
|
2965
|
+
|
|
2966
|
+
function tokenizeArgs(input: string): string[] {
|
|
2967
|
+
const tokens: string[] = [];
|
|
2968
|
+
const s = input.trim();
|
|
2969
|
+
let i = 0;
|
|
2970
|
+
|
|
2971
|
+
while (i < s.length) {
|
|
2972
|
+
while (i < s.length && /\s/.test(s[i]!)) i++;
|
|
2973
|
+
if (i >= s.length) break;
|
|
2974
|
+
|
|
2975
|
+
const ch = s[i]!;
|
|
2976
|
+
if (ch === '"' || ch === "'") {
|
|
2977
|
+
const quote = ch;
|
|
2978
|
+
i++;
|
|
2979
|
+
let token = "";
|
|
2980
|
+
while (i < s.length && s[i] !== quote) {
|
|
2981
|
+
token += s[i];
|
|
2982
|
+
i++;
|
|
2983
|
+
}
|
|
2984
|
+
if (i < s.length) i++; // skip closing quote
|
|
2985
|
+
tokens.push(token);
|
|
2986
|
+
} else {
|
|
2987
|
+
let token = "";
|
|
2988
|
+
while (i < s.length && !/\s/.test(s[i]!)) {
|
|
2989
|
+
token += s[i];
|
|
2990
|
+
i++;
|
|
2991
|
+
}
|
|
2992
|
+
tokens.push(token);
|
|
2993
|
+
}
|
|
2994
|
+
}
|
|
2995
|
+
|
|
2996
|
+
return tokens;
|
|
2997
|
+
}
|
|
2998
|
+
|
|
2999
|
+
function parsePreviewArgs(args: string): { target?: PreviewTarget; pick?: boolean; file?: string; help?: boolean; error?: string } {
|
|
3000
|
+
const tokens = tokenizeArgs(args);
|
|
3001
|
+
let target: PreviewTarget = "terminal";
|
|
3002
|
+
let explicitTarget = false;
|
|
3003
|
+
let pick = false;
|
|
3004
|
+
let file: string | undefined;
|
|
3005
|
+
|
|
3006
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
3007
|
+
const token = tokens[i]!;
|
|
3008
|
+
|
|
3009
|
+
if (token === "--help" || token === "-h" || token === "help") {
|
|
3010
|
+
return { help: true };
|
|
3011
|
+
}
|
|
3012
|
+
|
|
3013
|
+
if (token === "--pick" || token === "pick" || token === "-p") {
|
|
3014
|
+
pick = true;
|
|
3015
|
+
continue;
|
|
3016
|
+
}
|
|
3017
|
+
|
|
3018
|
+
if (token === "--file" || token === "-f") {
|
|
3019
|
+
const next = tokens[i + 1];
|
|
3020
|
+
if (!next || next.startsWith("-")) {
|
|
3021
|
+
return { error: "Missing file path after --file." };
|
|
3022
|
+
}
|
|
3023
|
+
file = next;
|
|
3024
|
+
i++;
|
|
3025
|
+
continue;
|
|
3026
|
+
}
|
|
3027
|
+
|
|
3028
|
+
if (
|
|
3029
|
+
token === "--browser" ||
|
|
3030
|
+
token === "browser" ||
|
|
3031
|
+
token === "--external" ||
|
|
3032
|
+
token === "external" ||
|
|
3033
|
+
token === "--browser-native" ||
|
|
3034
|
+
token === "native"
|
|
3035
|
+
) {
|
|
3036
|
+
if (explicitTarget && target !== "browser") {
|
|
3037
|
+
return { error: "Conflicting output targets. Choose one of terminal, browser, or pdf." };
|
|
3038
|
+
}
|
|
3039
|
+
target = "browser";
|
|
3040
|
+
explicitTarget = true;
|
|
3041
|
+
continue;
|
|
3042
|
+
}
|
|
3043
|
+
|
|
3044
|
+
if (token === "--pdf" || token === "pdf") {
|
|
3045
|
+
if (explicitTarget && target !== "pdf") {
|
|
3046
|
+
return { error: "Conflicting output targets. Choose one of terminal, browser, or pdf." };
|
|
3047
|
+
}
|
|
3048
|
+
target = "pdf";
|
|
3049
|
+
explicitTarget = true;
|
|
3050
|
+
continue;
|
|
3051
|
+
}
|
|
3052
|
+
|
|
3053
|
+
if (token === "--terminal" || token === "terminal") {
|
|
3054
|
+
if (explicitTarget && target !== "terminal") {
|
|
3055
|
+
return { error: "Conflicting output targets. Choose one of terminal, browser, or pdf." };
|
|
3056
|
+
}
|
|
3057
|
+
target = "terminal";
|
|
3058
|
+
explicitTarget = true;
|
|
3059
|
+
continue;
|
|
3060
|
+
}
|
|
3061
|
+
|
|
3062
|
+
if (token.startsWith("--engine") || token.startsWith("-engine")) {
|
|
3063
|
+
return { error: "Engine selection was removed. Use /preview or /preview --browser." };
|
|
3064
|
+
}
|
|
3065
|
+
|
|
3066
|
+
// Treat bare argument as a file path if no --file flag was used
|
|
3067
|
+
if (!file && !token.startsWith("-")) {
|
|
3068
|
+
file = token;
|
|
3069
|
+
continue;
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3072
|
+
return { error: `Unknown argument \"${token}\". Use /preview [--pick|-p] [--file|-f <path>] [--browser] [--pdf] [--terminal]` };
|
|
3073
|
+
}
|
|
3074
|
+
|
|
3075
|
+
if (file && pick) {
|
|
3076
|
+
return { error: "Cannot use --pick and --file together." };
|
|
3077
|
+
}
|
|
3078
|
+
|
|
3079
|
+
return { target, pick, file };
|
|
3080
|
+
}
|
|
3081
|
+
|
|
3082
|
+
export default function (pi: ExtensionAPI) {
|
|
3083
|
+
const run = async (args: string, ctx: ExtensionCommandContext) => {
|
|
3084
|
+
const parsed = parsePreviewArgs(args);
|
|
3085
|
+
if (parsed.help) {
|
|
3086
|
+
ctx.ui.notify("Usage: /preview [--pick|-p] [--file|-f <path>] [--browser] [--pdf] [--terminal] or /preview <path>", "info");
|
|
3087
|
+
return;
|
|
3088
|
+
}
|
|
3089
|
+
if (parsed.error || !parsed.target) {
|
|
3090
|
+
ctx.ui.notify(parsed.error ?? "Invalid preview arguments.", "error");
|
|
3091
|
+
return;
|
|
3092
|
+
}
|
|
3093
|
+
|
|
3094
|
+
await ctx.waitForIdle();
|
|
3095
|
+
|
|
3096
|
+
let markdown: string | undefined;
|
|
3097
|
+
let resourcePath: string | undefined;
|
|
3098
|
+
let isLatex = false;
|
|
3099
|
+
if (parsed.file) {
|
|
3100
|
+
try {
|
|
3101
|
+
const expanded = parsed.file.startsWith("~/") ? join(homedir(), parsed.file.slice(2))
|
|
3102
|
+
: parsed.file === "~" ? homedir()
|
|
3103
|
+
: parsed.file;
|
|
3104
|
+
const filePath = resolvePath(ctx.cwd, expanded);
|
|
3105
|
+
const fileContent = await readFile(filePath, "utf-8");
|
|
3106
|
+
resourcePath = dirname(filePath);
|
|
3107
|
+
if (isLatexFile(filePath)) {
|
|
3108
|
+
markdown = fileContent;
|
|
3109
|
+
isLatex = true;
|
|
3110
|
+
} else if (isMarkdownFile(filePath)) {
|
|
3111
|
+
markdown = fileContent;
|
|
3112
|
+
} else {
|
|
3113
|
+
const lang = detectLanguageFromPath(filePath);
|
|
3114
|
+
markdown = wrapCodeAsMarkdown(fileContent, lang, filePath);
|
|
3115
|
+
}
|
|
3116
|
+
} catch (error) {
|
|
3117
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3118
|
+
ctx.ui.notify(`Failed to read file: ${message}`, "error");
|
|
3119
|
+
return;
|
|
3120
|
+
}
|
|
3121
|
+
} else if (parsed.pick) {
|
|
3122
|
+
const picked = await pickAssistantMessage(ctx);
|
|
3123
|
+
if (picked === null) return;
|
|
3124
|
+
markdown = picked;
|
|
3125
|
+
}
|
|
3126
|
+
|
|
3127
|
+
const effectiveMarkdown = markdown ?? getLastAssistantMarkdown(ctx);
|
|
3128
|
+
if (!resourcePath && effectiveMarkdown && hasLikelyRelativeLocalImages(effectiveMarkdown)) {
|
|
3129
|
+
ctx.ui.notify(
|
|
3130
|
+
"Relative local image paths may not resolve for assistant-response previews. Use /preview --file <path> for reliable local image loading.",
|
|
3131
|
+
"warning",
|
|
3132
|
+
);
|
|
3133
|
+
}
|
|
3134
|
+
|
|
3135
|
+
if (parsed.target === "browser") {
|
|
3136
|
+
try {
|
|
3137
|
+
await openPreviewInBrowser(ctx, markdown, resourcePath, isLatex);
|
|
3138
|
+
ctx.ui.notify("Opened preview in browser.", "info");
|
|
3139
|
+
} catch (error) {
|
|
3140
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3141
|
+
ctx.ui.notify(`Browser preview failed: ${message}`, "error");
|
|
3142
|
+
}
|
|
3143
|
+
return;
|
|
3144
|
+
}
|
|
3145
|
+
|
|
3146
|
+
if (parsed.target === "pdf") {
|
|
3147
|
+
try {
|
|
3148
|
+
await exportPdf(ctx, markdown, resourcePath, isLatex);
|
|
3149
|
+
ctx.ui.notify("Opened PDF preview.", "info");
|
|
3150
|
+
} catch (error) {
|
|
3151
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3152
|
+
ctx.ui.notify(`PDF export failed: ${message}`, "error");
|
|
3153
|
+
}
|
|
3154
|
+
return;
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3157
|
+
await openPreview(ctx, markdown, resourcePath, isLatex);
|
|
3158
|
+
};
|
|
3159
|
+
|
|
3160
|
+
pi.registerCommand("preview", {
|
|
3161
|
+
description: "Rendered markdown preview (--pick select response, --file <path> or bare path, --browser for HTML, --pdf for PDF, --terminal to force inline)",
|
|
3162
|
+
handler: run,
|
|
3163
|
+
});
|
|
3164
|
+
|
|
3165
|
+
pi.registerCommand("preview-browser", {
|
|
3166
|
+
description: "Open rendered markdown + LaTeX preview in the default browser (MathML + selective MathJax fallback)",
|
|
3167
|
+
handler: async (args, ctx) => {
|
|
3168
|
+
await ctx.waitForIdle();
|
|
3169
|
+
await run(`--browser ${args}`.trim(), ctx);
|
|
3170
|
+
},
|
|
3171
|
+
});
|
|
3172
|
+
|
|
3173
|
+
pi.registerCommand("preview-pdf", {
|
|
3174
|
+
description: "Export markdown to PDF via pandoc + LaTeX and open it",
|
|
3175
|
+
handler: async (args, ctx) => {
|
|
3176
|
+
await ctx.waitForIdle();
|
|
3177
|
+
// Re-use the main run handler with --pdf prepended
|
|
3178
|
+
await run(`--pdf ${args}`.trim(), ctx);
|
|
3179
|
+
},
|
|
3180
|
+
});
|
|
3181
|
+
|
|
3182
|
+
pi.registerCommand("preview-clear-cache", {
|
|
3183
|
+
description: "Clear rendered preview cache (~/.pi/cache/markdown-preview)",
|
|
3184
|
+
handler: async (_args, ctx) => {
|
|
3185
|
+
await ctx.waitForIdle();
|
|
3186
|
+
try {
|
|
3187
|
+
await rm(CACHE_DIR, { recursive: true, force: true });
|
|
3188
|
+
ctx.ui.notify(`Cleared preview cache: ${CACHE_DIR}`, "info");
|
|
3189
|
+
} catch (error) {
|
|
3190
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3191
|
+
ctx.ui.notify(`Failed to clear preview cache: ${message}`, "error");
|
|
3192
|
+
}
|
|
3193
|
+
},
|
|
3194
|
+
});
|
|
3195
|
+
|
|
3196
|
+
// -------------------------------------------------------------------------
|
|
3197
|
+
// Cross-extension preview protocol: listen for "ck:preview-request" messages
|
|
3198
|
+
// dispatched by CaveKit's /ck:preview command.
|
|
3199
|
+
// -------------------------------------------------------------------------
|
|
3200
|
+
pi.on("message_start", async (event, ctx) => {
|
|
3201
|
+
if (!ctx.hasUI) return;
|
|
3202
|
+
const msg = event.message as any;
|
|
3203
|
+
if (msg.role !== "custom" || msg.customType !== "ck:preview-request") return;
|
|
3204
|
+
|
|
3205
|
+
const { filePath, target } = msg.details ?? {};
|
|
3206
|
+
if (!filePath || typeof filePath !== "string") return;
|
|
3207
|
+
|
|
3208
|
+
try {
|
|
3209
|
+
const fileContent = await readFile(filePath, "utf-8");
|
|
3210
|
+
const resourcePath = dirname(filePath);
|
|
3211
|
+
let markdown: string;
|
|
3212
|
+
let isLatex = false;
|
|
3213
|
+
|
|
3214
|
+
if (isLatexFile(filePath)) {
|
|
3215
|
+
markdown = fileContent;
|
|
3216
|
+
isLatex = true;
|
|
3217
|
+
} else if (isMarkdownFile(filePath)) {
|
|
3218
|
+
markdown = fileContent;
|
|
3219
|
+
} else {
|
|
3220
|
+
const lang = detectLanguageFromPath(filePath);
|
|
3221
|
+
markdown = wrapCodeAsMarkdown(fileContent, lang, filePath);
|
|
3222
|
+
}
|
|
3223
|
+
|
|
3224
|
+
// Cast ExtensionContext to ExtensionCommandContext — file preview only
|
|
3225
|
+
// uses ctx.ui and ctx.cwd which are on the base ExtensionContext.
|
|
3226
|
+
const cmdCtx = ctx as unknown as ExtensionCommandContext;
|
|
3227
|
+
|
|
3228
|
+
if (target === "browser") {
|
|
3229
|
+
await openPreviewInBrowser(cmdCtx, markdown, resourcePath, isLatex);
|
|
3230
|
+
} else if (target === "pdf") {
|
|
3231
|
+
await exportPdf(cmdCtx, markdown, resourcePath, isLatex);
|
|
3232
|
+
} else {
|
|
3233
|
+
await openPreview(cmdCtx, markdown, resourcePath, isLatex);
|
|
3234
|
+
}
|
|
3235
|
+
} catch (error) {
|
|
3236
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3237
|
+
ctx.ui.notify(`Preview failed: ${message}`, "error");
|
|
3238
|
+
}
|
|
3239
|
+
});
|
|
3240
|
+
}
|