pi-studio 0.5.23 → 0.5.24

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.
Files changed (3) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/index.ts +376 -3
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -4,6 +4,11 @@ All notable changes to `pi-studio` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.5.24] — 2026-03-20
8
+
9
+ ### Fixed
10
+ - LaTeX PDF export now intercepts grouped `subfigure` blocks before Pandoc and reinjects them into the generated LaTeX as grouped minipage-based figure pages with aux-derived `Figure n` / `(a)` / `(b)` labels, preserving grouped subfigure layout more faithfully in exported PDFs.
11
+
7
12
  ## [0.5.23] — 2026-03-20
8
13
 
9
14
  ### Fixed
package/index.ts CHANGED
@@ -1123,6 +1123,25 @@ interface StudioLatexSubfigurePreviewTransformResult {
1123
1123
  subfigureGroups: StudioLatexSubfigurePreviewGroup[];
1124
1124
  }
1125
1125
 
1126
+ interface StudioLatexPdfSubfigureItem {
1127
+ imagePath: string;
1128
+ imageOptions: string | null;
1129
+ widthSpec: string | null;
1130
+ caption: string | null;
1131
+ label: string | null;
1132
+ }
1133
+
1134
+ interface StudioLatexPdfSubfigureGroup {
1135
+ caption: string | null;
1136
+ label: string | null;
1137
+ items: StudioLatexPdfSubfigureItem[];
1138
+ }
1139
+
1140
+ interface StudioLatexPdfSubfigureTransformResult {
1141
+ markdown: string;
1142
+ groups: Array<{ placeholder: string; group: StudioLatexPdfSubfigureGroup }>;
1143
+ }
1144
+
1126
1145
  interface StudioLatexAlgorithmPreviewLine {
1127
1146
  indent: number;
1128
1147
  content: string;
@@ -1197,6 +1216,17 @@ function readStudioLatexEnvironmentBlock(
1197
1216
  return null;
1198
1217
  }
1199
1218
 
1219
+ function extractStudioLatexFirstCommandArgument(input: string, commandName: string, allowStar = false): string | null {
1220
+ const escapedCommand = commandName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1221
+ const pattern = new RegExp(`\\\\${escapedCommand}${allowStar ? "\\*?" : ""}(?:\\s*\\[[^\\]]*\\])?\\s*\\{`, "g");
1222
+ const match = pattern.exec(input);
1223
+ if (!match) return null;
1224
+ const openBraceIndex = pattern.lastIndex - 1;
1225
+ const closeBraceIndex = findStudioLatexMatchingBrace(input, openBraceIndex);
1226
+ if (closeBraceIndex < 0) return null;
1227
+ return input.slice(openBraceIndex + 1, closeBraceIndex).trim() || null;
1228
+ }
1229
+
1200
1230
  function extractStudioLatexLastCommandArgument(input: string, commandName: string, allowStar = false): string | null {
1201
1231
  const escapedCommand = commandName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1202
1232
  const pattern = new RegExp(`\\\\${escapedCommand}${allowStar ? "\\*?" : ""}(?:\\s*\\[[^\\]]*\\])?\\s*\\{`, "g");
@@ -1233,10 +1263,98 @@ function convertStudioLatexLengthToCss(length: string): string | null {
1233
1263
  return null;
1234
1264
  }
1235
1265
 
1236
- function extractStudioLatexSubfigureWidth(blockText: string): string | null {
1266
+ function extractStudioLatexSubfigureWidthSpec(blockText: string): string | null {
1237
1267
  const match = blockText.match(/^\\begin\s*\{subfigure\*?\}(?:\s*\[[^\]]*\])?\s*\{([^}]*)\}/);
1268
+ return match?.[1]?.trim() || null;
1269
+ }
1270
+
1271
+ function extractStudioLatexSubfigureWidth(blockText: string): string | null {
1272
+ const widthSpec = extractStudioLatexSubfigureWidthSpec(blockText);
1273
+ if (!widthSpec) return null;
1274
+ return convertStudioLatexLengthToCss(widthSpec);
1275
+ }
1276
+
1277
+ function extractStudioLatexIncludeGraphics(input: string): { path: string; options: string | null } | null {
1278
+ const pattern = /\\includegraphics\*?(?:\s*\[[^\]]*\])?\s*\{/g;
1279
+ const match = pattern.exec(input);
1238
1280
  if (!match) return null;
1239
- return convertStudioLatexLengthToCss(match[1] ?? "");
1281
+ const openBraceIndex = pattern.lastIndex - 1;
1282
+ const closeBraceIndex = findStudioLatexMatchingBrace(input, openBraceIndex);
1283
+ if (closeBraceIndex < 0) return null;
1284
+ const optionMatch = match[0].match(/\[([^\]]*)\]/);
1285
+ return {
1286
+ path: input.slice(openBraceIndex + 1, closeBraceIndex).trim(),
1287
+ options: optionMatch?.[1]?.trim() || null,
1288
+ };
1289
+ }
1290
+
1291
+ function collectStudioLatexPdfSubfigureGroups(markdown: string): Array<{ start: number; end: number; group: StudioLatexPdfSubfigureGroup }> {
1292
+ const groups: Array<{ start: number; end: number; group: StudioLatexPdfSubfigureGroup }> = [];
1293
+ const figurePattern = /\\begin\s*\{(figure\*?)\}/g;
1294
+
1295
+ for (;;) {
1296
+ const figureMatch = figurePattern.exec(markdown);
1297
+ if (!figureMatch) break;
1298
+ const envName = figureMatch[1] ?? "figure";
1299
+ const block = readStudioLatexEnvironmentBlock(markdown, figureMatch.index, envName);
1300
+ if (!block) continue;
1301
+ const inner = block.innerText;
1302
+ const subfigurePattern = /\\begin\s*\{(subfigure\*?)\}/g;
1303
+ const subfigureBlocks: Array<{ start: number; end: number; fullText: string }> = [];
1304
+ for (;;) {
1305
+ const subfigureMatch = subfigurePattern.exec(inner);
1306
+ if (!subfigureMatch) break;
1307
+ const subfigureEnvName = subfigureMatch[1] ?? "subfigure";
1308
+ const subfigureBlock = readStudioLatexEnvironmentBlock(inner, subfigureMatch.index, subfigureEnvName);
1309
+ if (!subfigureBlock) continue;
1310
+ subfigureBlocks.push({
1311
+ start: subfigureMatch.index,
1312
+ end: subfigureBlock.endIndex,
1313
+ fullText: subfigureBlock.fullText.trim(),
1314
+ });
1315
+ subfigurePattern.lastIndex = subfigureBlock.endIndex;
1316
+ }
1317
+ if (subfigureBlocks.length === 0) continue;
1318
+
1319
+ let outerResidual = "";
1320
+ let residualCursor = 0;
1321
+ for (const subfigureBlock of subfigureBlocks) {
1322
+ outerResidual += inner.slice(residualCursor, subfigureBlock.start);
1323
+ residualCursor = subfigureBlock.end;
1324
+ }
1325
+ outerResidual += inner.slice(residualCursor);
1326
+
1327
+ const items: StudioLatexPdfSubfigureItem[] = [];
1328
+ let allHaveImages = true;
1329
+ for (const subfigureBlock of subfigureBlocks) {
1330
+ const image = extractStudioLatexIncludeGraphics(subfigureBlock.fullText);
1331
+ if (!image?.path) {
1332
+ allHaveImages = false;
1333
+ break;
1334
+ }
1335
+ items.push({
1336
+ imagePath: image.path,
1337
+ imageOptions: image.options,
1338
+ widthSpec: extractStudioLatexSubfigureWidthSpec(subfigureBlock.fullText),
1339
+ caption: extractStudioLatexFirstCommandArgument(subfigureBlock.fullText, "caption", true),
1340
+ label: extractStudioLatexLastCommandArgument(subfigureBlock.fullText, "label"),
1341
+ });
1342
+ }
1343
+ if (!allHaveImages || items.length === 0) continue;
1344
+
1345
+ groups.push({
1346
+ start: figureMatch.index,
1347
+ end: block.endIndex,
1348
+ group: {
1349
+ caption: extractStudioLatexLastCommandArgument(outerResidual, "caption", true),
1350
+ label: extractStudioLatexLastCommandArgument(outerResidual, "label"),
1351
+ items,
1352
+ },
1353
+ });
1354
+ figurePattern.lastIndex = block.endIndex;
1355
+ }
1356
+
1357
+ return groups;
1240
1358
  }
