pi-studio 0.5.32 → 0.5.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,17 @@ All notable changes to `pi-studio` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.5.33] — 2026-03-27
8
+
9
+ ### Changed
10
+ - Studio browser tabs now use `π Studio` branding plus a simple theme-reactive `π` favicon instead of the generic browser globe.
11
+
12
+ ### Fixed
13
+ - Markdown preview now preserves `[an: ...]` markers more reliably by replacing them with preview-safe placeholders before pandoc and restoring annotation pills afterwards, preventing long or markdown-like annotations from leaking through as raw text.
14
+ - Preview/PDF markdown preparation now normalizes fenced blocks whose contents contain competing backtick/tilde fence runs, avoiding broken rendering/export for diff-heavy content that itself contains code fences.
15
+ - Diff PDF exports now route highlighted diff content through the generated-LaTeX path more reliably, keeping add/delete/meta/hunk styling and line wrapping on exports that previously rendered poorly or fell back unnecessarily.
16
+ - PDF annotation badges now wrap within the page width instead of overflowing on long notes, preserve inline math inside annotation text, and also render correctly inside diff token lines such as `+[an: ...]`.
17
+
7
18
  ## [0.5.32] — 2026-03-25
8
19
 
9
20
  ### Added
@@ -242,6 +242,7 @@
242
242
  let editorHighlightRenderRaf = null;
243
243
  let annotationsEnabled = true;
244
244
  const ANNOTATION_MARKER_REGEX = /\[an:\s*([^\]]+?)\]/gi;
245
+ const PREVIEW_ANNOTATION_PLACEHOLDER_PREFIX = "PISTUDIOANNOT";
245
246
  const EMPTY_OVERLAY_LINE = "\u200b";
246
247
  const MERMAID_CDN_URL = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs";
247
248
  const MATHJAX_CDN_URL = "https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js";
@@ -1183,6 +1184,80 @@
1183
1184
  return annotationsEnabled ? raw : stripAnnotationMarkers(raw);
1184
1185
  }
1185
1186
 
1187
+ function normalizePreviewAnnotationLabel(text) {
1188
+ return String(text || "")
1189
+ .replace(/\r\n/g, "\n")
1190
+ .replace(/\s*\n\s*/g, " ")
1191
+ .replace(/\s{2,}/g, " ")
1192
+ .trim();
1193
+ }
1194
+
1195
+ function prepareMarkdownForPandocPreview(markdown) {
1196
+ const source = String(markdown || "").replace(/\r\n/g, "\n");
1197
+ const placeholders = [];
1198
+ if (!source) {
1199
+ return { markdown: source, placeholders: placeholders };
1200
+ }
1201
+
1202
+ const lines = source.split("\n");
1203
+ const out = [];
1204
+ let plainBuffer = [];
1205
+ let inFence = false;
1206
+ let fenceChar = null;
1207
+ let fenceLength = 0;
1208
+
1209
+ function flushPlain() {
1210
+ if (plainBuffer.length === 0) return;
1211
+ const segment = plainBuffer.join("\n").replace(/\[an:\s*([^\]]+?)\]/gi, function(_match, markerText) {
1212
+ const label = normalizePreviewAnnotationLabel(markerText);
1213
+ if (!label) return "";
1214
+ const token = PREVIEW_ANNOTATION_PLACEHOLDER_PREFIX + placeholders.length + "TOKEN";
1215
+ placeholders.push({ token: token, text: label, title: "[an: " + label + "]" });
1216
+ return token;
1217
+ });
1218
+ out.push(segment);
1219
+ plainBuffer = [];
1220
+ }
1221
+
1222
+ for (const line of lines) {
1223
+ const trimmed = line.trimStart();
1224
+ const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/);
1225
+
1226
+ if (fenceMatch) {
1227
+ const marker = fenceMatch[1] || "";
1228
+ const markerChar = marker.charAt(0);
1229
+ const markerLength = marker.length;
1230
+
1231
+ if (!inFence) {
1232
+ flushPlain();
1233
+ inFence = true;
1234
+ fenceChar = markerChar;
1235
+ fenceLength = markerLength;
1236
+ out.push(line);
1237
+ continue;
1238
+ }
1239
+
1240
+ if (fenceChar === markerChar && markerLength >= fenceLength) {
1241
+ inFence = false;
1242
+ fenceChar = null;
1243
+ fenceLength = 0;
1244
+ }
1245
+
1246
+ out.push(line);
1247
+ continue;
1248
+ }
1249
+
1250
+ if (inFence) {
1251
+ out.push(line);
1252
+ } else {
1253
+ plainBuffer.push(line);
1254
+ }
1255
+ }
1256
+
1257
+ flushPlain();
1258
+ return { markdown: out.join("\n"), placeholders: placeholders };
1259
+ }
1260
+
1186
1261
  function wrapAsFencedCodeBlock(text, language) {
1187
1262
  const source = String(text || "").trimEnd();
1188
1263
  const lang = String(language || "").trim();
@@ -1622,6 +1697,79 @@
1622
1697
  }
