pi-studio 0.5.29 → 0.5.30

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,13 @@ All notable changes to `pi-studio` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.5.30] — 2026-03-24
8
+
9
+ ### Fixed
10
+ - LaTeX preview now preserves structured display-math environments such as `bmatrix` inside `\[ ... \]` instead of flattening their rows during Markdown math normalization, and preview display equations now center more robustly across browser engines.
11
+ - Studio now highlights custom `[an: ...]` markers in LaTeX editor syntax-highlighting mode, and PDF export renders those markers as styled annotation badges for both Markdown and LaTeX documents instead of leaving the raw bracket syntax in the final PDF.
12
+ - Right-pane response PDF export now also respects the current annotation-visibility mode, so hidden annotations do not leak into exported PDFs as raw `[an: ...]` text.
13
+
7
14
  ## [0.5.29] — 2026-03-21
8
15
 
9
16
  ### Changed
@@ -1715,7 +1715,9 @@
1715
1715
  return;
1716
1716
  }
1717
1717
 
1718
- const markdown = rightView === "editor-preview" ? prepareEditorTextForPdfExport(sourceTextEl.value) : latestResponseMarkdown;
1718
+ const markdown = rightView === "editor-preview"
1719
+ ? prepareEditorTextForPdfExport(sourceTextEl.value)
1720
+ : prepareEditorTextForPreview(latestResponseMarkdown);
1719
1721
  if (!markdown || !markdown.trim()) {
1720
1722
  setStatus("Nothing to export yet.", "warning");
1721
1723
  return;
@@ -2558,17 +2560,49 @@
2558
2560
  }
2559
2561
 
2560
2562
  if (lang === "latex") {
2561
- const texPattern = /(%.*$)|(\\(?:documentclass|usepackage|newtheorem|begin|end|section|subsection|subsubsection|chapter|part|title|author|date|maketitle|tableofcontents|includegraphics|caption|label|ref|eqref|cite|textbf|textit|texttt|emph|footnote|centering|newcommand|renewcommand|providecommand|bibliography|bibliographystyle|bibitem|item|input|include)\b)|(\\[A-Za-z]+)|(\{|\})|(\$\$?(?:[^$\\]|\\.)+\$\$?)|(\[(?:.*?)\])/g;
2562
- const highlighted = highlightCodeTokens(source, texPattern, (match) => {
2563
- if (match[1]) return "hl-code-com";
2564
- if (match[2]) return "hl-code-kw";
2565
- if (match[3]) return "hl-code-fn";
2566
- if (match[4]) return "hl-code-op";
2567
- if (match[5]) return "hl-code-str";
2568
- if (match[6]) return "hl-code-num";
2569
- return "hl-code";
2570
- });
2571
- return highlighted;
2563
+ const texPattern = /(%.*$)|(\[an:\s*[^\]]+\])|(\\(?:documentclass|usepackage|newtheorem|begin|end|section|subsection|subsubsection|chapter|part|title|author|date|maketitle|tableofcontents|includegraphics|caption|label|ref|eqref|cite|textbf|textit|texttt|emph|footnote|centering|newcommand|renewcommand|providecommand|bibliography|bibliographystyle|bibitem|item|input|include)\b)|(\\[A-Za-z]+)|(\{|\})|(\$\$?(?:[^$\\]|\\.)+\$\$?)|(\[(?:.*?)\])/gi;
2564
+ let out = "";
2565
+ let lastIndex = 0;
2566
+ texPattern.lastIndex = 0;
2567
+
2568
+ let match;
2569
+ while ((match = texPattern.exec(source)) !== null) {
2570
+ const token = match[0] || "";
2571
+ const start = typeof match.index === "number" ? match.index : 0;
2572
+
2573
+ if (start > lastIndex) {
2574
+ out += escapeHtml(source.slice(lastIndex, start));
2575
+ }
2576
+
2577
+ if (match[1]) {
2578
+ out += wrapHighlight("hl-code-com", token);
2579
+ } else if (match[2]) {
2580
+ out += highlightInlineAnnotations(token, renderMode);
2581
+ } else if (match[3]) {
2582
+ out += wrapHighlight("hl-code-kw", token);
2583
+ } else if (match[4]) {
2584
+ out += wrapHighlight("hl-code-fn", token);
2585
+ } else if (match[5]) {
2586
+ out += wrapHighlight("hl-code-op", token);
2587
+ } else if (match[6]) {
2588
+ out += wrapHighlight("hl-code-str", token);
2589
+ } else if (match[7]) {
2590
+ out += wrapHighlight("hl-code-num", token);
2591
+ } else {
2592
+ out += escapeHtml(token);
2593
+ }
2594
+
2595
+ lastIndex = start + token.length;
2596
+ if (token.length === 0) {
2597
+ texPattern.lastIndex += 1;
2598
+ }
2599
+ }
2600
+
2601
+ if (lastIndex < source.length) {
2602
+ out += escapeHtml(source.slice(lastIndex));
2603
+ }
2604
+
2605
+ return out;
2572
2606
  }