1241
1359
 
1242
1360
  function preprocessStudioLatexSubfiguresForPreview(markdown: string): StudioLatexSubfigurePreviewTransformResult {
@@ -1691,6 +1809,106 @@ function formatStudioLatexMainFigureCaptionLabel(label: string | null, labels: M
1691
1809
  return `Figure ${entry.number}`;
1692
1810
  }
1693
1811
 
1812
+ function estimateStudioLatexRelativeWidth(widthSpec: string | null | undefined): number | null {
1813
+ const normalized = String(widthSpec ?? "").replace(/\s+/g, "");
1814
+ if (!normalized) return null;
1815
+ const fractionalMatch = normalized.match(/^([0-9]*\.?[0-9]+)\\(?:textwidth|linewidth|columnwidth|hsize)$/);
1816
+ if (!fractionalMatch) return null;
1817
+ const value = Number.parseFloat(fractionalMatch[1] ?? "");
1818
+ return Number.isFinite(value) && value > 0 ? value : null;
1819
+ }
1820
+
1821
+ function buildStudioLatexInjectedPdfSubfigureBlock(
1822
+ group: StudioLatexPdfSubfigureGroup,
1823
+ labels: Map<string, { number: string; kind: string }>,
1824
+ ): string {
1825
+ const figureLabel = formatStudioLatexMainFigureCaptionLabel(group.label, labels);
1826
+ const figureCaption = figureLabel
1827
+ ? (group.caption ? `\\textbf{${figureLabel}} ${group.caption}` : `\\textbf{${figureLabel}}`)
1828
+ : (group.caption ? group.caption : "");
1829
+
1830
+ const minipageBlocks = group.items.map((item) => {
1831
+ const widthSpec = item.widthSpec || "0.48\\textwidth";
1832
+ const imageCommand = `\\includegraphics${item.imageOptions ? `[${item.imageOptions}]` : "[width=\\linewidth]"}{${item.imagePath}}`;
1833
+ const subfigureLabel = formatStudioLatexSubfigureCaptionLabel(item.label, labels);
1834
+ const captionLine = subfigureLabel
1835
+ ? (item.caption ? `\\textbf{${subfigureLabel}} ${item.caption}` : `\\textbf{${subfigureLabel}}`)
1836
+ : (item.caption ? item.caption : "");
1837
+ const parts = [
1838
+ `\\begin{minipage}[t]{${widthSpec}}`,
1839
+ "\\centering",
1840
+ imageCommand,
1841
+ captionLine ? `\\par\\smallskip ${captionLine}` : "",
1842
+ "\\end{minipage}",
1843
+ ].filter(Boolean);
1844
+ return {
1845
+ latex: parts.join("\n"),
1846
+ relativeWidth: estimateStudioLatexRelativeWidth(widthSpec) ?? 0.48,
1847
+ };
1848
+ });
1849
+
1850
+ const rows: string[] = [];
1851
+ let currentRow: string[] = [];
1852
+ let currentWidth = 0;
1853
+ for (const block of minipageBlocks) {
1854
+ if (currentRow.length > 0 && currentWidth + block.relativeWidth > 1.02) {
1855
+ rows.push(currentRow.join("\n\\hfill\n"));
1856
+ currentRow = [];
1857
+ currentWidth = 0;
1858
+ }
1859
+ currentRow.push(block.latex);
1860
+ currentWidth += block.relativeWidth;
1861
+ }
1862
+ if (currentRow.length > 0) rows.push(currentRow.join("\n\\hfill\n"));
1863
+
1864
+ const bodyParts = [
1865
+ "\\clearpage",
1866
+ "\\begin{figure}[p]",
1867
+ "\\centering",
1868
+ rows.join("\n\\par\\medskip\n"),
1869
+ figureCaption ? `\\par\\bigskip ${figureCaption}` : "",
1870
+ "\\end{figure}",
1871
+ "\\clearpage",
1872
+ ].filter(Boolean);
1873
+ return `\n${bodyParts.join("\n")}\n`;
1874
+ }
1875
+
1876
+ function preprocessStudioLatexSubfiguresForPdf(markdown: string): StudioLatexPdfSubfigureTransformResult {
1877
+ const groups = collectStudioLatexPdfSubfigureGroups(markdown);
1878
+ if (groups.length === 0) return { markdown, groups: [] };
1879
+ let transformed = "";
1880
+ let cursor = 0;
1881
+ const placeholderGroups: Array<{ placeholder: string; group: StudioLatexPdfSubfigureGroup }> = [];
1882
+
1883
+ for (const [index, entry] of groups.entries()) {
1884
+ const placeholder = `PISTUDIOSUBFIGUREPDFPLACEHOLDER${index + 1}`;
1885
+ placeholderGroups.push({ placeholder, group: entry.group });
1886
+ transformed += markdown.slice(cursor, entry.start);
1887
+ transformed += `\n\n${placeholder}\n\n`;
1888
+ cursor = entry.end;
1889
+ }
1890
+ transformed += markdown.slice(cursor);
1891
+ return {
1892
+ markdown: transformed,
1893
+ groups: placeholderGroups,
1894
+ };
1895
+ }
1896
+
1897
+ function injectStudioLatexPdfSubfigureBlocks(
1898
+ latex: string,
1899
+ groups: Array<{ placeholder: string; group: StudioLatexPdfSubfigureGroup }>,
1900
+ sourcePath: string | undefined,
1901
+ baseDir: string | undefined,
1902
+ ): string {
1903
+ if (groups.length === 0) return latex;
1904
+ const labels = readStudioLatexAuxLabels(sourcePath, baseDir);
1905
+ let transformed = String(latex ?? "");
1906
+ for (const entry of groups) {
1907
+ transformed = transformed.replace(entry.placeholder, buildStudioLatexInjectedPdfSubfigureBlock(entry.group, labels));
1908
+ }
1909
+ return transformed;
1910
+ }
1911
+
1694
1912
  function formatStudioLatexMainAlgorithmCaptionLabel(label: string | null, labels: Map<string, { number: string; kind: string }>): string | null {
1695
1913
  const normalizedLabel = String(label ?? "").trim();
1696
1914
  if (!normalizedLabel) return null;
@@ -2709,6 +2927,141 @@ async function renderStudioLiteralTextPdf(text: string, title = "Studio export")
2709
2927
  }
2710
2928
  }
