pi-studio 0.5.31 → 0.5.32

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/index.ts CHANGED
@@ -199,17 +199,48 @@ const CMUX_STUDIO_STATUS_COLOR_DARK = "#5ea1ff";
199
199
  const CMUX_STUDIO_STATUS_COLOR_LIGHT = "#0047ab";
200
200
  const STUDIO_PROMPT_METADATA_CUSTOM_TYPE = "pi-studio/direct-prompt";
201
201
 
202
- const PDF_PREAMBLE = `\\usepackage{titlesec}
203
- \\titleformat{\\section}{\\Large\\bfseries\\sffamily}{}{0pt}{}[\\vspace{3pt}\\titlerule\\vspace{12pt}]
204
- \\titleformat{\\subsection}{\\large\\bfseries\\sffamily}{}{0pt}{}
205
- \\titleformat{\\subsubsection}{\\normalsize\\bfseries\\sffamily}{}{0pt}{}
206
- \\titlespacing*{\\section}{0pt}{1.5ex plus 0.5ex minus 0.2ex}{1ex plus 0.2ex}
207
- \\titlespacing*{\\subsection}{0pt}{1.2ex plus 0.4ex minus 0.2ex}{0.6ex plus 0.1ex}
202
+ function scaleStudioPdfLength(length: string, factor: number): string | null {
203
+ const match = String(length ?? "").trim().match(/^(\d+(?:\.\d+)?)(pt|bp|mm|cm|in|pc)$/i);
204
+ if (!match) return null;
205
+ const value = Number(match[1]);
206
+ if (!Number.isFinite(value)) return null;
207
+ const scaled = value * factor;
208
+ const formatted = Number.isInteger(scaled) ? String(scaled) : scaled.toFixed(2).replace(/\.0+$/, "").replace(/(\.\d*?)0+$/, "$1");
209
+ return `${formatted}${match[2]}`;
210
+ }
211
+
212
+ function buildStudioPdfHeadingSizeCommand(size: string | undefined, fallback: string): string {
213
+ const trimmed = String(size ?? "").trim();
214
+ if (!trimmed) return fallback;
215
+ const lineHeight = scaleStudioPdfLength(trimmed, 1.2) ?? trimmed;
216
+ return `\\fontsize{${trimmed}}{${lineHeight}}\\selectfont`;
217
+ }
218
+
219
+ function buildStudioPdfTitleSpacingLength(value: string | undefined, fallback: string): string {
220
+ const trimmed = String(value ?? "").trim();
221
+ return trimmed || fallback;
222
+ }
223
+
224
+ function buildStudioPdfPreamble(options?: StudioPdfRenderOptions): string {
225
+ const sectionHeadingSize = buildStudioPdfHeadingSizeCommand(options?.sectionSize, "\\Large");
226
+ const subsectionHeadingSize = buildStudioPdfHeadingSizeCommand(options?.subsectionSize, "\\large");
227
+ const subsubsectionHeadingSize = buildStudioPdfHeadingSizeCommand(options?.subsubsectionSize, "\\normalsize");
228
+ const sectionSpaceBefore = buildStudioPdfTitleSpacingLength(options?.sectionSpaceBefore, "1.5ex plus 0.5ex minus 0.2ex");
229
+ const sectionSpaceAfter = buildStudioPdfTitleSpacingLength(options?.sectionSpaceAfter, "1ex plus 0.2ex");
230
+ const subsectionSpaceBefore = buildStudioPdfTitleSpacingLength(options?.subsectionSpaceBefore, "1.2ex plus 0.4ex minus 0.2ex");
231
+ const subsectionSpaceAfter = buildStudioPdfTitleSpacingLength(options?.subsectionSpaceAfter, "0.6ex plus 0.1ex");
232
+ return `\\usepackage{titlesec}
233
+ \\titleformat{\\section}{${sectionHeadingSize}\\bfseries\\sffamily}{}{0pt}{}[\\vspace{3pt}\\titlerule\\vspace{12pt}]
234
+ \\titleformat{\\subsection}{${subsectionHeadingSize}\\bfseries\\sffamily}{}{0pt}{}
235
+ \\titleformat{\\subsubsection}{${subsubsectionHeadingSize}\\bfseries\\sffamily}{}{0pt}{}
236
+ \\titlespacing*{\\section}{0pt}{${sectionSpaceBefore}}{${sectionSpaceAfter}}
237
+ \\titlespacing*{\\subsection}{0pt}{${subsectionSpaceBefore}}{${subsectionSpaceAfter}}
208
238
  \\usepackage{xcolor}
209
239
  \\definecolor{StudioAnnotationBg}{HTML}{EAF3FF}
210
240
  \\definecolor{StudioAnnotationBorder}{HTML}{8CB8FF}
211
241
  \\definecolor{StudioAnnotationText}{HTML}{1F5FBF}
212
242
  \\newcommand{\\studioannotation}[1]{\\begingroup\\setlength{\\fboxsep}{1.5pt}\\fcolorbox{StudioAnnotationBorder}{StudioAnnotationBg}{\\textcolor{StudioAnnotationText}{\\sffamily\\footnotesize\\strut #1}}\\endgroup}
243
+ \\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}}
213
244
  \\usepackage{caption}
214
245
  \\captionsetup[figure]{justification=raggedright,singlelinecheck=false}
215
246
  \\usepackage{enumitem}
@@ -225,6 +256,7 @@ const PDF_PREAMBLE = `\\usepackage{titlesec}
225
256
  }
226
257
  \\makeatother
227
258
  `;
259
+ }
228
260
 
229
261
  type StudioThemeMode = "dark" | "light";
230
262
 
@@ -886,6 +918,53 @@ function parsePathArgument(args: string): string | null {
886
918
  return trimmed;
887
919
  }
888
920
 