1623
1698
  }
1624
1699
 
1700
+ function applyPreviewAnnotationPlaceholdersToElement(targetEl, placeholders) {
1701
+ if (!targetEl || !Array.isArray(placeholders) || placeholders.length === 0) return;
1702
+ if (typeof document.createTreeWalker !== "function") return;
1703
+
1704
+ const placeholderMap = new Map();
1705
+ const placeholderTokens = [];
1706
+ placeholders.forEach(function(entry) {
1707
+ const token = entry && typeof entry.token === "string" ? entry.token : "";
1708
+ if (!token) return;
1709
+ placeholderMap.set(token, entry);
1710
+ placeholderTokens.push(token);
1711
+ });
1712
+ if (placeholderTokens.length === 0) return;
1713
+
1714
+ const placeholderPattern = new RegExp(placeholderTokens.map(escapeRegExp).join("|"), "g");
1715
+ const walker = document.createTreeWalker(targetEl, NodeFilter.SHOW_TEXT);
1716
+ const textNodes = [];
1717
+ let node = walker.nextNode();
1718
+ while (node) {
1719
+ const textNode = node;
1720
+ const value = typeof textNode.nodeValue === "string" ? textNode.nodeValue : "";
1721
+ if (value && value.indexOf(PREVIEW_ANNOTATION_PLACEHOLDER_PREFIX) !== -1) {
1722
+ const parent = textNode.parentElement;
1723
+ const tag = parent && parent.tagName ? parent.tagName.toUpperCase() : "";
1724
+ if (tag !== "CODE" && tag !== "PRE" && tag !== "SCRIPT" && tag !== "STYLE" && tag !== "TEXTAREA") {
1725
+ textNodes.push(textNode);
1726
+ }
1727
+ }
1728
+ node = walker.nextNode();
1729
+ }
1730
+
1731
+ textNodes.forEach(function(textNode) {
1732
+ const text = typeof textNode.nodeValue === "string" ? textNode.nodeValue : "";
1733
+ if (!text) return;
1734
+ placeholderPattern.lastIndex = 0;
1735
+ if (!placeholderPattern.test(text)) return;
1736
+ placeholderPattern.lastIndex = 0;
1737
+
1738
+ const fragment = document.createDocumentFragment();
1739
+ let lastIndex = 0;
1740
+ let match;
1741
+ while ((match = placeholderPattern.exec(text)) !== null) {
1742
+ const token = match[0] || "";
1743
+ const entry = placeholderMap.get(token);
1744
+ const start = typeof match.index === "number" ? match.index : 0;
1745
+ if (start > lastIndex) {
1746
+ fragment.appendChild(document.createTextNode(text.slice(lastIndex, start)));
1747
+ }
1748
+ if (entry) {
1749
+ const markerEl = document.createElement("span");
1750
+ markerEl.className = "annotation-preview-marker";
1751
+ markerEl.textContent = typeof entry.text === "string" ? entry.text : token;
1752
+ markerEl.title = typeof entry.title === "string" ? entry.title : markerEl.textContent;
1753
+ fragment.appendChild(markerEl);
1754
+ } else {
1755
+ fragment.appendChild(document.createTextNode(token));
1756
+ }
1757
+ lastIndex = start + token.length;
1758
+ if (token.length === 0) {
1759
+ placeholderPattern.lastIndex += 1;
1760
+ }
1761
+ }
1762
+
1763
+ if (lastIndex < text.length) {
1764
+ fragment.appendChild(document.createTextNode(text.slice(lastIndex)));
1765
+ }
1766
+
1767
+ if (textNode.parentNode) {
1768
+ textNode.parentNode.replaceChild(fragment, textNode);
1769
+ }
1770
+ });
1771
+ }
1772
+
1625
1773
  function applyAnnotationMarkersToElement(targetEl, mode) {
1626
1774
  if (!targetEl || mode === "none") return;
1627
1775
  if (typeof document.createTreeWalker !== "function") return;
@@ -2094,8 +2242,12 @@
2094
2242
  }
2095
2243
 