2711
2929
 
2930
+ async function renderStudioPdfFromGeneratedLatex(
2931
+ markdown: string,
2932
+ pandocCommand: string,
2933
+ pdfEngine: string,
2934
+ resourcePath: string | undefined,
2935
+ pandocWorkingDir: string | undefined,
2936
+ bibliographyArgs: string[],
2937
+ sourcePath: string | undefined,
2938
+ subfigureGroups: Array<{ placeholder: string; group: StudioLatexPdfSubfigureGroup }>,
2939
+ ): Promise<{ pdf: Buffer; warning?: string }> {
2940
+ const tempDir = join(tmpdir(), `pi-studio-pdf-${Date.now()}-${randomUUID()}`);
2941
+ const preamblePath = join(tempDir, "_pdf_preamble.tex");
2942
+ const latexPath = join(tempDir, "studio-export.tex");
2943
+ const outputPath = join(tempDir, "studio-export.pdf");
2944
+
2945
+ await mkdir(tempDir, { recursive: true });
2946
+ await writeFile(preamblePath, PDF_PREAMBLE, "utf-8");
2947
+
2948
+ const pandocArgs = [
2949
+ "-f", "latex",
2950
+ "-t", "latex",
2951
+ "-s",
2952
+ "-o", latexPath,
2953
+ "-V", "geometry:margin=2.2cm",
2954
+ "-V", "fontsize=11pt",
2955
+ "-V", "linestretch=1.25",
2956
+ "-V", "urlcolor=blue",
2957
+ "-V", "linkcolor=blue",
2958
+ "--include-in-header", preamblePath,
2959
+ ...bibliographyArgs,
2960
+ ];
2961
+ if (resourcePath) pandocArgs.push(`--resource-path=${resourcePath}`);
2962
+
2963
+ try {
2964
+ await new Promise<void>((resolve, reject) => {
2965
+ const child = spawn(pandocCommand, pandocArgs, { stdio: ["pipe", "pipe", "pipe"], cwd: pandocWorkingDir });
2966
+ const stderrChunks: Buffer[] = [];
2967
+ let settled = false;
2968
+
2969
+ const fail = (error: Error) => {
2970
+ if (settled) return;
2971
+ settled = true;
2972
+ reject(error);
2973
+ };
2974
+
2975
+ child.stderr.on("data", (chunk: Buffer | string) => {
2976
+ stderrChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
2977
+ });
2978
+
2979
+ child.once("error", (error) => {
2980
+ const errno = error as NodeJS.ErrnoException;
2981
+ if (errno.code === "ENOENT") {
2982
+ const commandHint = pandocCommand === "pandoc"
2983
+ ? "pandoc was not found. Install pandoc or set PANDOC_PATH to the pandoc binary."
2984
+ : `${pandocCommand} was not found. Check PANDOC_PATH.`;
2985
+ fail(new Error(commandHint));
2986
+ return;
2987
+ }
2988
+ fail(error);
2989
+ });
2990
+
2991
+ child.once("close", (code) => {
2992
+ if (settled) return;
2993
+ if (code === 0) {
2994
+ settled = true;
2995
+ resolve();
2996
+ return;
2997
+ }
2998
+ const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
2999
+ fail(new Error(`pandoc LaTeX generation failed with exit code ${code}${stderr ? `: ${stderr}` : ""}`));
3000
+ });
3001
+
3002
+ child.stdin.end(markdown);
3003
+ });
3004
+
3005
+ const generatedLatex = await readFile(latexPath, "utf-8");
3006
+ const injectedLatex = injectStudioLatexPdfSubfigureBlocks(generatedLatex, subfigureGroups, sourcePath, resourcePath);
3007
+ await writeFile(latexPath, injectedLatex, "utf-8");
3008
+
3009
+ await new Promise<void>((resolve, reject) => {
3010
+ const child = spawn(pdfEngine, [
3011
+ "-interaction=nonstopmode",
3012
+ "-halt-on-error",
3013
+ `-output-directory=${tempDir}`,
3014
+ latexPath,
3015
+ ], { stdio: ["ignore", "pipe", "pipe"], cwd: pandocWorkingDir });
3016
+ const stdoutChunks: Buffer[] = [];
3017
+ const stderrChunks: Buffer[] = [];
3018
+ let settled = false;
3019
+
3020
+ const fail = (error: Error) => {
3021
+ if (settled) return;
3022
+ settled = true;
3023
+ reject(error);
3024
+ };
3025
+
3026
+ child.stdout.on("data", (chunk: Buffer | string) => {
3027
+ stdoutChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
3028
+ });
3029
+ child.stderr.on("data", (chunk: Buffer | string) => {
3030
+ stderrChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
3031
+ });
3032
+
3033
+ child.once("error", (error) => {
3034
+ const errno = error as NodeJS.ErrnoException;
3035
+ if (errno.code === "ENOENT") {
3036
+ fail(new Error(
3037
+ `${pdfEngine} was not found. Install TeX Live (e.g. brew install --cask mactex) or set PANDOC_PDF_ENGINE.`,
3038
+ ));
3039
+ return;
3040
+ }
3041
+ fail(error);
3042
+ });
3043
+
3044
+ child.once("close", (code) => {
3045
+ if (settled) return;
3046
+ if (code === 0) {
3047
+ settled = true;
3048
+ resolve();
3049
+ return;
3050
+ }
3051
+ const stdout = Buffer.concat(stdoutChunks).toString("utf-8");
3052
+ const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
3053
+ const errorMatch = stdout.match(/^! .+$/m);
3054
+ const hint = errorMatch ? `: ${errorMatch[0]}` : (stderr ? `: ${stderr}` : "");
3055
+ fail(new Error(`${pdfEngine} PDF export failed with exit code ${code}${hint}`));
3056
+ });
3057
+ });
3058
+
3059
+ return { pdf: await readFile(outputPath) };
3060
+ } finally {
3061
+ await rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
3062
+ }
3063
+ }
3064
+
2712
3065
  async function renderStudioPdfWithPandoc(
2713
3066
  markdown: string,
2714
3067
  isLatex?: boolean,
@@ -2718,8 +3071,15 @@ async function renderStudioPdfWithPandoc(
2718
3071
  ): Promise<{ pdf: Buffer; warning?: string }> {
2719
3072
  const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
2720
3073
  const pdfEngine = process.env.PANDOC_PDF_ENGINE?.trim() || "xelatex";
3074
+ const latexSubfigurePdfTransform = isLatex
3075
+ ? preprocessStudioLatexSubfiguresForPdf(markdown)
3076
+ : { markdown, groups: [] };
2721
3077
  const latexPdfSource = isLatex
2722
- ? preprocessStudioLatexAlgorithmsForPdf(markdown, sourcePath, resourcePath)
3078
+ ? preprocessStudioLatexAlgorithmsForPdf(
3079
+ latexSubfigurePdfTransform.markdown,
3080
+ sourcePath,
3081
+ resourcePath,
3082
+ )
2723
3083
  : markdown;
2724
3084
  const sourceWithResolvedRefs = isLatex
2725
3085
  ? injectStudioLatexEquationTags(preprocessStudioLatexReferences(latexPdfSource, sourcePath, resourcePath), sourcePath, resourcePath)
@@ -2805,6 +3165,19 @@ async function renderStudioPdfWithPandoc(
2805
3165
  }
2806
3166
  };
2807
3167
 
3168
+ if (isLatex && latexSubfigurePdfTransform.groups.length > 0) {
3169
+ return await renderStudioPdfFromGeneratedLatex(
3170
+ sourceWithResolvedRefs,
3171
+ pandocCommand,
3172
+ pdfEngine,
3173
+ resourcePath,
3174
+ pandocWorkingDir,
3175
+ bibliographyArgs,
3176
+ sourcePath,
3177
+ latexSubfigurePdfTransform.groups,
3178
+ );
3179
+ }
3180
+
2808
3181
  if (!isLatex && effectiveEditorLanguage === "diff") {
2809
3182
  const inputFormat = "markdown+lists_without_preceding_blankline+tex_math_dollars+autolink_bare_uris+superscript+subscript-raw_html";
2810
3183
  const diffMarkdown = prepareStudioPdfMarkdown(markdown, false, effectiveEditorLanguage);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.5.23",
3
+ "version": "0.5.24",
4
4
  "description": "Browser GUI for structured critique workflows in pi",
5
5
  "type": "module",
6
6
  "license": "MIT",