921
+ function tokenizeStudioCommandArgs(input: string): { tokens: string[]; error?: string } {
922
+ const tokens: string[] = [];
923
+ let current = "";
924
+ let quote: '"' | "'" | null = null;
925
+
926
+ for (let i = 0; i < input.length; i += 1) {
927
+ const ch = input[i]!;
928
+ if (quote) {
929
+ if (ch === "\\" && i + 1 < input.length) {
930
+ const next = input[i + 1]!;
931
+ if (next === quote || next === "\\") {
932
+ current += next;
933
+ i += 1;
934
+ continue;
935
+ }
936
+ }
937
+ if (ch === quote) {
938
+ quote = null;
939
+ continue;
940
+ }
941
+ current += ch;
942
+ continue;
943
+ }
944
+
945
+ if (ch === '"' || ch === "'") {
946
+ quote = ch;
947
+ continue;
948
+ }
949
+
950
+ if (/\s/.test(ch)) {
951
+ if (current) {
952
+ tokens.push(current);
953
+ current = "";
954
+ }
955
+ continue;
956
+ }
957
+
958
+ current += ch;
959
+ }
960
+
961
+ if (quote) {
962
+ return { tokens, error: "Unterminated quoted argument." };
963
+ }
964
+ if (current) tokens.push(current);
965
+ return { tokens };
966
+ }
967
+
889
968
  function normalizePathInput(pathInput: string): string {
890
969
  const trimmed = pathInput.trim();
891
970
  if (trimmed.startsWith("@")) return trimmed.slice(1).trim();
@@ -960,7 +1039,7 @@ function readStudioFile(pathArg: string, cwd: string):
960
1039
  function inferStudioPdfLanguageFromPath(pathInput: string): string | undefined {
961
1040
  const extension = extname(pathInput).toLowerCase();
962
1041
  if (extension === ".tex" || extension === ".latex") return "latex";
963
- if (extension === ".md" || extension === ".markdown" || extension === ".mdx") return "markdown";
1042
+ if (extension === ".md" || extension === ".markdown" || extension === ".mdx" || extension === ".qmd") return "markdown";
964
1043
  if (extension === ".diff" || extension === ".patch") return "diff";
965
1044
  return undefined;
966
1045
  }
@@ -2563,6 +2642,187 @@ function normalizeMathDelimiters(markdown: string): string {
2563
2642
  return out.join("\n");
2564
2643
  }
2565
2644
 
2645
+ function stripStudioMarkdownHtmlCommentsInSegment(markdown: string): string {
2646
+ const source = String(markdown ?? "");
2647
+ let out = "";
2648
+ let i = 0;
2649
+ let codeSpanFenceLength = 0;
2650
+ let inHtmlComment = false;
2651
+
2652
+ while (i < source.length) {
2653
+ if (inHtmlComment) {
2654
+ if (source.startsWith("-->", i)) {
2655
+ inHtmlComment = false;
2656
+ i += 3;
2657
+ continue;
2658
+ }
2659
+ const ch = source[i]!;
2660
+ if (ch === "\n" || ch === "\r") out += ch;
2661
+ i += 1;
2662
+ continue;
2663
+ }
2664
+
2665
+ if (codeSpanFenceLength > 0) {
2666
+ const fence = "`".repeat(codeSpanFenceLength);
2667
+ if (source.startsWith(fence, i)) {
2668
+ out += fence;
2669
+ i += codeSpanFenceLength;
2670
+ codeSpanFenceLength = 0;
2671
+ continue;
2672
+ }
2673
+ out += source[i]!;
2674
+ i += 1;
2675
+ continue;
2676
+ }
2677
+
2678
+ const backtickMatch = source.slice(i).match(/^`+/);
2679
+ if (backtickMatch) {
2680
+ const fence = backtickMatch[0]!;
2681
+ codeSpanFenceLength = fence.length;
2682
+ out += fence;
2683
+ i += fence.length;
2684
+ continue;
2685
+ }
2686
+
2687
+ if (source.startsWith("<!--", i)) {
2688
+ inHtmlComment = true;
2689
+ i += 4;
2690
+ continue;
2691
+ }
2692
+
2693
+ out += source[i]!;
2694
+ i += 1;
2695
+ }
2696
+
2697
+ return out;
2698
+ }
2699
+
2700
+ function stripStudioMarkdownHtmlComments(markdown: string): string {
2701
+ const lines = String(markdown ?? "").split("\n");
2702
+ const out: string[] = [];
2703
+ let plainBuffer: string[] = [];
2704
+ let inFence = false;
2705
+ let fenceChar: "`" | "~" | undefined;
2706
+ let fenceLength = 0;
2707
+
2708
+ const flushPlain = () => {
2709
+ if (plainBuffer.length === 0) return;
2710
+ out.push(stripStudioMarkdownHtmlCommentsInSegment(plainBuffer.join("\n")));
2711
+ plainBuffer = [];
2712
+ };
2713
+
2714
+ for (const line of lines) {
2715
+ const trimmed = line.trimStart();
2716
+ const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/);
2717
+
2718
+ if (fenceMatch) {
2719
+ const marker = fenceMatch[1]!;
2720
+ const markerChar = marker[0] as "`" | "~";
2721
+ const markerLength = marker.length;
2722
+
2723
+ if (!inFence) {
2724
+ flushPlain();
2725
+ inFence = true;
2726
+ fenceChar = markerChar;
2727
+ fenceLength = markerLength;
2728
+ out.push(line);
2729
+ continue;
2730
+ }
2731
+
2732
+ if (fenceChar === markerChar && markerLength >= fenceLength) {
2733
+ inFence = false;
2734
+ fenceChar = undefined;
2735
+ fenceLength = 0;
2736
+ }
2737
+
2738
+ out.push(line);
2739
+ continue;
2740
+ }
2741
+
2742
+ if (inFence) {
2743
+ out.push(line);
2744
+ } else {
2745
+ plainBuffer.push(line);
2746
+ }
2747
+ }
2748
+
2749
+ flushPlain();
2750
+ return out.join("\n");
2751
+ }
2752
+
2753
+ const STUDIO_PREVIEW_PAGE_BREAK_SENTINEL_PREFIX = "PI_STUDIO_PAGE_BREAK__";
2754
+
2755
+ function replaceStudioPreviewPageBreakCommands(markdown: string): string {
2756
+ const lines = String(markdown ?? "").split("\n");
2757
+ const out: string[] = [];
2758
+ let plainBuffer: string[] = [];
2759
+ let inFence = false;
2760
+ let fenceChar: "`" | "~" | undefined;
2761
+ let fenceLength = 0;
2762
+
2763
+ const flushPlain = () => {
2764
+ if (plainBuffer.length === 0) return;
2765
+ out.push(
2766
+ plainBuffer.map((line) => {
2767
+ const match = line.trim().match(/^\\(newpage|pagebreak|clearpage)(?:\s*\[[^\]]*\])?\s*$/i);
2768
+ if (!match) return line;
2769
+ const command = match[1]!.toLowerCase();
2770
+ return `${STUDIO_PREVIEW_PAGE_BREAK_SENTINEL_PREFIX}${command.toUpperCase()}__`;
2771
+ }).join("\n"),
2772
+ );
2773
+ plainBuffer = [];
2774
+ };
2775
+
2776
+ for (const line of lines) {
2777
+ const trimmed = line.trimStart();
2778
+ const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/);
2779
+
2780
+ if (fenceMatch) {
2781
+ const marker = fenceMatch[1]!;
2782
+ const markerChar = marker[0] as "`" | "~";
2783
+ const markerLength = marker.length;
2784
+
2785
+ if (!inFence) {
2786
+ flushPlain();
2787
+ inFence = true;
2788
+ fenceChar = markerChar;
2789
+ fenceLength = markerLength;
2790
+ out.push(line);
2791
+ continue;
2792
+ }
2793
+
2794
+ if (fenceChar === markerChar && markerLength >= fenceLength) {
2795
+ inFence = false;
2796
+ fenceChar = undefined;
2797
+ fenceLength = 0;
2798
+ }
2799
+
2800
+ out.push(line);
2801
+ continue;
2802
+ }
2803
+
2804
+ if (inFence) {
2805
+ out.push(line);
2806
+ } else {
2807
+ plainBuffer.push(line);
2808
+ }
2809
+ }
2810
+
2811
+ flushPlain();
2812
+ return out.join("\n");
2813
+ }
2814
+
2815
+ function decorateStudioPreviewPageBreakHtml(html: string): string {
2816
+ return String(html ?? "").replace(
2817
+ new RegExp(`<p>${STUDIO_PREVIEW_PAGE_BREAK_SENTINEL_PREFIX}(NEWPAGE|PAGEBREAK|CLEARPAGE)__<\\/p>`, "gi"),
2818
+ (_match, command: string) => {
2819
+ const normalized = String(command || "").toLowerCase();
2820
+ const label = normalized === "clearpage" ? "Clear page" : "Page break";
2821
+ return `<div class="studio-page-break" data-page-break-kind="${normalized}"><span class="studio-page-break-rule" aria-hidden="true"></span><span class="studio-page-break-label">${escapeStudioHtmlText(label)}</span><span class="studio-page-break-rule" aria-hidden="true"></span></div>`;
2822
+ },
2823
+ );
2824
+ }
2825
+
2566
2826
  function normalizeStudioEditorLanguage(language: string | undefined): string | undefined {
2567
2827
  const trimmed = typeof language === "string" ? language.trim().toLowerCase() : "";
2568
2828
  if (!trimmed) return undefined;
@@ -2733,6 +2993,515 @@ function replaceStudioAnnotationMarkersForPdf(markdown: string): string {
2733
2993
  return out.join("\n");
2734
2994
  }
2735
2995
 
2996
+ interface StudioPdfRenderOptions {
2997
+ fontsize?: string;
2998
+ margin?: string;
2999
+ marginTop?: string;
3000
+ marginRight?: string;
3001
+ marginBottom?: string;
3002
+ marginLeft?: string;
3003
+ footskip?: string;
3004
+ linestretch?: string;
3005
+ mainfont?: string;
3006
+ papersize?: string;
3007
+ geometry?: string;
3008
+ sectionSize?: string;
3009
+ subsectionSize?: string;
3010
+ subsubsectionSize?: string;
3011
+ sectionSpaceBefore?: string;
3012
+ sectionSpaceAfter?: string;
3013
+ subsectionSpaceBefore?: string;
3014
+ subsectionSpaceAfter?: string;
3015
+ }
3016
+
3017
+ interface StudioParsedPdfCommandArgs {
3018
+ pathArg: string;
3019
+ options: StudioPdfRenderOptions;
3020
+ }
3021
+
3022
+ interface StudioPdfMarkdownCalloutBlock {
3023
+ kind: "note" | "tip" | "warning" | "important" | "caution";
3024
+ markerId: number;
3025
+ content: string;
3026
+ }
3027
+
3028
+ function parseStudioFencedDivOpenLine(line: string): { markerLength: number; info: string } | null {
3029
+ const trimmed = String(line ?? "").trim();
3030
+ const match = trimmed.match(/^(:{3,})(.+)$/);
3031
+ if (!match) return null;
3032
+ const info = String(match[2] ?? "").trim();
3033
+ if (!info) return null;
3034
+ return {
3035
+ markerLength: match[1]!.length,
3036
+ info,
3037
+ };
3038
+ }
3039
+
3040
+ function parseStudioPdfCalloutStartLine(line: string): { markerLength: number; kind: StudioPdfMarkdownCalloutBlock["kind"] } | null {
3041
+ const open = parseStudioFencedDivOpenLine(line);
3042
+ if (!open) return null;
3043
+ const kindMatch = open.info.match(/(?:^|[\s{])\.callout-(note|tip|warning|important|caution)(?=[\s}]|$)/i);
3044
+ if (!kindMatch) return null;
3045
+ return {
3046
+ markerLength: open.markerLength,
3047
+ kind: kindMatch[1]!.toLowerCase() as StudioPdfMarkdownCalloutBlock["kind"],
3048
+ };
3049
+ }
3050
+
3051
+ function preprocessStudioMarkdownCalloutsForPdf(markdown: string): { markdown: string; blocks: StudioPdfMarkdownCalloutBlock[] } {
3052
+ const lines = String(markdown ?? "").split("\n");
3053
+ const out: string[] = [];
3054
+ const blocks: StudioPdfMarkdownCalloutBlock[] = [];
3055
+ let inFence = false;
3056
+ let fenceChar: "`" | "~" | undefined;
3057
+ let fenceLength = 0;
3058
+ let markerId = 0;
3059
+
3060
+ for (let i = 0; i < lines.length; i += 1) {
3061
+ const line = lines[i] ?? "";
3062
+ const trimmed = line.trimStart();
3063
+ const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/);
3064
+ if (fenceMatch) {
3065
+ const marker = fenceMatch[1]!;
3066
+ const markerChar = marker[0] as "`" | "~";
3067
+ const markerLength = marker.length;
3068
+ if (!inFence) {
3069
+ inFence = true;
3070
+ fenceChar = markerChar;
3071
+ fenceLength = markerLength;
3072
+ out.push(line);
3073
+ continue;
3074
+ }
3075
+ if (fenceChar === markerChar && markerLength >= fenceLength) {
3076
+ inFence = false;
3077
+ fenceChar = undefined;
3078
+ fenceLength = 0;
3079
+ }
3080
+ out.push(line);
3081
+ continue;
3082
+ }
3083
+ if (inFence) {
3084
+ out.push(line);
3085
+ continue;
3086
+ }
3087
+
3088
+ const calloutStart = parseStudioPdfCalloutStartLine(line);
3089
+ if (!calloutStart) {
3090
+ out.push(line);
3091
+ continue;
3092
+ }
3093
+
3094
+ const contentLines: string[] = [];
3095
+ let innerInFence = false;
3096
+ let innerFenceChar: "`" | "~" | undefined;
3097
+ let innerFenceLength = 0;
3098
+ let nestedDivDepth = 0;
3099
+ let closed = false;
3100
+ let j = i + 1;
3101
+ for (; j < lines.length; j += 1) {
3102
+ const innerLine = lines[j] ?? "";
3103
+ const innerTrimmed = innerLine.trimStart();
3104
+ const innerFenceMatch = innerTrimmed.match(/^(`{3,}|~{3,})/);
3105
+ if (innerFenceMatch) {
3106
+ const marker = innerFenceMatch[1]!;
3107
+ const markerChar = marker[0] as "`" | "~";
3108
+ const markerLength = marker.length;
3109
+ if (!innerInFence) {
3110
+ innerInFence = true;
3111
+ innerFenceChar = markerChar;
3112
+ innerFenceLength = markerLength;
3113
+ contentLines.push(innerLine);
3114
+ continue;
3115
+ }
3116
+ if (innerFenceChar === markerChar && markerLength >= innerFenceLength) {
3117
+ innerInFence = false;
3118
+ innerFenceChar = undefined;
3119
+ innerFenceLength = 0;
3120
+ }
3121
+ contentLines.push(innerLine);
3122
+ continue;
3123
+ }
3124
+ if (!innerInFence) {
3125
+ const nestedOpen = parseStudioFencedDivOpenLine(innerLine);
3126
+ if (nestedOpen) {
3127
+ nestedDivDepth += 1;
3128
+ contentLines.push(innerLine);
3129
+ continue;
3130
+ }
3131
+ if (/^:{3,}\s*$/.test(innerLine.trim())) {
3132
+ if (nestedDivDepth > 0) {
3133
+ nestedDivDepth -= 1;
3134
+ contentLines.push(innerLine);
3135
+ continue;
3136
+ }
3137
+ closed = true;
3138
+ break;
3139
+ }
3140
+ }
3141
+ contentLines.push(innerLine);
3142
+ }
3143
+
3144
+ if (!closed) {
3145
+ out.push(line);
3146
+ out.push(...contentLines);
3147
+ i = j - 1;
3148
+ continue;
3149
+ }
3150
+
3151
+ const block: StudioPdfMarkdownCalloutBlock = {
3152
+ kind: calloutStart.kind,
3153
+ markerId: markerId += 1,
3154
+ content: contentLines.join("\n").trim(),
3155
+ };
3156
+ blocks.push(block);
3157
+ out.push(`PISTUDIOPDFCALLOUTSTART${block.kind.toUpperCase()}${block.markerId}`);
3158
+ if (block.content) out.push(block.content);
3159
+ out.push(`PISTUDIOPDFCALLOUTEND${block.kind.toUpperCase()}${block.markerId}`);
3160
+ i = j;
3161
+ }
3162
+
3163
+ return { markdown: out.join("\n"), blocks };
3164
+ }
3165
+
3166
+ interface StudioPdfAlignedImageBlock {
3167
+ align: "center" | "right";
3168
+ markerId: number;
3169
+ }
3170
+
3171
+ function preprocessStudioMarkdownImageAlignmentForPdf(markdown: string): { markdown: string; blocks: StudioPdfAlignedImageBlock[] } {
3172
+ const lines = String(markdown ?? "").split("\n");
3173
+ const out: string[] = [];
3174
+ const blocks: StudioPdfAlignedImageBlock[] = [];
3175
+ let inFence = false;
3176
+ let fenceChar: "`" | "~" | undefined;
3177
+ let fenceLength = 0;
3178
+ let markerId = 0;
3179
+
3180
+ for (const line of lines) {
3181
+ const trimmed = line.trimStart();
3182
+ const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/);
3183
+ if (fenceMatch) {
3184
+ const marker = fenceMatch[1]!;
3185
+ const markerChar = marker[0] as "`" | "~";
3186
+ const markerLength = marker.length;
3187
+ if (!inFence) {
3188
+ inFence = true;
3189
+ fenceChar = markerChar;
3190
+ fenceLength = markerLength;
3191
+ out.push(line);
3192
+ continue;
3193
+ }
3194
+ if (fenceChar === markerChar && markerLength >= fenceLength) {
3195
+ inFence = false;
3196
+ fenceChar = undefined;
3197
+ fenceLength = 0;
3198
+ }
3199
+ out.push(line);
3200
+ continue;
3201
+ }
3202
+ if (inFence) {
3203
+ out.push(line);
3204
+ continue;
3205
+ }
3206
+
3207
+ const imageMatch = line.trim().match(/^!\[[^\]]*\]\((?:<[^>]+>|[^)]+)\)(\{[^}]*\})\s*$/);
3208
+ if (!imageMatch) {
3209
+ out.push(line);
3210
+ continue;
3211
+ }
3212
+ const attrs = imageMatch[1] ?? "";
3213
+ const alignMatch = attrs.match(/(?:^|\s)fig-align\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s}]+))/i);
3214
+ const alignValue = String(alignMatch?.[1] ?? alignMatch?.[2] ?? alignMatch?.[3] ?? "").trim().toLowerCase();
3215
+ if (alignValue !== "center" && alignValue !== "right") {
3216
+ out.push(line);
3217
+ continue;
3218
+ }
3219
+ const block: StudioPdfAlignedImageBlock = {
3220
+ align: alignValue as StudioPdfAlignedImageBlock["align"],
3221
+ markerId: markerId += 1,
3222
+ };
3223
+ blocks.push(block);
3224
+ out.push(`PISTUDIOPDFALIGNSTART${block.align.toUpperCase()}${block.markerId}`);
3225
+ out.push(line);
3226
+ out.push(`PISTUDIOPDFALIGNEND${block.align.toUpperCase()}${block.markerId}`);
3227
+ }
3228
+
3229
+ return { markdown: out.join("\n"), blocks };
3230
+ }
3231
+
3232
+ function replaceStudioPdfCalloutBlocksInGeneratedLatex(
3233
+ latex: string,
3234
+ blocks: StudioPdfMarkdownCalloutBlock[],
3235
+ ): string {
3236
+ if (blocks.length === 0) return latex;
3237
+ let transformed = String(latex ?? "");
3238
+ for (const block of blocks) {
3239
+ const startMarker = `PISTUDIOPDFCALLOUTSTART${block.kind.toUpperCase()}${block.markerId}`;
3240
+ const endMarker = `PISTUDIOPDFCALLOUTEND${block.kind.toUpperCase()}${block.markerId}`;
3241
+ const startIndex = transformed.indexOf(startMarker);
3242
+ if (startIndex < 0) continue;
3243
+ const endIndex = transformed.indexOf(endMarker, startIndex + startMarker.length);
3244
+ if (endIndex < 0) continue;
3245
+ const inner = transformed.slice(startIndex + startMarker.length, endIndex).trim();
3246
+ const label = block.kind === "note"
3247
+ ? "Note"
3248
+ : block.kind === "tip"
3249
+ ? "Tip"
3250
+ : block.kind === "warning"
3251
+ ? "Warning"
3252
+ : block.kind === "important"
3253
+ ? "Important"
3254
+ : "Caution";
3255
+ const replacement = `\\begin{studiocallout}{${label}}\n${inner}\n\\end{studiocallout}`;
3256
+ transformed = transformed.slice(0, startIndex) + replacement + transformed.slice(endIndex + endMarker.length);
3257
+ }
3258
+ return transformed;
3259
+ }
3260
+
3261
+ function replaceStudioPdfAlignedImageBlocksInGeneratedLatex(
3262
+ latex: string,
3263
+ blocks: StudioPdfAlignedImageBlock[],
3264
+ ): string {
3265
+ if (blocks.length === 0) return latex;
3266
+ let transformed = String(latex ?? "");
3267
+ for (const block of blocks) {
3268
+ const startMarker = `PISTUDIOPDFALIGNSTART${block.align.toUpperCase()}${block.markerId}`;
3269
+ const endMarker = `PISTUDIOPDFALIGNEND${block.align.toUpperCase()}${block.markerId}`;
3270
+ const startIndex = transformed.indexOf(startMarker);
3271
+ if (startIndex < 0) continue;
3272
+ const endIndex = transformed.indexOf(endMarker, startIndex + startMarker.length);
3273
+ if (endIndex < 0) continue;
3274
+ const inner = transformed.slice(startIndex + startMarker.length, endIndex).trim();
3275
+ const env = block.align === "right" ? "flushright" : "center";
3276
+ const replacement = `\\begin{${env}}\n${inner}\n\\end{${env}}`;
3277
+ transformed = transformed.slice(0, startIndex) + replacement + transformed.slice(endIndex + endMarker.length);
3278
+ }
3279
+ return transformed;
3280
+ }
3281
+
3282
+ function isValidStudioPdfLength(value: string): boolean {
3283
+ return /^\d+(?:\.\d+)?(?:pt|bp|mm|cm|in|pc)$/i.test(value.trim());
3284
+ }
3285
+
3286
+ function isValidStudioPdfLineStretch(value: string): boolean {
3287
+ return /^\d+(?:\.\d+)?$/.test(value.trim());
3288
+ }
3289
+
3290
+ function isValidStudioPdfPaperSize(value: string): boolean {
3291
+ return /^[A-Za-z0-9-]+$/.test(value.trim());
3292
+ }
3293
+
3294
+ function sanitizeStudioPdfFreeformOption(value: string): string {
3295
+ return String(value ?? "").replace(/[\r\n]+/g, " ").trim();
3296
+ }
3297
+
3298
+ function parseStudioPdfCommandArgs(args: string): StudioParsedPdfCommandArgs | { error: string } {
3299
+ const parsed = tokenizeStudioCommandArgs(args);
3300
+ if (parsed.error) return { error: parsed.error };
3301
+ const tokens = parsed.tokens;
3302
+ if (tokens.length === 0) return { error: "Missing file path." };
3303
+
3304
+ const options: StudioPdfRenderOptions = {};
3305
+ let pathArg: string | null = null;
3306
+
3307
+ const takeValue = (flag: string, index: number): { value: string; nextIndex: number } | { error: string } => {
3308
+ if (index + 1 >= tokens.length) return { error: `Missing value for ${flag}.` };
3309
+ return { value: tokens[index + 1]!, nextIndex: index + 1 };
3310
+ };
3311
+
3312
+ for (let i = 0; i < tokens.length; i += 1) {
3313
+ const token = tokens[i]!;
3314
+ if (!token.startsWith("-")) {
3315
+ if (pathArg !== null) return { error: `Unexpected extra argument: ${token}` };
3316
+ pathArg = token;
3317
+ continue;
3318
+ }
3319
+
3320
+ if (!token.startsWith("--")) {
3321
+ return { error: `Unknown flag: ${token}` };
3322
+ }
3323
+
3324
+ const taken = takeValue(token, i);
3325
+ if ("error" in taken) return taken;
3326
+ const value = taken.value.trim();
3327
+ i = taken.nextIndex;
3328
+ if (!value) return { error: `Empty value for ${token}.` };
3329
+
3330
+ switch (token) {
3331
+ case "--fontsize":
3332
+ if (!isValidStudioPdfLength(value)) return { error: "Invalid --fontsize value. Example: 12pt" };
3333
+ options.fontsize = value;
3334
+ break;
3335
+ case "--section-size":
3336
+ if (!isValidStudioPdfLength(value)) return { error: "Invalid --section-size value. Example: 24pt" };
3337
+ options.sectionSize = value;
3338
+ break;
3339
+ case "--subsection-size":
3340
+ if (!isValidStudioPdfLength(value)) return { error: "Invalid --subsection-size value. Example: 18pt" };
3341
+ options.subsectionSize = value;
3342
+ break;
3343
+ case "--subsubsection-size":
3344
+ if (!isValidStudioPdfLength(value)) return { error: "Invalid --subsubsection-size value. Example: 14pt" };
3345
+ options.subsubsectionSize = value;
3346
+ break;
3347
+ case "--section-space-before":
3348
+ if (!isValidStudioPdfLength(value)) return { error: "Invalid --section-space-before value. Example: 10mm" };
3349
+ options.sectionSpaceBefore = value;
3350
+ break;
3351
+ case "--section-space-after":
3352
+ if (!isValidStudioPdfLength(value)) return { error: "Invalid --section-space-after value. Example: 6mm" };
3353
+ options.sectionSpaceAfter = value;
3354
+ break;
3355
+ case "--subsection-space-before":
3356
+ if (!isValidStudioPdfLength(value)) return { error: "Invalid --subsection-space-before value. Example: 8mm" };
3357
+ options.subsectionSpaceBefore = value;
3358
+ break;
3359
+ case "--subsection-space-after":
3360
+ if (!isValidStudioPdfLength(value)) return { error: "Invalid --subsection-space-after value. Example: 4mm" };
3361
+ options.subsectionSpaceAfter = value;
3362
+ break;
3363
+ case "--margin":
3364
+ if (!isValidStudioPdfLength(value)) return { error: "Invalid --margin value. Example: 25mm" };
3365
+ options.margin = value;
3366
+ break;
3367
+ case "--margin-top":
3368
+ if (!isValidStudioPdfLength(value)) return { error: "Invalid --margin-top value. Example: 30mm" };
3369
+ options.marginTop = value;
3370
+ break;
3371
+ case "--margin-right":
3372
+ if (!isValidStudioPdfLength(value)) return { error: "Invalid --margin-right value. Example: 25mm" };
3373
+ options.marginRight = value;
3374
+ break;
3375
+ case "--margin-bottom":
3376
+ if (!isValidStudioPdfLength(value)) return { error: "Invalid --margin-bottom value. Example: 30mm" };
3377
+ options.marginBottom = value;
3378
+ break;
3379
+ case "--margin-left":
3380
+ if (!isValidStudioPdfLength(value)) return { error: "Invalid --margin-left value. Example: 25mm" };
3381
+ options.marginLeft = value;
3382
+ break;
3383
+ case "--footskip":
3384
+ if (!isValidStudioPdfLength(value)) return { error: "Invalid --footskip value. Example: 12mm" };
3385
+ options.footskip = value;
3386
+ break;
3387
+ case "--linestretch":
3388
+ if (!isValidStudioPdfLineStretch(value)) return { error: "Invalid --linestretch value. Example: 1.2" };
3389
+ options.linestretch = value;
3390
+ break;
3391
+ case "--mainfont":
3392
+ options.mainfont = sanitizeStudioPdfFreeformOption(value);
3393
+ if (!options.mainfont) return { error: "Invalid --mainfont value." };
3394
+ break;
3395
+ case "--papersize":
3396
+ if (!isValidStudioPdfPaperSize(value)) return { error: "Invalid --papersize value. Example: a4" };
3397
+ options.papersize = value;
3398
+ break;
3399
+ case "--geometry":
3400
+ options.geometry = sanitizeStudioPdfFreeformOption(value);
3401
+ if (!options.geometry) return { error: "Invalid --geometry value." };
3402
+ break;
3403
+ default:
3404
+ return { error: `Unknown flag: ${token}` };
3405
+ }
3406
+ }
3407
+
3408
+ if (!pathArg) return { error: "Missing file path." };
3409
+ if (options.geometry && (options.margin || options.marginTop || options.marginRight || options.marginBottom || options.marginLeft || options.footskip)) {
3410
+ return { error: "Use either --geometry or the --margin/--margin-*/--footskip flags, not both." };
3411
+ }
3412
+
3413
+ return { pathArg, options };
3414
+ }
3415
+
3416
+ function getStudioRequestedPdfFontsizePt(options?: StudioPdfRenderOptions): number | null {
3417
+ const raw = String(options?.fontsize ?? "").trim();
3418
+ const match = raw.match(/^(\d+(?:\.\d+)?)pt$/i);
3419
+ if (!match) return null;
3420
+ const value = Number(match[1]);
3421
+ return Number.isFinite(value) ? value : null;
3422
+ }
3423
+
3424
+ function shouldUseStudioAltMarkdownPdfDocumentClass(options?: StudioPdfRenderOptions): boolean {
3425
+ const sizePt = getStudioRequestedPdfFontsizePt(options);
3426
+ return Boolean(sizePt && sizePt > 12);
3427
+ }
3428
+
3429
+ function getStudioDefaultPdfFootskip(options: StudioPdfRenderOptions | undefined, useAltClass: boolean): string | undefined {
3430
+ if (!useAltClass) return undefined;
3431
+ if (options?.geometry || options?.footskip) return undefined;
3432
+ return "12mm";
3433
+ }
3434
+
3435
+ function buildStudioPdfPandocVariableArgs(options?: StudioPdfRenderOptions, allowAltDocumentClass = false): string[] {
3436
+ const resolved = options ?? {};
3437
+ const args: string[] = [];
3438
+ const useAltClass = allowAltDocumentClass && shouldUseStudioAltMarkdownPdfDocumentClass(resolved);
3439
+ const defaultFootskip = getStudioDefaultPdfFootskip(resolved, useAltClass);
3440
+
3441
+ if (useAltClass) {
3442
+ args.push("-V", "documentclass=scrartcl");
3443
+ }
3444
+
3445
+ if (resolved.geometry) {
3446
+ args.push("-V", `geometry:${resolved.geometry}`);
3447
+ } else {
3448
+ args.push("-V", `geometry:margin=${resolved.margin ?? "2.2cm"}`);
3449
+ if (resolved.marginTop) args.push("-V", `geometry:top=${resolved.marginTop}`);
3450
+ if (resolved.marginRight) args.push("-V", `geometry:right=${resolved.marginRight}`);
3451
+ if (resolved.marginBottom) args.push("-V", `geometry:bottom=${resolved.marginBottom}`);
3452
+ if (resolved.marginLeft) args.push("-V", `geometry:left=${resolved.marginLeft}`);
3453
+ if (resolved.footskip) args.push("-V", `geometry:footskip=${resolved.footskip}`);
3454
+ else if (defaultFootskip) args.push("-V", `geometry:footskip=${defaultFootskip}`);
3455
+ }
3456
+
3457
+ args.push("-V", `fontsize=${resolved.fontsize ?? "11pt"}`);
3458
+ args.push("-V", `linestretch=${resolved.linestretch ?? "1.25"}`);
3459
+ if (resolved.mainfont) args.push("-V", `mainfont=${resolved.mainfont}`);
3460
+ if (resolved.papersize) args.push("-V", `papersize=${resolved.papersize}`);
3461
+ return args;
3462
+ }
3463
+
3464
+ function buildStudioLiteralTextPdfTexConfig(options?: StudioPdfRenderOptions): {
3465
+ className: string;
3466
+ classPaperOption: string;
3467
+ geometryOptions: string;
3468
+ fontCommands: string;
3469
+ lineStretch: string;
3470
+ fontSizeCommand: string;
3471
+ } {
3472
+ const resolved = options ?? {};
3473
+ const geometryParts: string[] = [];
3474
+ if (resolved.geometry) {
3475
+ geometryParts.push(sanitizeStudioPdfFreeformOption(resolved.geometry));
3476
+ } else {
3477
+ geometryParts.push(`margin=${resolved.margin ?? "2.2cm"}`);
3478
+ if (resolved.marginTop) geometryParts.push(`top=${resolved.marginTop}`);
3479
+ if (resolved.marginRight) geometryParts.push(`right=${resolved.marginRight}`);
3480
+ if (resolved.marginBottom) geometryParts.push(`bottom=${resolved.marginBottom}`);
3481
+ if (resolved.marginLeft) geometryParts.push(`left=${resolved.marginLeft}`);
3482
+ if (resolved.footskip) geometryParts.push(`footskip=${resolved.footskip}`);
3483
+ }
3484
+ const classPaperOption = resolved.papersize ? `,${resolved.papersize}paper` : "";
3485
+ const fontCommands = resolved.mainfont
3486
+ ? `\\usepackage{fontspec}\n\\setmainfont{${sanitizeStudioPdfFreeformOption(resolved.mainfont).replace(/[{}\\]/g, "")}}\n`
3487
+ : "";
3488
+ const lineStretch = sanitizeStudioPdfFreeformOption(resolved.linestretch || "1.25") || "1.25";
3489
+ const useAltClass = shouldUseStudioAltMarkdownPdfDocumentClass(resolved);
3490
+ const defaultFootskip = getStudioDefaultPdfFootskip(resolved, useAltClass);
3491
+ if (!resolved.geometry && !resolved.footskip && defaultFootskip) geometryParts.push(`footskip=${defaultFootskip}`);
3492
+ const fontSizeCommand = resolved.fontsize && !useAltClass
3493
+ ? `\\fontsize{${resolved.fontsize}}{${resolved.fontsize}}\\selectfont\n`
3494
+ : "";
3495
+ return {
3496
+ className: useAltClass ? "scrartcl" : "article",
3497
+ classPaperOption,
3498
+ geometryOptions: geometryParts.join(","),
3499
+ fontCommands,
3500
+ lineStretch,
3501
+ fontSizeCommand,
3502
+ };
3503
+ }
3504
+
2736
3505
  function prepareStudioPdfMarkdown(markdown: string, isLatex?: boolean, editorLanguage?: string): string {
2737
3506
  if (isLatex) return markdown;
2738
3507
  const effectiveEditorLanguage = inferStudioPdfLanguage(markdown, editorLanguage);
@@ -2743,7 +3512,8 @@ function prepareStudioPdfMarkdown(markdown: string, isLatex?: boolean, editorLan
2743
3512
  const annotationReadySource = !effectiveEditorLanguage || effectiveEditorLanguage === "markdown" || effectiveEditorLanguage === "latex"
2744
3513
  ? replaceStudioAnnotationMarkersForPdf(source)
2745
3514
  : source;
2746
- return normalizeObsidianImages(normalizeMathDelimiters(annotationReadySource));
3515
+ const commentStrippedSource = stripStudioMarkdownHtmlComments(annotationReadySource);
3516
+ return normalizeObsidianImages(normalizeMathDelimiters(commentStrippedSource));
2747
3517
  }
2748
3518
 
2749
3519
  function stripMathMlAnnotationTags(html: string): string {
@@ -2908,15 +3678,17 @@ async function preprocessStudioMermaidForPdf(markdown: string, workDir: string):
2908
3678
 
2909
3679
  async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolean, resourcePath?: string, sourcePath?: string): Promise<string> {
2910
3680
  const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
3681
+ const markdownWithoutHtmlComments = isLatex ? markdown : stripStudioMarkdownHtmlComments(markdown);
3682
+ const markdownWithPreviewPageBreaks = isLatex ? markdownWithoutHtmlComments : replaceStudioPreviewPageBreakCommands(markdownWithoutHtmlComments);
2911
3683
  const latexSubfigurePreviewTransform = isLatex
2912
- ? preprocessStudioLatexSubfiguresForPreview(markdown)
2913
- : { markdown, subfigureGroups: [] };
3684
+ ? preprocessStudioLatexSubfiguresForPreview(markdownWithPreviewPageBreaks)
3685
+ : { markdown: markdownWithPreviewPageBreaks, subfigureGroups: [] };
2914
3686
  const latexAlgorithmPreviewTransform = isLatex
2915
3687
  ? preprocessStudioLatexAlgorithmsForPreview(latexSubfigurePreviewTransform.markdown)
2916
- : { markdown, algorithmBlocks: [] };
3688
+ : { markdown: markdownWithPreviewPageBreaks, algorithmBlocks: [] };
2917
3689
  const sourceWithResolvedRefs = isLatex
2918
3690
  ? preprocessStudioLatexReferences(latexAlgorithmPreviewTransform.markdown, sourcePath, resourcePath)
2919
- : markdown;
3691
+ : markdownWithPreviewPageBreaks;
2920
3692
  const inputFormat = isLatex ? "latex" : "markdown+lists_without_preceding_blankline-blank_before_blockquote-blank_before_header+tex_math_dollars+tex_math_single_backslash+tex_math_double_backslash+autolink_bare_uris-raw_html";
2921
3693
  const bibliographyArgs = buildStudioPandocBibliographyArgs(markdown, isLatex, resourcePath);
2922
3694
  const args = ["-f", inputFormat, "-t", "html5", "--mathml", "--wrap=none", ...bibliographyArgs];
@@ -2928,7 +3700,7 @@ async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolea
2928
3700
  const normalizedMarkdown = isLatex ? sourceWithResolvedRefs : normalizeObsidianImages(normalizeMathDelimiters(sourceWithResolvedRefs));
2929
3701
  const pandocWorkingDir = resolveStudioPandocWorkingDir(resourcePath);
2930
3702
 
2931
- return await new Promise<string>((resolve, reject) => {
3703
+ let renderedHtml = await new Promise<string>((resolve, reject) => {
2932
3704
  const child = spawn(pandocCommand, args, { stdio: ["pipe", "pipe", "pipe"], cwd: pandocWorkingDir });
2933
3705
  const stdoutChunks: Buffer[] = [];
2934
3706
  const stderrChunks: Buffer[] = [];
@@ -2965,22 +3737,24 @@ async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolea
2965
3737
  child.once("close", (code) => {
2966
3738
  if (settled) return;
2967
3739
  if (code === 0) {
2968
- let renderedHtml = Buffer.concat(stdoutChunks).toString("utf-8");
3740
+ let html = Buffer.concat(stdoutChunks).toString("utf-8");
2969
3741
  // When --standalone was used, extract only the <body> content
2970
3742
  if (resourcePath) {
2971
- const bodyMatch = renderedHtml.match(/<body[^>]*>([\s\S]*)<\/body>/i);
2972
- if (bodyMatch) renderedHtml = bodyMatch[1];
3743
+ const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
3744
+ if (bodyMatch) html = bodyMatch[1];
2973
3745
  }
2974
3746
  if (isLatex) {
2975
- renderedHtml = decorateStudioLatexRenderedHtml(
2976
- renderedHtml,
3747
+ html = decorateStudioLatexRenderedHtml(
3748
+ html,
2977
3749
  sourcePath,
2978
3750
  resourcePath,
2979
3751
  latexSubfigurePreviewTransform.subfigureGroups,
2980
3752
  latexAlgorithmPreviewTransform.algorithmBlocks,
2981
3753
  );
3754
+ } else {
3755
+ html = decorateStudioPreviewPageBreakHtml(html);
2982
3756
  }
2983
- succeed(stripMathMlAnnotationTags(renderedHtml));
3757
+ succeed(stripMathMlAnnotationTags(html));
2984
3758
  return;
2985
3759
  }
2986
3760
  const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
@@ -2989,9 +3763,11 @@ async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolea
2989
3763
 
2990
3764
  child.stdin.end(normalizedMarkdown);
2991
3765
  });
3766
+
3767
+ return renderedHtml;
2992
3768
  }
2993
3769
 
2994
- async function renderStudioLiteralTextPdf(text: string, title = "Studio export"): Promise<Buffer> {
3770
+ async function renderStudioLiteralTextPdf(text: string, title = "Studio export", options?: StudioPdfRenderOptions): Promise<Buffer> {
2995
3771
  const pdfEngine = process.env.PANDOC_PDF_ENGINE?.trim() || "xelatex";
2996
3772
  const tempDir = join(tmpdir(), `pi-studio-text-pdf-${Date.now()}-${randomUUID()}`);
2997
3773
  const textPath = join(tempDir, "input.txt");
@@ -2999,13 +3775,15 @@ async function renderStudioLiteralTextPdf(text: string, title = "Studio export")
2999
3775
  const outputPath = join(tempDir, "input.pdf");
3000
3776
 
3001
3777
  const normalizedText = String(text ?? "").replace(/\r\n/g, "\n");
3002
- const texDocument = `\\documentclass[11pt]{article}
3003
- \\usepackage[margin=2.2cm]{geometry}
3004
- \\usepackage{fvextra}
3778
+ const literalPdfConfig = buildStudioLiteralTextPdfTexConfig(options);
3779
+ const texDocument = `\\documentclass[${options?.fontsize ?? "11pt"}${literalPdfConfig.classPaperOption}]{${literalPdfConfig.className}}
3780
+ \\usepackage[${literalPdfConfig.geometryOptions}]{geometry}
3781
+ ${literalPdfConfig.fontCommands}\\usepackage{fvextra}
3005
3782
  \\usepackage{xcolor}
3006
3783
  \\usepackage{upquote}
3007
3784
  \\begin{document}
3008
- \\section*{${title.replace(/[{}\\]/g, "").trim() || "Studio export"}}
3785
+ \\renewcommand{\\baselinestretch}{${literalPdfConfig.lineStretch}}\\selectfont
3786
+ ${literalPdfConfig.fontSizeCommand}\\section*{${title.replace(/[{}\\]/g, "").trim() || "Studio export"}}
3009
3787
  \\VerbatimInput[breaklines,breakanywhere,fontsize=\\small,frame=single,rulecolor=\\color{black!15},framesep=2mm]{input.txt}
3010
3788
  \\end{document}
3011
3789
  `;
@@ -3119,6 +3897,10 @@ async function renderStudioPdfFromGeneratedLatex(
3119
3897
  bibliographyArgs: string[],
3120
3898
  sourcePath: string | undefined,
3121
3899
  subfigureGroups: Array<{ placeholder: string; group: StudioLatexPdfSubfigureGroup }>,
3900
+ inputFormat = "latex",
3901
+ calloutBlocks: StudioPdfMarkdownCalloutBlock[] = [],
3902
+ alignedImageBlocks: StudioPdfAlignedImageBlock[] = [],
3903
+ pdfOptions?: StudioPdfRenderOptions,
3122
3904
  ): Promise<{ pdf: Buffer; warning?: string }> {
3123
3905
  const tempDir = join(tmpdir(), `pi-studio-pdf-${Date.now()}-${randomUUID()}`);
3124
3906
  const preamblePath = join(tempDir, "_pdf_preamble.tex");
@@ -3126,16 +3908,14 @@ async function renderStudioPdfFromGeneratedLatex(
3126
3908
  const outputPath = join(tempDir, "studio-export.pdf");
3127
3909
 
3128
3910
  await mkdir(tempDir, { recursive: true });
3129
- await writeFile(preamblePath, PDF_PREAMBLE, "utf-8");
3911
+ await writeFile(preamblePath, buildStudioPdfPreamble(pdfOptions), "utf-8");
3130
3912
 
3131
3913
  const pandocArgs = [
3132
- "-f", "latex",
3914
+ "-f", inputFormat,
3133
3915
  "-t", "latex",
3134
3916
  "-s",
3135
3917
  "-o", latexPath,
3136
- "-V", "geometry:margin=2.2cm",
3137
- "-V", "fontsize=11pt",
3138
- "-V", "linestretch=1.25",
3918
+ ...buildStudioPdfPandocVariableArgs(pdfOptions, inputFormat !== "latex"),
3139
3919
  "-V", "urlcolor=blue",
3140
3920
  "-V", "linkcolor=blue",
3141
3921
  "--include-in-header", preamblePath,
@@ -3188,7 +3968,9 @@ async function renderStudioPdfFromGeneratedLatex(
3188
3968
  const generatedLatex = await readFile(latexPath, "utf-8");
3189
3969
  const injectedLatex = injectStudioLatexPdfSubfigureBlocks(generatedLatex, subfigureGroups, sourcePath, resourcePath);
3190
3970
  const annotationReadyLatex = replaceStudioAnnotationMarkersInGeneratedLatex(injectedLatex);
3191
- const normalizedLatex = normalizeStudioGeneratedFigureCaptions(annotationReadyLatex);
3971
+ const calloutReadyLatex = replaceStudioPdfCalloutBlocksInGeneratedLatex(annotationReadyLatex, calloutBlocks);
3972
+ const alignedReadyLatex = replaceStudioPdfAlignedImageBlocksInGeneratedLatex(calloutReadyLatex, alignedImageBlocks);
3973
+ const normalizedLatex = normalizeStudioGeneratedFigureCaptions(alignedReadyLatex);
3192
3974
  await writeFile(latexPath, normalizedLatex, "utf-8");
3193
3975
 
3194
3976
  await new Promise<void>((resolve, reject) => {
@@ -3253,6 +4035,7 @@ async function renderStudioPdfWithPandoc(
3253
4035
  resourcePath?: string,
3254
4036
  editorPdfLanguage?: string,
3255
4037
  sourcePath?: string,
4038
+ pdfOptions?: StudioPdfRenderOptions,
3256
4039
  ): Promise<{ pdf: Buffer; warning?: string }> {
3257
4040
  const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
3258
4041
  const pdfEngine = process.env.PANDOC_PDF_ENGINE?.trim() || "xelatex";
@@ -3270,6 +4053,12 @@ async function renderStudioPdfWithPandoc(
3270
4053
  ? injectStudioLatexEquationTags(preprocessStudioLatexReferences(latexPdfSource, sourcePath, resourcePath), sourcePath, resourcePath)
3271
4054
  : markdown;
3272
4055
  const effectiveEditorLanguage = inferStudioPdfLanguage(sourceWithResolvedRefs, editorPdfLanguage);
4056
+ const pdfCalloutTransform = !isLatex && (!effectiveEditorLanguage || effectiveEditorLanguage === "markdown")
4057
+ ? preprocessStudioMarkdownCalloutsForPdf(sourceWithResolvedRefs)
4058
+ : { markdown: sourceWithResolvedRefs, blocks: [] as StudioPdfMarkdownCalloutBlock[] };
4059
+ const pdfAlignedImageTransform = !isLatex && (!effectiveEditorLanguage || effectiveEditorLanguage === "markdown")
4060
+ ? preprocessStudioMarkdownImageAlignmentForPdf(pdfCalloutTransform.markdown)
4061
+ : { markdown: pdfCalloutTransform.markdown, blocks: [] as StudioPdfAlignedImageBlock[] };
3273
4062
  const pandocWorkingDir = resolveStudioPandocWorkingDir(resourcePath);
3274
4063
  const bibliographyArgs = buildStudioPandocBibliographyArgs(markdown, isLatex, resourcePath);
3275
4064
 
@@ -3283,15 +4072,13 @@ async function renderStudioPdfWithPandoc(
3283
4072
  const outputPath = join(tempDir, "studio-export.pdf");
3284
4073
 
3285
4074
  await mkdir(tempDir, { recursive: true });
3286
- await writeFile(preamblePath, PDF_PREAMBLE, "utf-8");
4075
+ await writeFile(preamblePath, buildStudioPdfPreamble(pdfOptions), "utf-8");
3287
4076
 
3288
4077
  const args = [
3289
4078
  "-f", inputFormat,
3290
4079
  "-o", outputPath,
3291
4080
  `--pdf-engine=${pdfEngine}`,
3292
- "-V", "geometry:margin=2.2cm",
3293
- "-V", "fontsize=11pt",
3294
- "-V", "linestretch=1.25",
4081
+ ...buildStudioPdfPandocVariableArgs(pdfOptions, inputFormat !== "latex"),
3295
4082
  "-V", "urlcolor=blue",
3296
4083
  "-V", "linkcolor=blue",
3297
4084
  "--include-in-header", preamblePath,
@@ -3360,6 +4147,10 @@ async function renderStudioPdfWithPandoc(
3360
4147
  bibliographyArgs,
3361
4148
  sourcePath,
3362
4149
  latexSubfigurePdfTransform.groups,
4150
+ "latex",
4151
+ [],
4152
+ [],
4153
+ pdfOptions,
3363
4154
  );
3364
4155
  }
3365
4156
 
@@ -3372,7 +4163,7 @@ async function renderStudioPdfWithPandoc(
3372
4163
  const fenced = parseStudioSingleFencedCodeBlock(diffMarkdown);
3373
4164
  const diffText = fenced ? fenced.content : markdown;
3374
4165
  return {
3375
- pdf: await renderStudioLiteralTextPdf(diffText, "Git diff"),
4166
+ pdf: await renderStudioLiteralTextPdf(diffText, "Git diff", pdfOptions),
3376
4167
  warning: "Highlighted diff export failed, so Studio used a plain-text fallback without syntax colours.",
3377
4168
  };
3378
4169
  }
@@ -3381,27 +4172,44 @@ async function renderStudioPdfWithPandoc(
3381
4172
  const inputFormat = isLatex
3382
4173
  ? "latex"
3383
4174
  : "markdown+lists_without_preceding_blankline-blank_before_blockquote-blank_before_header+tex_math_dollars+tex_math_single_backslash+tex_math_double_backslash+autolink_bare_uris+superscript+subscript-raw_html";
3384
- const normalizedMarkdown = prepareStudioPdfMarkdown(sourceWithResolvedRefs, isLatex, effectiveEditorLanguage);
4175
+ const normalizedMarkdown = prepareStudioPdfMarkdown(pdfAlignedImageTransform.markdown, isLatex, effectiveEditorLanguage);
3385
4176
 
3386
4177
  const tempDir = join(tmpdir(), `pi-studio-pdf-${Date.now()}-${randomUUID()}`);
3387
4178
  const preamblePath = join(tempDir, "_pdf_preamble.tex");
3388
4179
  const outputPath = join(tempDir, "studio-export.pdf");
3389
4180
 
3390
4181
  await mkdir(tempDir, { recursive: true });
3391
- await writeFile(preamblePath, PDF_PREAMBLE, "utf-8");
4182
+ await writeFile(preamblePath, buildStudioPdfPreamble(pdfOptions), "utf-8");
3392
4183
 
3393
4184
  const mermaidPrepared: StudioMermaidPdfPreprocessResult = isLatex
3394
4185
  ? { markdown: normalizedMarkdown, found: 0, replaced: 0, failed: 0, missingCli: false }
3395
4186
  : await preprocessStudioMermaidForPdf(normalizedMarkdown, tempDir);
3396
4187
  const markdownForPdf = mermaidPrepared.markdown;
3397
4188
 
4189
+ if (!isLatex && (pdfCalloutTransform.blocks.length > 0 || pdfAlignedImageTransform.blocks.length > 0)) {
4190
+ const rendered = await renderStudioPdfFromGeneratedLatex(
4191
+ markdownForPdf,
4192
+ pandocCommand,
4193
+ pdfEngine,
4194
+ resourcePath,
4195
+ pandocWorkingDir,
4196
+ bibliographyArgs,
4197
+ sourcePath,
4198
+ [],
4199
+ inputFormat,
4200
+ pdfCalloutTransform.blocks,
4201
+ pdfAlignedImageTransform.blocks,
4202
+ pdfOptions,
4203
+ );
4204
+ await rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
4205
+ return { pdf: rendered.pdf, warning: mermaidPrepared.warning ?? rendered.warning };
4206
+ }
4207
+
3398
4208
  const args = [
3399
4209
  "-f", inputFormat,
3400
4210
  "-o", outputPath,
3401
4211
  `--pdf-engine=${pdfEngine}`,
3402
- "-V", "geometry:margin=2.2cm",
3403
- "-V", "fontsize=11pt",
3404
- "-V", "linestretch=1.25",
4212
+ ...buildStudioPdfPandocVariableArgs(pdfOptions, !isLatex),
3405
4213
  "-V", "urlcolor=blue",
3406
4214
  "-V", "linkcolor=blue",
3407
4215
  "--include-in-header", preamblePath,
@@ -4457,7 +5265,7 @@ ${cssVarsBlock}
4457
5265
  <div class="controls">
4458
5266
  <button id="saveAsBtn" type="button" title="Save editor content to a new file path.">Save editor as…</button>
4459
5267
  <button id="saveOverBtn" type="button" title="Overwrite current file with editor content." disabled>Save editor</button>
4460
- <label class="file-label" title="Load a local file into editor text.">Load file content<input id="fileInput" type="file" accept=".md,.markdown,.mdx,.js,.mjs,.cjs,.jsx,.ts,.mts,.cts,.tsx,.py,.pyw,.sh,.bash,.zsh,.json,.jsonc,.json5,.rs,.c,.h,.cpp,.cxx,.cc,.hpp,.hxx,.jl,.f90,.f95,.f03,.f,.for,.r,.R,.m,.tex,.latex,.diff,.patch,.java,.go,.rb,.swift,.html,.htm,.css,.xml,.yaml,.yml,.toml,.lua,.txt,.rst,.adoc" /></label>
5268
+ <label class="file-label" title="Load a local file into editor text.">Load file content<input id="fileInput" type="file" accept=".md,.markdown,.mdx,.qmd,.js,.mjs,.cjs,.jsx,.ts,.mts,.cts,.tsx,.py,.pyw,.sh,.bash,.zsh,.json,.jsonc,.json5,.rs,.c,.h,.cpp,.cxx,.cc,.hpp,.hxx,.jl,.f90,.f95,.f03,.f,.for,.r,.R,.m,.tex,.latex,.diff,.patch,.java,.go,.rb,.swift,.html,.htm,.css,.xml,.yaml,.yml,.toml,.lua,.txt,.rst,.adoc" /></label>
4461
5269
  <button id="loadGitDiffBtn" type="button" title="Load the current git diff from the Studio context into the editor.">Load git diff</button>
4462
5270
  <button id="getEditorBtn" type="button" title="Load the current terminal editor draft into Studio.">Load from pi editor</button>
4463
5271
  </div>
@@ -6868,23 +7676,39 @@ export default function (pi: ExtensionAPI) {
6868
7676
  const trimmed = args.trim();
6869
7677
  if (!trimmed || trimmed === "help" || trimmed === "--help" || trimmed === "-h") {
6870
7678
  ctx.ui.notify(
6871
- "Usage: /studio-pdf <path>\n"
6872
- + " Export a local Markdown/LaTeX file to <name>.studio.pdf using the Studio PDF pipeline.",
7679
+ "Usage: /studio-pdf <path> [options]\n"
7680
+ + " Export a local Markdown/LaTeX file to <name>.studio.pdf using the Studio PDF pipeline.\n"
7681
+ + "Options:\n"
7682
+ + " --fontsize <value> e.g. 12pt\n"
7683
+ + " --section-size <value> e.g. 24pt\n"
7684
+ + " --subsection-size <value>\n"
7685
+ + " --subsubsection-size <value>\n"
7686
+ + " --section-space-before <value>\n"
7687
+ + " --section-space-after <value>\n"
7688
+ + " --subsection-space-before <value>\n"
7689
+ + " --subsection-space-after <value>\n"
7690
+ + " --margin <value> e.g. 25mm\n"
7691
+ + " --margin-top <value>\n"
7692
+ + " --margin-right <value>\n"
7693
+ + " --margin-bottom <value>\n"
7694
+ + " --margin-left <value>\n"
7695
+ + " --footskip <value> e.g. 12mm\n"
7696
+ + " --linestretch <value> e.g. 1.2\n"
7697
+ + " --mainfont <name> e.g. \"TeX Gyre Pagella\"\n"
7698
+ + " --papersize <name> e.g. a4\n"
7699
+ + " --geometry <spec> e.g. \"top=30mm,left=25mm,right=25mm,bottom=30mm,footskip=12mm\"\n"
7700
+ + " Note: use either --geometry or the --margin/--margin-*/--footskip flags.",
6873
7701
  "info",
6874
7702
  );
6875
7703
  return;
6876
7704
  }
6877
7705
 
6878
- if (trimmed.startsWith("-")) {
6879
- ctx.ui.notify(`Unknown flag: ${trimmed}. Use /studio-pdf --help`, "error");
6880
- return;
6881
- }
6882
-
6883
- const pathArg = parsePathArgument(trimmed);
6884
- if (!pathArg) {
6885
- ctx.ui.notify("Invalid file path argument.", "error");
7706
+ const parsedArgs = parseStudioPdfCommandArgs(trimmed);
7707
+ if ("error" in parsedArgs) {
7708
+ ctx.ui.notify(parsedArgs.error, "error");
6886
7709
  return;
6887
7710
  }
7711
+ const { pathArg, options: pdfOptions } = parsedArgs;
6888
7712
 
6889
7713
  const file = readStudioFile(pathArg, ctx.cwd);
6890
7714
  if (file.ok === false) {
@@ -6916,6 +7740,7 @@ export default function (pi: ExtensionAPI) {
6916
7740
  resourcePath,
6917
7741
  editorPdfLanguage,
6918
7742
  file.resolvedPath,
7743
+ pdfOptions,
6919
7744
  );
6920
7745
  await writeFile(outputPath, pdf);
6921
7746