2096
2244
  async function applyRenderedMarkdown(targetEl, markdown, pane, nonce) {
2245
+ const previewPrepared = annotationsEnabled
2246
+ ? prepareMarkdownForPandocPreview(markdown)
2247
+ : { markdown: stripAnnotationMarkers(String(markdown || "")), placeholders: [] };
2248
+
2097
2249
  try {
2098
- const renderedHtml = await renderMarkdownWithPandoc(markdown);
2250
+ const renderedHtml = await renderMarkdownWithPandoc(previewPrepared.markdown);
2099
2251
 
2100
2252
  if (pane === "source") {
2101
2253
  if (nonce !== sourcePreviewRenderNonce || editorView !== "preview") return;
@@ -2105,6 +2257,7 @@
2105
2257
 
2106
2258
  finishPreviewRender(targetEl);
2107
2259
  targetEl.innerHTML = sanitizeRenderedHtml(renderedHtml, markdown);
2260
+ applyPreviewAnnotationPlaceholdersToElement(targetEl, previewPrepared.placeholders);
2108
2261
  decoratePdfEmbeds(targetEl);
2109
2262
  await renderPdfPreviewsInElement(targetEl);
2110
2263
  const annotationMode = (pane === "source" || pane === "response")
@@ -2544,6 +2697,10 @@
2544
2697
  .replace(/'/g, "&#39;");
2545
2698
  }
2546
2699
 
2700
+ function escapeRegExp(text) {
2701
+ return String(text || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2702
+ }
2703
+
2547
2704
  function wrapHighlight(className, text) {
2548
2705
  return "<span class='" + className + "'>" + escapeHtml(String(text || "")) + "</span>";
2549
2706
  }
package/client/studio.css CHANGED
@@ -516,7 +516,11 @@
516
516
  background: var(--accent-soft);
517
517
  border: 1px solid var(--marker-border);
518
518
  border-radius: 4px;
519
+ display: inline-block;
520
+ max-width: 100%;
519
521
  padding: 0 4px;
522
+ white-space: normal;
523
+ vertical-align: baseline;
520
524
  }
521
525
 
522
526
  #sourcePreview {
package/index.ts CHANGED
@@ -236,10 +236,20 @@ function buildStudioPdfPreamble(options?: StudioPdfRenderOptions): string {
236
236
  \\titlespacing*{\\section}{0pt}{${sectionSpaceBefore}}{${sectionSpaceAfter}}
237
237
  \\titlespacing*{\\subsection}{0pt}{${subsectionSpaceBefore}}{${subsectionSpaceAfter}}
238
238
  \\usepackage{xcolor}
239
+ \\usepackage{varwidth}
239
240
  \\definecolor{StudioAnnotationBg}{HTML}{EAF3FF}
240
241
  \\definecolor{StudioAnnotationBorder}{HTML}{8CB8FF}
241
242
  \\definecolor{StudioAnnotationText}{HTML}{1F5FBF}
242
- \\newcommand{\\studioannotation}[1]{\\begingroup\\setlength{\\fboxsep}{1.5pt}\\fcolorbox{StudioAnnotationBorder}{StudioAnnotationBg}{\\textcolor{StudioAnnotationText}{\\sffamily\\footnotesize\\strut #1}}\\endgroup}
243
+ \\definecolor{StudioDiffAddText}{HTML}{1A7F37}
244
+ \\definecolor{StudioDiffDelText}{HTML}{CF222E}
245
+ \\definecolor{StudioDiffMetaText}{HTML}{57606A}
246
+ \\definecolor{StudioDiffHunkText}{HTML}{0969DA}
247
+ \\newcommand{\\studioannotation}[1]{\\begingroup\\setlength{\\fboxsep}{1.5pt}\\fcolorbox{StudioAnnotationBorder}{StudioAnnotationBg}{\\begin{varwidth}{\\dimexpr\\linewidth-2\\fboxsep-2\\fboxrule\\relax}\\raggedright\\textcolor{StudioAnnotationText}{\\sffamily\\footnotesize\\strut #1}\\end{varwidth}}\\endgroup}
248
+ \\newcommand{\\StudioDiffAddTok}[1]{\\textcolor{StudioDiffAddText}{#1}}
249
+ \\newcommand{\\StudioDiffDelTok}[1]{\\textcolor{StudioDiffDelText}{#1}}
250
+ \\newcommand{\\StudioDiffMetaTok}[1]{\\textcolor{StudioDiffMetaText}{#1}}
251
+ \\newcommand{\\StudioDiffHunkTok}[1]{\\textcolor{StudioDiffHunkText}{#1}}
252
+ \\newcommand{\\StudioDiffHeaderTok}[1]{\\textcolor{StudioDiffHunkText}{\\textbf{#1}}}
243
253
  \\newenvironment{studiocallout}[1]{\\par\\vspace{0.22em}\\noindent\\begingroup\\color{StudioAnnotationBorder}\\hrule height 0.45pt\\color{black}\\vspace{0.08em}\\noindent{\\sffamily\\bfseries\\textcolor{StudioAnnotationText}{#1}}\\par\\vspace{0.02em}\\leftskip=0.7em\\rightskip=0pt\\parindent=0pt\\parskip=0.15em}{\\par\\vspace{0.02em}\\noindent\\color{StudioAnnotationBorder}\\hrule height 0.45pt\\par\\endgroup\\vspace{0.22em}}
244
254
  \\usepackage{caption}
245
255
  \\captionsetup[figure]{justification=raggedright,singlelinecheck=false}
@@ -2892,6 +2902,105 @@ function wrapStudioCodeAsMarkdown(code: string, language?: string): string {
2892
2902
  return `${marker}${lang}\n${source}\n${marker}`;
2893
2903
  }
2894
2904
 
2905
+ function extractStudioFenceInfoLanguage(info: string): string | undefined {
2906
+ const firstToken = String(info ?? "").trim().split(/\s+/)[0]?.replace(/^\./, "") ?? "";
2907
+ return normalizeStudioEditorLanguage(firstToken || undefined);
2908
+ }
2909
+
2910
+ function normalizeStudioMarkdownFencedBlocks(markdown: string): string {
2911
+ const lines = String(markdown ?? "").replace(/\r\n/g, "\n").split("\n");
2912
+ const out: string[] = [];
2913
+
2914
+ for (let index = 0; index < lines.length; index += 1) {
2915
+ const line = lines[index] ?? "";
2916
+ const openingMatch = line.match(/^(\s{0,3})(`{3,}|~{3,})([^\n]*)$/);
2917
+ if (!openingMatch) {
2918
+ out.push(line);
2919
+ continue;
2920
+ }
2921
+
2922
+ const indent = openingMatch[1] ?? "";
2923
+ const openingFence = openingMatch[2]!;
2924
+ const openingSuffix = openingMatch[3] ?? "";
2925
+ const fenceChar = openingFence[0] as "`" | "~";
2926
+ const fenceLength = openingFence.length;
2927
+
2928
+ let closingIndex = -1;
2929
+ for (let innerIndex = index + 1; innerIndex < lines.length; innerIndex += 1) {
2930
+ const innerLine = lines[innerIndex] ?? "";
2931
+ const closingMatch = innerLine.match(/^\s{0,3}(`{3,}|~{3,})\s*$/);
2932
+ if (!closingMatch) continue;
2933
+ const closingFence = closingMatch[1]!;
2934
+ if (closingFence[0] !== fenceChar || closingFence.length < fenceLength) continue;
2935
+ closingIndex = innerIndex;
2936
+ break;
2937
+ }
2938
+
2939
+ if (closingIndex === -1) {
2940
+ out.push(line);
2941
+ continue;
2942
+ }
2943
+
2944
+ const contentLines = lines.slice(index + 1, closingIndex);
2945
+ const content = contentLines.join("\n");
2946
+ const maxBackticks = getLongestStudioFenceRun(content, "`");
2947
+ const maxTildes = getLongestStudioFenceRun(content, "~");
2948
+ const currentMaxRun = fenceChar === "`" ? maxBackticks : maxTildes;
2949
+
2950
+ if (currentMaxRun < fenceLength) {
2951
+ out.push(line, ...contentLines, lines[closingIndex] ?? "");
2952
+ index = closingIndex;
2953
+ continue;
2954
+ }
2955
+
2956
+ const neededBackticks = Math.max(3, maxBackticks + 1);
2957
+ const neededTildes = Math.max(3, maxTildes + 1);
2958
+ let markerChar: "`" | "~" = fenceChar;
2959
+
2960
+ if (neededBackticks < neededTildes) {
2961
+ markerChar = "`";
2962
+ } else if (neededTildes < neededBackticks) {
2963
+ markerChar = "~";
2964
+ } else if (fenceChar === "`") {
2965
+ markerChar = "~";
2966
+ }
2967
+
2968
+ const markerLength = markerChar === "`" ? neededBackticks : neededTildes;
2969
+ const marker = markerChar.repeat(markerLength);
2970
+ out.push(`${indent}${marker}${openingSuffix}`, ...contentLines, `${indent}${marker}`);
2971
+ index = closingIndex;
2972
+ }
2973
+
2974
+ return out.join("\n");
2975
+ }
2976
+
2977
+ function hasStudioMarkdownDiffFence(markdown: string): boolean {
2978
+ const lines = String(markdown ?? "").replace(/\r\n/g, "\n").split("\n");
2979
+
2980
+ for (let index = 0; index < lines.length; index += 1) {
2981
+ const line = lines[index] ?? "";
2982
+ const openingMatch = line.match(/^\s{0,3}(`{3,}|~{3,})([^\n]*)$/);
2983
+ if (!openingMatch) continue;
2984
+
2985
+ const openingFence = openingMatch[1]!;
2986
+ const infoLanguage = extractStudioFenceInfoLanguage(openingMatch[2] ?? "");
2987
+ if (infoLanguage !== "diff") continue;
2988
+
2989
+ const fenceChar = openingFence[0];
2990
+ const fenceLength = openingFence.length;
2991
+ for (let innerIndex = index + 1; innerIndex < lines.length; innerIndex += 1) {
2992
+ const innerLine = lines[innerIndex] ?? "";
2993
+ const closingMatch = innerLine.match(/^\s{0,3}(`{3,}|~{3,})\s*$/);
2994
+ if (!closingMatch) continue;
2995
+ const closingFence = closingMatch[1]!;
2996
+ if (closingFence[0] !== fenceChar || closingFence.length < fenceLength) continue;
2997
+ return true;
2998
+ }
2999
+ }
3000
+
3001
+ return false;
3002
+ }
3003
+
2895
3004
  function isLikelyRawStudioGitDiff(markdown: string): boolean {
2896
3005
  const text = String(markdown ?? "");
2897
3006
  if (!text.trim() || isStudioSingleFencedCodeBlock(text)) return false;
@@ -2914,16 +3023,66 @@ function inferStudioPdfLanguage(markdown: string, editorLanguage?: string): stri
2914
3023
  return undefined;
2915
3024
  }
2916
3025
 
2917
- function escapeStudioPdfLatexText(text: string): string {
3026
+ function escapeStudioPdfLatexTextFragment(text: string): string {
2918
3027
  return String(text ?? "")
2919
- .replace(/\r\n/g, "\n")
2920
- .replace(/\s*\n\s*/g, " ")
2921
- .trim()
2922
3028
  .replace(/\\/g, "\\textbackslash{}")
2923
3029
  .replace(/([{}%#$&_])/g, "\\$1")
2924
3030
  .replace(/~/g, "\\textasciitilde{}")
2925
- .replace(/\^/g, "\\textasciicircum{}")
2926
- .replace(/\s{2,}/g, " ");
3031
+ .replace(/\^/g, "\\textasciicircum{}");
3032
+ }
3033
+
3034
+ function escapeStudioPdfLatexText(text: string): string {
3035
+ const normalized = String(text ?? "")
3036
+ .replace(/\r\n/g, "\n")
3037
+ .replace(/\s*\n\s*/g, " ")
3038
+ .replace(/\s{2,}/g, " ")
3039
+ .trim();
3040
+ if (!normalized) return "";
3041
+
3042
+ const mathPattern = /\\\(([\s\S]*?)\\\)|\\\[([\s\S]*?)\\\]|\$\$([\s\S]*?)\$\$|\$([^$\n]+?)\$/g;
3043
+ let out = "";
3044
+ let lastIndex = 0;
3045
+ let match: RegExpExecArray | null;
3046
+
3047
+ while ((match = mathPattern.exec(normalized)) !== null) {
3048
+ const token = match[0] ?? "";
3049
+ const start = match.index;
3050
+ if (start > lastIndex) {
3051
+ out += escapeStudioPdfLatexTextFragment(normalized.slice(lastIndex, start));
3052
+ }
3053
+
3054
+ const inlineParenExpr = match[1];
3055
+ const displayBracketExpr = match[2];
3056
+ const displayDollarExpr = match[3];
3057
+ const inlineDollarExpr = match[4];
3058
+ let mathLatex = "";
3059
+
3060
+ if (typeof inlineParenExpr === "string" && isLikelyMathExpression(inlineParenExpr)) {
3061
+ const content = inlineParenExpr.trim();
3062
+ mathLatex = content ? `\\(${content}\\)` : "";
3063
+ } else if (typeof displayBracketExpr === "string" && isLikelyMathExpression(displayBracketExpr)) {
3064
+ const content = collapseDisplayMathContent(displayBracketExpr);
3065
+ mathLatex = content ? `\\(${content}\\)` : "";
3066
+ } else if (typeof displayDollarExpr === "string" && isLikelyMathExpression(displayDollarExpr)) {
3067
+ const content = collapseDisplayMathContent(displayDollarExpr);
3068
+ mathLatex = content ? `\\(${content}\\)` : "";
3069
+ } else if (typeof inlineDollarExpr === "string" && isLikelyMathExpression(inlineDollarExpr)) {
3070
+ const content = inlineDollarExpr.trim();
3071
+ mathLatex = content ? `\\(${content}\\)` : "";
3072
+ }
3073
+
3074
+ out += mathLatex || escapeStudioPdfLatexTextFragment(token);
3075
+ lastIndex = start + token.length;
3076
+ if (token.length === 0) {
3077
+ mathPattern.lastIndex += 1;
3078
+ }
3079
+ }
3080
+
3081
+ if (lastIndex < normalized.length) {
3082
+ out += escapeStudioPdfLatexTextFragment(normalized.slice(lastIndex));
3083
+ }
3084
+
3085
+ return out.trim();
2927
3086
  }
2928
3087
 
2929
3088
  function replaceStudioAnnotationMarkersForPdfInSegment(text: string): string {
@@ -3697,7 +3856,9 @@ async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolea
3697
3856
  // Embed images as data URIs so they render in the browser preview
3698
3857
  args.push("--embed-resources", "--standalone");
3699
3858
  }
3700
- const normalizedMarkdown = isLatex ? sourceWithResolvedRefs : normalizeObsidianImages(normalizeMathDelimiters(sourceWithResolvedRefs));
3859
+ const normalizedMarkdown = isLatex
3860
+ ? sourceWithResolvedRefs
3861
+ : normalizeStudioMarkdownFencedBlocks(normalizeObsidianImages(normalizeMathDelimiters(sourceWithResolvedRefs)));
3701
3862
  const pandocWorkingDir = resolveStudioPandocWorkingDir(resourcePath);
3702
3863
 
3703
3864
  let renderedHtml = await new Promise<string>((resolve, reject) => {
@@ -3888,6 +4049,114 @@ function replaceStudioAnnotationMarkersInGeneratedLatex(latex: string): string {
3888
4049
  return out.join("\n");
3889
4050
  }
3890
4051
 
4052
+ function isStudioGeneratedDiffHighlightingBlock(lines: string[]): boolean {
4053
+ const body = lines.join("\n");
4054
+ const hasAdditionOrDeletion = /\\VariableTok\{\+|\\StringTok\{\{-\}/.test(body);
4055
+ const hasDiffStructure = /\\DataTypeTok\{@@|\\NormalTok\{diff \{-\}\{-\}git |\\KeywordTok\{\{-\}\{-\}\{-\}|\\DataTypeTok\{\+\+\+/.test(body);
4056
+ return hasAdditionOrDeletion && hasDiffStructure;
4057
+ }
4058
+
4059
+ function replaceStudioAnnotationMarkersInDiffTokenLine(line: string, macroName: string): string {
4060
+ const tokenMatch = line.match(new RegExp(`^\\\\${macroName}\\{([\\s\\S]*)\\}$`));
4061
+ if (!tokenMatch) return line;
4062
+
4063
+ const body = tokenMatch[1] ?? "";
4064
+ const markerPattern = /\[an:\s*([^\]]+?)\]/gi;
4065
+ let lastIndex = 0;
4066
+ let rewritten = "";
4067
+ let match: RegExpExecArray | null;
4068
+
4069
+ const wrapText = (text: string): string => text ? `\\${macroName}{${text}}` : "";
4070
+
4071
+ while ((match = markerPattern.exec(body)) !== null) {
4072
+ const token = match[0] ?? "";
4073
+ const start = match.index;
4074
+ if (start > lastIndex) {
4075
+ rewritten += wrapText(body.slice(lastIndex, start));
4076
+ }
4077
+
4078
+ const markerText = (match[1] ?? "").replace(/\s{2,}/g, " ").trim();
4079
+ if (markerText) {
4080
+ rewritten += `\\studioannotation{${markerText}}`;
4081
+ }
4082
+
4083
+ lastIndex = start + token.length;
4084
+ if (token.length === 0) {
4085
+ markerPattern.lastIndex += 1;
4086
+ }
4087
+ }
4088
+
4089
+ if (lastIndex === 0) return line;
4090
+ if (lastIndex < body.length) {
4091
+ rewritten += wrapText(body.slice(lastIndex));
4092
+ }
4093
+
4094
+ return rewritten || wrapText(body);
4095
+ }
4096
+
4097
+ function rewriteStudioGeneratedDiffHighlighting(latex: string): string {
4098
+ const lines = String(latex ?? "").split("\n");
4099
+ const out: string[] = [];
4100
+
4101
+ for (let index = 0; index < lines.length; index += 1) {
4102
+ const line = lines[index] ?? "";
4103
+ if (!/^\\begin\{Highlighting\}/.test(line)) {
4104
+ out.push(line);
4105
+ continue;
4106
+ }
4107
+
4108
+ let closingIndex = -1;
4109
+ for (let innerIndex = index + 1; innerIndex < lines.length; innerIndex += 1) {
4110
+ if (/^\\end\{Highlighting\}/.test(lines[innerIndex] ?? "")) {
4111
+ closingIndex = innerIndex;
4112
+ break;
4113
+ }
4114
+ }
4115
+
4116
+ if (closingIndex === -1) {
4117
+ out.push(line);
4118
+ continue;
4119
+ }
4120
+
4121
+ const blockLines = lines.slice(index, closingIndex + 1);
4122
+ if (!isStudioGeneratedDiffHighlightingBlock(blockLines)) {
4123
+ out.push(...blockLines);
4124
+ index = closingIndex;
4125
+ continue;
4126
+ }
4127
+
4128
+ const rewrittenBlock = blockLines.map((blockLine) => {
4129
+ if (/^\\VariableTok\{/.test(blockLine)) {
4130
+ return replaceStudioAnnotationMarkersInDiffTokenLine(
4131
+ blockLine.replace(/^\\VariableTok\{/, "\\StudioDiffAddTok{"),
4132
+ "StudioDiffAddTok",
4133
+ );
4134
+ }
4135
+ if (/^\\StringTok\{/.test(blockLine)) {
4136
+ return replaceStudioAnnotationMarkersInDiffTokenLine(
4137
+ blockLine.replace(/^\\StringTok\{/, "\\StudioDiffDelTok{"),
4138
+ "StudioDiffDelTok",
4139
+ );
4140
+ }
4141
+ if (/^\\DataTypeTok\{@@/.test(blockLine)) return blockLine.replace(/^\\DataTypeTok\{/, "\\StudioDiffHunkTok{");
4142
+ if (/^\\DataTypeTok\{\+\+\+/.test(blockLine)) return blockLine.replace(/^\\DataTypeTok\{/, "\\StudioDiffHeaderTok{");
4143
+ if (/^\\KeywordTok\{\{-\}\{-\}\{-\}/.test(blockLine)) return blockLine.replace(/^\\KeywordTok\{/, "\\StudioDiffHeaderTok{");
4144
+ if (/^\\NormalTok\{(?:diff \{-\}\{-\}git |index |new file mode |deleted file mode |similarity index |rename from |rename to |Binary files )/.test(blockLine)) {
4145
+ return replaceStudioAnnotationMarkersInDiffTokenLine(
4146
+ blockLine.replace(/^\\NormalTok\{/, "\\StudioDiffMetaTok{"),
4147
+ "StudioDiffMetaTok",
4148
+ );
4149
+ }
4150
+ return blockLine;
4151
+ });
4152
+
4153
+ out.push(...rewrittenBlock);
4154
+ index = closingIndex;
4155
+ }
4156
+
4157
+ return out.join("\n");
4158
+ }
4159
+
3891
4160
  async function renderStudioPdfFromGeneratedLatex(
3892
4161
  markdown: string,
3893
4162
  pandocCommand: string,
@@ -3923,6 +4192,8 @@ async function renderStudioPdfFromGeneratedLatex(
3923
4192
  ];
3924
4193
  if (resourcePath) pandocArgs.push(`--resource-path=${resourcePath}`);
3925
4194
 
4195
+ const pandocSource = inputFormat === "latex" ? markdown : normalizeStudioMarkdownFencedBlocks(markdown);
4196
+
3926
4197
  try {
3927
4198
  await new Promise<void>((resolve, reject) => {
3928
4199
  const child = spawn(pandocCommand, pandocArgs, { stdio: ["pipe", "pipe", "pipe"], cwd: pandocWorkingDir });
@@ -3962,13 +4233,14 @@ async function renderStudioPdfFromGeneratedLatex(
3962
4233
  fail(new Error(`pandoc LaTeX generation failed with exit code ${code}${stderr ? `: ${stderr}` : ""}`));
3963
4234
  });
3964
4235
 
3965
- child.stdin.end(markdown);
4236
+ child.stdin.end(pandocSource);
3966
4237
  });
3967
4238
 
3968
4239
  const generatedLatex = await readFile(latexPath, "utf-8");
3969
4240
  const injectedLatex = injectStudioLatexPdfSubfigureBlocks(generatedLatex, subfigureGroups, sourcePath, resourcePath);
3970
4241
  const annotationReadyLatex = replaceStudioAnnotationMarkersInGeneratedLatex(injectedLatex);
3971
- const calloutReadyLatex = replaceStudioPdfCalloutBlocksInGeneratedLatex(annotationReadyLatex, calloutBlocks);
4242
+ const diffReadyLatex = rewriteStudioGeneratedDiffHighlighting(annotationReadyLatex);
4243
+ const calloutReadyLatex = replaceStudioPdfCalloutBlocksInGeneratedLatex(diffReadyLatex, calloutBlocks);
3972
4244
  const alignedReadyLatex = replaceStudioPdfAlignedImageBlocksInGeneratedLatex(calloutReadyLatex, alignedImageBlocks);
3973
4245
  const normalizedLatex = normalizeStudioGeneratedFigureCaptions(alignedReadyLatex);
3974
4246
  await writeFile(latexPath, normalizedLatex, "utf-8");
@@ -4067,6 +4339,7 @@ async function renderStudioPdfWithPandoc(
4067
4339
  markdownForPdf: string,
4068
4340
  warning?: string,
4069
4341
  ): Promise<{ pdf: Buffer; warning?: string }> => {
4342
+ const pandocSource = inputFormat === "latex" ? markdownForPdf : normalizeStudioMarkdownFencedBlocks(markdownForPdf);
4070
4343
  const tempDir = join(tmpdir(), `pi-studio-pdf-${Date.now()}-${randomUUID()}`);
4071
4344
  const preamblePath = join(tempDir, "_pdf_preamble.tex");
4072
4345
  const outputPath = join(tempDir, "studio-export.pdf");
@@ -4128,7 +4401,7 @@ async function renderStudioPdfWithPandoc(
4128
4401
  fail(new Error(`pandoc PDF export failed with exit code ${code}${stderr ? `: ${stderr}` : ""}${hint}`));
4129
4402
  });
4130
4403
 
4131
- child.stdin.end(markdownForPdf);
4404
+ child.stdin.end(pandocSource);
4132
4405
  });
4133
4406
 
4134
4407
  return { pdf: await readFile(outputPath), warning };
@@ -4158,7 +4431,20 @@ async function renderStudioPdfWithPandoc(
4158
4431
  const inputFormat = "markdown+lists_without_preceding_blankline-blank_before_blockquote-blank_before_header+tex_math_dollars+autolink_bare_uris+superscript+subscript-raw_html";
4159
4432
  const diffMarkdown = prepareStudioPdfMarkdown(markdown, false, effectiveEditorLanguage);
4160
4433
  try {
4161
- return await runPandocPdfExport(inputFormat, diffMarkdown);
4434
+ return await renderStudioPdfFromGeneratedLatex(
4435
+ diffMarkdown,
4436
+ pandocCommand,
4437
+ pdfEngine,
4438
+ resourcePath,
4439
+ pandocWorkingDir,
4440
+ bibliographyArgs,
4441
+ sourcePath,
4442
+ [],
4443
+ inputFormat,
4444
+ [],
4445
+ [],
4446
+ pdfOptions,
4447
+ );
4162
4448
  } catch {
4163
4449
  const fenced = parseStudioSingleFencedCodeBlock(diffMarkdown);
4164
4450
  const diffText = fenced ? fenced.content : markdown;
@@ -4185,8 +4471,9 @@ async function renderStudioPdfWithPandoc(
4185
4471
  ? { markdown: normalizedMarkdown, found: 0, replaced: 0, failed: 0, missingCli: false }
4186
4472
  : await preprocessStudioMermaidForPdf(normalizedMarkdown, tempDir);
4187
4473
  const markdownForPdf = mermaidPrepared.markdown;
4474
+ const hasDiffBlocks = !isLatex && hasStudioMarkdownDiffFence(markdownForPdf);
4188
4475
 
4189
- if (!isLatex && (pdfCalloutTransform.blocks.length > 0 || pdfAlignedImageTransform.blocks.length > 0)) {
4476
+ if (!isLatex && (pdfCalloutTransform.blocks.length > 0 || pdfAlignedImageTransform.blocks.length > 0 || hasDiffBlocks)) {
4190
4477
  const rendered = await renderStudioPdfFromGeneratedLatex(
4191
4478
  markdownForPdf,
4192
4479
  pandocCommand,
@@ -4216,6 +4503,7 @@ async function renderStudioPdfWithPandoc(
4216
4503
  ...bibliographyArgs,
4217
4504
  ];
4218
4505
  if (resourcePath) args.push(`--resource-path=${resourcePath}`);
4506
+ const pandocSource = isLatex ? markdownForPdf : normalizeStudioMarkdownFencedBlocks(markdownForPdf);
4219
4507
 
4220
4508
  try {
4221
4509
  await new Promise<void>((resolve, reject) => {
@@ -4259,7 +4547,7 @@ async function renderStudioPdfWithPandoc(
4259
4547
  fail(new Error(`pandoc PDF export failed with exit code ${code}${stderr ? `: ${stderr}` : ""}${hint}`));
4260
4548
  });
4261
4549
 
4262
- child.stdin.end(markdownForPdf);
4550
+ child.stdin.end(pandocSource);
4263
4551
  });
4264
4552
 
4265
4553
  return { pdf: await readFile(outputPath), warning: mermaidPrepared.warning };
@@ -5185,6 +5473,16 @@ function buildThemeCssVars(style: StudioThemeStyle): Record<string, string> {
5185
5473
  };
5186
5474
  }
5187
5475
 
5476
+ function buildStudioFaviconDataUri(style: StudioThemeStyle): string {
5477
+ const iconFg = style.palette.text;
5478
+ const svg = [
5479
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">',
5480
+ `<text x="32" y="35" text-anchor="middle" dominant-baseline="middle" font-size="50" font-weight="700" font-family="ui-sans-serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" fill="${iconFg}">π</text>`,
5481
+ "</svg>",
5482
+ ].join("");
5483
+ return `data:image/svg+xml,${encodeURIComponent(svg)}`;
5484
+ }
5485
+
5188
5486
  function buildStudioHtml(
5189
5487
  initialDocument: InitialStudioDocument | null,
5190
5488
  studioToken?: string,
@@ -5244,6 +5542,7 @@ function buildStudioHtml(
5244
5542
  const cssVarsBlock = Object.entries(vars).map(([k, v]) => ` ${k}: ${v};`).join("\n");
5245
5543
  const stylesheetHref = `/studio.css?token=${encodeURIComponent(studioToken ?? "")}`;
5246
5544
  const clientScriptHref = `/studio-client.js?token=${encodeURIComponent(studioToken ?? "")}`;
5545
+ const faviconHref = buildStudioFaviconDataUri(style);
5247
5546
  const bootConfigJson = JSON.stringify({ mermaidConfig }).replace(/</g, "\\u003c");
5248
5547
 
5249
5548
  return `<!doctype html>
@@ -5251,7 +5550,8 @@ function buildStudioHtml(
5251
5550
  <head>
5252
5551
  <meta charset="utf-8" />
5253
5552
  <meta name="viewport" content="width=device-width,initial-scale=1" />
5254
- <title>pi Studio</title>
5553
+ <title Studio</title>
5554
+ <link rel="icon" href="${faviconHref}" type="image/svg+xml" />
5255
5555
  <style>
5256
5556
  :root {
5257
5557
  ${cssVarsBlock}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.5.32",
3
+ "version": "0.5.33",
4
4
  "description": "Two-pane browser workspace for pi with prompt/response editing, annotations, critiques, prompt/response history, and live Markdown/LaTeX/code preview",
5
5
  "type": "module",
6
6
  "license": "MIT",