2573
2607
 
2574
2608
  if (lang === "diff") {
package/client/studio.css CHANGED
@@ -874,7 +874,9 @@
874
874
 
875
875
  .rendered-markdown mjx-container[display="true"] {
876
876
  display: block;
877
- margin: 1em 0;
877
+ width: fit-content;
878
+ max-width: 100%;
879
+ margin: 1em auto;
878
880
  text-align: center;
879
881
  overflow-x: auto;
880
882
  overflow-y: hidden;
@@ -893,7 +895,7 @@
893
895
 
894
896
  .rendered-markdown .studio-display-equation-body math[display="block"],
895
897
  .rendered-markdown .studio-display-equation-body mjx-container[display="true"] {
896
- margin: 0;
898
+ margin: 0 auto;
897
899
  }
898
900
 
899
901
  .rendered-markdown .studio-display-equation-number {
@@ -910,7 +912,9 @@
910
912
 
911
913
  .rendered-markdown math[display="block"] {
912
914
  display: block;
913
- margin: 1em 0;
915
+ width: fit-content;
916
+ max-width: 100%;
917
+ margin: 1em auto;
914
918
  text-align: center;
915
919
  overflow-x: auto;
916
920
  overflow-y: hidden;
package/index.ts CHANGED
@@ -205,6 +205,11 @@ const PDF_PREAMBLE = `\\usepackage{titlesec}
205
205
  \\titleformat{\\subsubsection}{\\normalsize\\bfseries\\sffamily}{}{0pt}{}
206
206
  \\titlespacing*{\\section}{0pt}{1.5ex plus 0.5ex minus 0.2ex}{1ex plus 0.2ex}
207
207
  \\titlespacing*{\\subsection}{0pt}{1.2ex plus 0.4ex minus 0.2ex}{0.6ex plus 0.1ex}
208
+ \\usepackage{xcolor}
209
+ \\definecolor{StudioAnnotationBg}{HTML}{EAF3FF}
210
+ \\definecolor{StudioAnnotationBorder}{HTML}{8CB8FF}
211
+ \\definecolor{StudioAnnotationText}{HTML}{1F5FBF}
212
+ \\newcommand{\\studioannotation}[1]{\\begingroup\\setlength{\\fboxsep}{1.5pt}\\fcolorbox{StudioAnnotationBorder}{StudioAnnotationBg}{\\textcolor{StudioAnnotationText}{\\sffamily\\footnotesize\\strut #1}}\\endgroup}
208
213
  \\usepackage{caption}
209
214
  \\captionsetup[figure]{justification=raggedright,singlelinecheck=false}
210
215
  \\usepackage{enumitem}
@@ -2466,6 +2471,9 @@ function isLikelyMathExpression(expr: string): boolean {
2466
2471
 
2467
2472
  function collapseDisplayMathContent(expr: string): string {
2468
2473
  let content = expr.trim();
2474
+ if (/\\begin\{[^}]+\}|\\end\{[^}]+\}/.test(content)) {
2475
+ return content;
2476
+ }
2469
2477
  if (content.includes("\\\\") || content.includes("\n")) {
2470
2478
  content = content.replace(/\\\\\s*/g, " ");
2471
2479
  content = content.replace(/\s*\n\s*/g, " ");
@@ -2646,6 +2654,85 @@ function inferStudioPdfLanguage(markdown: string, editorLanguage?: string): stri
2646
2654
  return undefined;
2647
2655
  }
2648
2656
 
2657
+ function escapeStudioPdfLatexText(text: string): string {
2658
+ return String(text ?? "")
2659
+ .replace(/\r\n/g, "\n")
2660
+ .replace(/\s*\n\s*/g, " ")
2661
+ .trim()
2662
+ .replace(/\\/g, "\\textbackslash{}")
2663
+ .replace(/([{}%#$&_])/g, "\\$1")
2664
+ .replace(/~/g, "\\textasciitilde{}")
2665
+ .replace(/\^/g, "\\textasciicircum{}")
2666
+ .replace(/\s{2,}/g, " ");
2667
+ }
2668
+
2669
+ function replaceStudioAnnotationMarkersForPdfInSegment(text: string): string {
2670
+ return String(text ?? "")
2671
+ .replace(/\[an:\s*([^\]]+?)\]/gi, (_match, markerText: string) => {
2672
+ const cleaned = escapeStudioPdfLatexText(markerText);
2673
+ if (!cleaned) return "";
2674
+ return `\\studioannotation{${cleaned}}`;
2675
+ })
2676
+ .replace(/\{\[\}\s*an:\s*([\s\S]*?)\s*\{\]\}/gi, (_match, markerText: string) => {
2677
+ const cleaned = escapeStudioPdfLatexText(markerText);
2678
+ if (!cleaned) return "";
2679
+ return `\\studioannotation{${cleaned}}`;
2680
+ });
2681
+ }
2682
+
2683
+ function replaceStudioAnnotationMarkersForPdf(markdown: string): string {
2684
+ const lines = String(markdown ?? "").split("\n");
2685
+ const out: string[] = [];
2686
+ let plainBuffer: string[] = [];
2687
+ let inFence = false;
2688
+ let fenceChar: "`" | "~" | undefined;
2689
+ let fenceLength = 0;
2690
+
2691
+ const flushPlain = () => {
2692
+ if (plainBuffer.length === 0) return;
2693
+ out.push(replaceStudioAnnotationMarkersForPdfInSegment(plainBuffer.join("\n")));
2694
+ plainBuffer = [];
2695
+ };
2696
+
2697
+ for (const line of lines) {
2698
+ const trimmed = line.trimStart();
2699
+ const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/);
2700
+
2701
+ if (fenceMatch) {
2702
+ const marker = fenceMatch[1]!;
2703
+ const markerChar = marker[0] as "`" | "~";
2704
+ const markerLength = marker.length;
2705
+
2706
+ if (!inFence) {
2707
+ flushPlain();
2708
+ inFence = true;
2709
+ fenceChar = markerChar;
2710
+ fenceLength = markerLength;
2711
+ out.push(line);
2712
+ continue;
2713
+ }
2714
+
2715
+ if (fenceChar === markerChar && markerLength >= fenceLength) {
2716
+ inFence = false;
2717
+ fenceChar = undefined;
2718
+ fenceLength = 0;
2719
+ }
2720
+
2721
+ out.push(line);
2722
+ continue;
2723
+ }
2724
+
2725
+ if (inFence) {
2726
+ out.push(line);
2727
+ } else {
2728
+ plainBuffer.push(line);
2729
+ }
2730
+ }
2731
+
2732
+ flushPlain();
2733
+ return out.join("\n");
2734
+ }
2735
+
2649
2736
  function prepareStudioPdfMarkdown(markdown: string, isLatex?: boolean, editorLanguage?: string): string {
2650
2737
  if (isLatex) return markdown;
2651
2738
  const effectiveEditorLanguage = inferStudioPdfLanguage(markdown, editorLanguage);
@@ -2653,7 +2740,10 @@ function prepareStudioPdfMarkdown(markdown: string, isLatex?: boolean, editorLan
2653
2740
  && !isStudioSingleFencedCodeBlock(markdown)
2654
2741
  ? wrapStudioCodeAsMarkdown(markdown, effectiveEditorLanguage)
2655
2742
  : markdown;
2656
- return normalizeObsidianImages(normalizeMathDelimiters(source));
2743
+ const annotationReadySource = !effectiveEditorLanguage || effectiveEditorLanguage === "markdown" || effectiveEditorLanguage === "latex"
2744
+ ? replaceStudioAnnotationMarkersForPdf(source)
2745
+ : source;
2746
+ return normalizeObsidianImages(normalizeMathDelimiters(annotationReadySource));
2657
2747
  }
2658
2748
 
2659
2749
  function stripMathMlAnnotationTags(html: string): string {
@@ -2980,6 +3070,46 @@ async function renderStudioLiteralTextPdf(text: string, title = "Studio export")
2980
3070
  }
2981
3071
  }
2982
3072
 
3073
+ function replaceStudioAnnotationMarkersInGeneratedLatex(latex: string): string {
3074
+ const lines = String(latex ?? "").split("\n");
3075
+ const out: string[] = [];
3076
+ const rawEnvStack: string[] = [];
3077
+ const rawEnvNames = new Set(["verbatim", "Verbatim", "Highlighting", "lstlisting"]);
3078
+
3079
+ const updateRawEnvStack = (line: string) => {
3080
+ const envPattern = /\\(begin|end)\{([^}]+)\}/g;
3081
+ let match: RegExpExecArray | null;
3082
+ while ((match = envPattern.exec(line)) !== null) {
3083
+ const kind = match[1];
3084
+ const envName = match[2];
3085
+ if (!envName || !rawEnvNames.has(envName)) continue;
3086
+ if (kind === "begin") {
3087
+ rawEnvStack.push(envName);
3088
+ } else {
3089
+ for (let i = rawEnvStack.length - 1; i >= 0; i -= 1) {
3090
+ if (rawEnvStack[i] === envName) {
3091
+ rawEnvStack.splice(i, 1);
3092
+ break;
3093
+ }
3094
+ }
3095
+ }
3096
+ }
3097
+ };
3098
+
3099
+ for (const line of lines) {
3100
+ if (rawEnvStack.length > 0) {
3101
+ out.push(line);
3102
+ updateRawEnvStack(line);
3103
+ continue;
3104
+ }
3105
+
3106
+ out.push(replaceStudioAnnotationMarkersForPdfInSegment(line));
3107
+ updateRawEnvStack(line);
3108
+ }
3109
+
3110
+ return out.join("\n");
3111
+ }
3112
+
2983
3113
  async function renderStudioPdfFromGeneratedLatex(
2984
3114
  markdown: string,
2985
3115
  pandocCommand: string,
@@ -3057,7 +3187,8 @@ async function renderStudioPdfFromGeneratedLatex(
3057
3187
 
3058
3188
  const generatedLatex = await readFile(latexPath, "utf-8");
3059
3189
  const injectedLatex = injectStudioLatexPdfSubfigureBlocks(generatedLatex, subfigureGroups, sourcePath, resourcePath);
3060
- const normalizedLatex = normalizeStudioGeneratedFigureCaptions(injectedLatex);
3190
+ const annotationReadyLatex = replaceStudioAnnotationMarkersInGeneratedLatex(injectedLatex);
3191
+ const normalizedLatex = normalizeStudioGeneratedFigureCaptions(annotationReadyLatex);
3061
3192
  await writeFile(latexPath, normalizedLatex, "utf-8");
3062
3193
 
3063
3194
  await new Promise<void>((resolve, reject) => {
@@ -3219,7 +3350,7 @@ async function renderStudioPdfWithPandoc(
3219
3350
  }
3220
3351
  };
3221
3352
 
3222
- if (isLatex && latexSubfigurePdfTransform.groups.length > 0) {
3353
+ if (isLatex && (latexSubfigurePdfTransform.groups.length > 0 || /\[an:\s*[^\]]+\]/i.test(sourceWithResolvedRefs))) {
3223
3354
  return await renderStudioPdfFromGeneratedLatex(
3224
3355
  sourceWithResolvedRefs,
3225
3356
  pandocCommand,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.5.29",
3
+ "version": "0.5.30",
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",