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/CHANGELOG.md CHANGED
@@ -4,6 +4,19 @@ All notable changes to `pi-studio` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.5.32] — 2026-03-25
8
+
9
+ ### Added
10
+ - `/studio-pdf <path>` now accepts a curated set of advanced layout controls for file-based exports, including font size, margins, line stretch, main font, paper size, geometry, heading sizes, heading spacing, and footer skip.
11
+
12
+ ### Changed
13
+ - Large-font Markdown/QMD Studio PDF exports now switch to a more suitable LaTeX document class and use a safer default footer skip unless you explicitly override the geometry.
14
+ - PDF callout blocks now render more compactly, reducing extra vertical whitespace around note/tip/warning content.
15
+
16
+ ### Fixed
17
+ - Studio preview/PDF preparation now treats `.qmd` files like Markdown, strips HTML comments more narrowly, shows standalone LaTeX page-break commands as subtle preview dividers, and supports common Quarto-style callout and `fig-align` patterns in preview/PDF output.
18
+ - Markdown/QMD preview now renders embedded local PDF figures more reliably via `pdf.js`, avoiding grey-box browser embed failures in the Studio preview surface.
19
+
7
20
  ## [0.5.31] — 2026-03-24
8
21
 
9
22
  ### Fixed
package/README.md CHANGED
@@ -43,7 +43,7 @@ Extension for [pi](https://pi.dev) that opens a local two-pane browser workspace
43
43
  | `/studio --stop` | Stop studio server |
44
44
  | `/studio --help` | Show help |
45
45
  | `/studio-current <path>` | Load a file into currently open Studio tab(s) without opening a new browser window |
46
- | `/studio-pdf <path>` | Export a local file to `<name>.studio.pdf` via the Studio PDF pipeline |
46
+ | `/studio-pdf <path> [options]` | Export a local file to `<name>.studio.pdf` via the Studio PDF pipeline, with optional layout controls |
47
47
 
48
48
  ## Install
49
49
 
@@ -188,7 +188,7 @@
188
188
  const EDITOR_LANGUAGE_STORAGE_KEY = "piStudio.editorLanguage";
189
189
  // Single source of truth: language -> file extensions (and display label)
190
190
  var LANG_EXT_MAP = {
191
- markdown: { label: "Markdown", exts: ["md", "markdown", "mdx"] },
191
+ markdown: { label: "Markdown", exts: ["md", "markdown", "mdx", "qmd"] },
192
192
  javascript: { label: "JavaScript", exts: ["js", "mjs", "cjs", "jsx"] },
193
193
  typescript: { label: "TypeScript", exts: ["ts", "mts", "cts", "tsx"] },
194
194
  python: { label: "Python", exts: ["py", "pyw"] },
@@ -245,6 +245,8 @@
245
245
  const EMPTY_OVERLAY_LINE = "\u200b";
246
246
  const MERMAID_CDN_URL = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs";
247
247
  const MATHJAX_CDN_URL = "https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js";
248
+ const PDFJS_CDN_URL = "https://cdn.jsdelivr.net/npm/pdfjs-dist@4.10.38/legacy/build/pdf.min.mjs";
249
+ const PDFJS_WORKER_CDN_URL = "https://cdn.jsdelivr.net/npm/pdfjs-dist@4.10.38/legacy/build/pdf.worker.min.mjs";
248
250
  const BOOT = (typeof window.__PI_STUDIO_BOOT__ === "object" && window.__PI_STUDIO_BOOT__)
249
251
  ? window.__PI_STUDIO_BOOT__
250
252
  : {};
@@ -255,9 +257,12 @@
255
257
  const MERMAID_RENDER_FAIL_MESSAGE = "Mermaid render failed. Showing diagram source text.";
256
258
  const MATHJAX_UNAVAILABLE_MESSAGE = "Math fallback unavailable. Some unsupported equations may remain as raw TeX.";
257
259
  const MATHJAX_RENDER_FAIL_MESSAGE = "Math fallback could not render some unsupported equations.";
260
+ const PDF_PREVIEW_UNAVAILABLE_MESSAGE = "PDF figure preview unavailable. Inline PDF rendering is not supported in this Studio browser environment.";
261
+ const PDF_PREVIEW_RENDER_FAIL_MESSAGE = "PDF figure preview could not be rendered.";
258
262
  let mermaidModulePromise = null;
259
263
  let mermaidInitialized = false;
260
264
  let mathJaxPromise = null;
265
+ let pdfJsPromise = null;
261
266
 
262
267
  const DEBUG_ENABLED = (() => {
263
268
  try {
@@ -1248,11 +1253,210 @@
1248
1253
  mathMl: true,
1249
1254
  svg: true,
1250
1255
  },
1256
+ ADD_TAGS: ["embed"],
1257
+ ADD_ATTR: ["src", "type", "title", "width", "height", "style", "data-fig-align"],
1258
+ ADD_DATA_URI_TAGS: ["embed"],
1251
1259
  });
1252
1260
  }
1253
1261
  return buildPreviewErrorHtml("Preview sanitizer unavailable. Showing plain markdown.", markdown);
1254
1262
  }
1255
1263
 
1264
+ function isPdfPreviewSource(src) {
1265
+ return Boolean(src) && (/^data:application\/pdf(?:;|,)/i.test(src) || /\.pdf(?:$|[?#])/i.test(src));
1266
+ }
1267
+
1268
+ function decoratePdfEmbeds(targetEl) {
1269
+ if (!targetEl || typeof targetEl.querySelectorAll !== "function") {
1270
+ return;
1271
+ }
1272
+
1273
+ const embeds = targetEl.querySelectorAll("embed[src]");
1274
+ embeds.forEach(function(embedEl) {
1275
+ const src = typeof embedEl.getAttribute === "function" ? (embedEl.getAttribute("src") || "") : "";
1276
+ if (!isPdfPreviewSource(src)) {
1277
+ return;
1278
+ }
1279
+ if (!embedEl.getAttribute("type")) {
1280
+ embedEl.setAttribute("type", "application/pdf");
1281
+ }
1282
+ if (!embedEl.getAttribute("title")) {
1283
+ embedEl.setAttribute("title", "Embedded PDF figure");
1284
+ }
1285
+ });
1286
+ }
1287
+
1288
+ function decodePdfDataUri(src) {
1289
+ const match = String(src || "").match(/^data:application\/pdf(?:;[^,]*)?,([A-Za-z0-9+/=\s]+)$/i);
1290
+ if (!match) return null;
1291
+ const payload = (match[1] || "").replace(/\s+/g, "");
1292
+ if (!payload) return null;
1293
+ const binary = window.atob(payload);
1294
+ const bytes = new Uint8Array(binary.length);
1295
+ for (let i = 0; i < binary.length; i += 1) {
1296
+ bytes[i] = binary.charCodeAt(i);
1297
+ }
1298
+ return bytes;
1299
+ }
1300
+
1301
+ function ensurePdfJs() {
1302
+ if (window.pdfjsLib && typeof window.pdfjsLib.getDocument === "function") {
1303
+ return Promise.resolve(window.pdfjsLib);
1304
+ }
1305
+ if (pdfJsPromise) {
1306
+ return pdfJsPromise;
1307
+ }
1308
+
1309
+ pdfJsPromise = import(PDFJS_CDN_URL)
1310
+ .then((module) => {
1311
+ const api = module && typeof module.getDocument === "function"
1312
+ ? module
1313
+ : (module && module.default && typeof module.default.getDocument === "function" ? module.default : null);
1314
+ if (!api || typeof api.getDocument !== "function") {
1315
+ throw new Error("pdf.js did not initialize.");
1316
+ }
1317
+ if (api.GlobalWorkerOptions && !api.GlobalWorkerOptions.workerSrc) {
1318
+ api.GlobalWorkerOptions.workerSrc = PDFJS_WORKER_CDN_URL;
1319
+ }
1320
+ window.pdfjsLib = api;
1321
+ return api;
1322
+ })
1323
+ .catch((error) => {
1324
+ pdfJsPromise = null;
1325
+ throw error;
1326
+ });
1327
+
1328
+ return pdfJsPromise;
1329
+ }
1330
+
1331
+ function appendPdfPreviewNotice(targetEl, message) {
1332
+ if (!targetEl || typeof targetEl.querySelector !== "function" || typeof targetEl.appendChild !== "function") {
1333
+ return;
1334
+ }
1335
+ if (targetEl.querySelector(".preview-pdf-warning")) {
1336
+ return;
1337
+ }
1338
+ const warningEl = document.createElement("div");
1339
+ warningEl.className = "preview-warning preview-pdf-warning";
1340
+ warningEl.textContent = String(message || PDF_PREVIEW_UNAVAILABLE_MESSAGE);
1341
+ targetEl.appendChild(warningEl);
1342
+ }
1343
+
1344
+ async function loadPdfDocumentSource(src) {
1345
+ const embedded = decodePdfDataUri(src);
1346
+ if (embedded) {
1347
+ return { data: embedded };
1348
+ }
1349
+ const response = await fetch(src);
1350
+ if (!response.ok) {
1351
+ throw new Error("Failed to fetch PDF figure for preview.");
1352
+ }
1353
+ const bytes = new Uint8Array(await response.arrayBuffer());
1354
+ return { data: bytes };
1355
+ }
1356
+
1357
+ async function renderSinglePdfPreviewEmbed(embedEl, pdfjsLib) {
1358
+ if (!embedEl || embedEl.dataset.studioPdfPreviewRendered === "1") {
1359
+ return false;
1360
+ }
1361
+
1362
+ const src = embedEl.getAttribute("src") || "";
1363
+ if (!isPdfPreviewSource(src)) {
1364
+ return false;
1365
+ }
1366
+
1367
+ const measuredWidth = Math.max(1, Math.round(embedEl.getBoundingClientRect().width || 0));
1368
+ const styleText = embedEl.getAttribute("style") || "";
1369
+ const widthAttr = embedEl.getAttribute("width") || "";
1370
+ const figAlign = embedEl.getAttribute("data-fig-align") || "";
1371
+ const pdfSource = await loadPdfDocumentSource(src);
1372
+ const loadingTask = pdfjsLib.getDocument(pdfSource);
1373
+ const pdfDocument = await loadingTask.promise;
1374
+
1375
+ try {
1376
+ const page = await pdfDocument.getPage(1);
1377
+ const baseViewport = page.getViewport({ scale: 1 });
1378
+ const cssWidth = Math.max(1, measuredWidth || Math.round(baseViewport.width));
1379
+ const renderScale = Math.max(0.25, cssWidth / baseViewport.width) * Math.min(window.devicePixelRatio || 1, 2);
1380
+ const viewport = page.getViewport({ scale: renderScale });
1381
+ const canvas = document.createElement("canvas");
1382
+ const context = canvas.getContext("2d", { alpha: false });
1383
+ if (!context) {
1384
+ throw new Error("Canvas 2D context unavailable.");
1385
+ }
1386
+
1387
+ canvas.width = Math.max(1, Math.ceil(viewport.width));
1388
+ canvas.height = Math.max(1, Math.ceil(viewport.height));
1389
+ canvas.style.width = "100%";
1390
+ canvas.style.height = "auto";
1391
+ canvas.setAttribute("aria-label", "PDF figure preview");
1392
+
1393
+ await page.render({
1394
+ canvasContext: context,
1395
+ viewport,
1396
+ }).promise;
1397
+
1398
+ const wrapper = document.createElement("div");
1399
+ wrapper.className = "studio-pdf-preview";
1400
+ if (styleText) {
1401
+ wrapper.style.cssText = styleText;
1402
+ } else if (widthAttr) {
1403
+ wrapper.style.width = /^\d+(?:\.\d+)?$/.test(widthAttr) ? (widthAttr + "px") : widthAttr;
1404
+ } else {
1405
+ wrapper.style.width = "100%";
1406
+ }
1407
+ if (figAlign) {
1408
+ wrapper.setAttribute("data-fig-align", figAlign);
1409
+ }
1410
+ wrapper.title = "PDF figure preview (page 1)";
1411
+ wrapper.appendChild(canvas);
1412
+ embedEl.dataset.studioPdfPreviewRendered = "1";
1413
+ embedEl.replaceWith(wrapper);
1414
+ return true;
1415
+ } finally {
1416
+ if (typeof pdfDocument.cleanup === "function") {
1417
+ try { pdfDocument.cleanup(); } catch {}
1418
+ }
1419
+ if (typeof pdfDocument.destroy === "function") {
1420
+ try { await pdfDocument.destroy(); } catch {}
1421
+ }
1422
+ }
1423
+ }
1424
+
1425
+ async function renderPdfPreviewsInElement(targetEl) {
1426
+ if (!targetEl || typeof targetEl.querySelectorAll !== "function") {
1427
+ return;
1428
+ }
1429
+
1430
+ const embeds = Array.from(targetEl.querySelectorAll("embed[src]"))
1431
+ .filter((embedEl) => isPdfPreviewSource(embedEl.getAttribute("src") || ""));
1432
+ if (embeds.length === 0) {
1433
+ return;
1434
+ }
1435
+
1436
+ let pdfjsLib;
1437
+ try {
1438
+ pdfjsLib = await ensurePdfJs();
1439
+ } catch (error) {
1440
+ console.error("pdf.js load failed:", error);
1441
+ appendPdfPreviewNotice(targetEl, PDF_PREVIEW_UNAVAILABLE_MESSAGE);
1442
+ return;
1443
+ }
1444
+
1445
+ let hadFailure = false;
1446
+ for (const embedEl of embeds) {
1447
+ try {
1448
+ await renderSinglePdfPreviewEmbed(embedEl, pdfjsLib);
1449
+ } catch (error) {
1450
+ hadFailure = true;
1451
+ console.error("PDF preview render failed:", error);
1452
+ }
1453
+ }
1454
+
1455
+ if (hadFailure) {
1456
+ appendPdfPreviewNotice(targetEl, PDF_PREVIEW_RENDER_FAIL_MESSAGE);
1457
+ }
1458
+ }
1459
+
1256
1460
  function appendMathFallbackNotice(targetEl, message) {
1257
1461
  if (!targetEl || typeof targetEl.querySelector !== "function" || typeof targetEl.appendChild !== "function") {
1258
1462
  return;
@@ -1901,6 +2105,8 @@
1901
2105
 
1902
2106
  finishPreviewRender(targetEl);
1903
2107
  targetEl.innerHTML = sanitizeRenderedHtml(renderedHtml, markdown);
2108
+ decoratePdfEmbeds(targetEl);
2109
+ await renderPdfPreviewsInElement(targetEl);
1904
2110
  const annotationMode = (pane === "source" || pane === "response")
1905
2111
  ? (annotationsEnabled ? "highlight" : "hide")
1906
2112
  : "none";
package/client/studio.css CHANGED
@@ -633,6 +633,91 @@
633
633
  color: var(--md-quote);
634
634
  }
635
635
 
636
+ .rendered-markdown .callout-note,
637
+ .rendered-markdown .callout-tip,
638
+ .rendered-markdown .callout-warning,
639
+ .rendered-markdown .callout-important,
640
+ .rendered-markdown .callout-caution {
641
+ margin: 1.15em 0;
642
+ padding: 0.8em 1rem 0.95em;
643
+ border: 1px solid var(--border-muted);
644
+ border-left-width: 4px;
645
+ border-radius: 10px;
646
+ background: var(--panel-2);
647
+ color: inherit;
648
+ }
649
+
650
+ .rendered-markdown .callout-note::before,
651
+ .rendered-markdown .callout-tip::before,
652
+ .rendered-markdown .callout-warning::before,
653
+ .rendered-markdown .callout-important::before,
654
+ .rendered-markdown .callout-caution::before {
655
+ display: inline-block;
656
+ margin-bottom: 0.45rem;
657
+ font-size: 0.76em;
658
+ font-weight: 700;
659
+ letter-spacing: 0.08em;
660
+ text-transform: uppercase;
661
+ }
662
+
663
+ .rendered-markdown .callout-note::before {
664
+ content: "Note";
665
+ color: var(--accent);
666
+ }
667
+
668
+ .rendered-markdown .callout-tip::before {
669
+ content: "Tip";
670
+ color: var(--ok);
671
+ }
672
+
673
+ .rendered-markdown .callout-warning::before {
674
+ content: "Warning";
675
+ color: var(--warn);
676
+ }
677
+
678
+ .rendered-markdown .callout-important::before {
679
+ content: "Important";
680
+ color: var(--error);
681
+ }
682
+
683
+ .rendered-markdown .callout-caution::before {
684
+ content: "Caution";
685
+ color: var(--error);
686
+ }
687
+
688
+ .rendered-markdown .callout-note {
689
+ border-left-color: var(--accent);
690
+ }
691
+
692
+ .rendered-markdown .callout-tip {
693
+ border-left-color: var(--ok);
694
+ }
695
+
696
+ .rendered-markdown .callout-warning {
697
+ border-left-color: var(--warn);
698
+ }
699
+
700
+ .rendered-markdown .callout-important,
701
+ .rendered-markdown .callout-caution {
702
+ border-left-color: var(--error);
703
+ }
704
+
705
+ .rendered-markdown .callout-note > :first-child,
706
+ .rendered-markdown .callout-tip > :first-child,
707
+ .rendered-markdown .callout-warning > :first-child,
708
+ .rendered-markdown .callout-important > :first-child,
709
+ .rendered-markdown .callout-caution > :first-child {
710
+ margin-top: 0;
711
+ }
712
+
713
+ .rendered-markdown .callout-note > :last-child,
714
+ .rendered-markdown .callout-tip > :last-child,
715
+ .rendered-markdown .callout-warning > :last-child,
716
+ .rendered-markdown .callout-important > :last-child,
717
+ .rendered-markdown .callout-caution > :last-child {
718
+ margin-bottom: 0;
719
+ }
720
+
636
721
  .rendered-markdown pre {
637
722
  background: var(--panel-2);
638
723
  border: 1px solid var(--md-codeblock-border);
@@ -765,10 +850,83 @@
765
850
  margin: 1.25em 0;
766
851
  }
767
852
 
853
+ .rendered-markdown .studio-page-break {
854
+ display: flex;
855
+ align-items: center;
856
+ gap: 0.85rem;
857
+ margin: 1.75em 0;
858
+ color: var(--muted);
859
+ font-size: 0.88em;
860
+ text-transform: uppercase;
861
+ letter-spacing: 0.08em;
862
+ }
863
+
864
+ .rendered-markdown .studio-page-break-rule {
865
+ flex: 1 1 auto;
866
+ height: 1px;
867
+ background: var(--md-hr);
868
+ opacity: 0.95;
869
+ }
870
+
871
+ .rendered-markdown .studio-page-break-label {
872
+ flex: 0 0 auto;
873
+ white-space: nowrap;
874
+ }
875
+
768
876
  .rendered-markdown img {
769
877
  max-width: 100%;
770
878
  }
771
879
 
880
+ .rendered-markdown img[data-fig-align="center"],
881
+ .rendered-markdown embed[data-fig-align="center"],
882
+ .rendered-markdown .studio-pdf-preview[data-fig-align="center"] {
883
+ display: block;
884
+ margin-left: auto;
885
+ margin-right: auto;
886
+ }
887
+
888
+ .rendered-markdown img[data-fig-align="right"],
889
+ .rendered-markdown embed[data-fig-align="right"],
890
+ .rendered-markdown .studio-pdf-preview[data-fig-align="right"] {
891
+ display: block;
892
+ margin-left: auto;
893
+ margin-right: 0;
894
+ }
895
+
896
+ .rendered-markdown embed[type="application/pdf"],
897
+ .rendered-markdown embed[src^="data:application/pdf"],
898
+ .rendered-markdown embed[src$=".pdf"] {
899
+ display: block;
900
+ width: 100%;
901
+ min-height: 18rem;
902
+ border: 1px solid var(--md-table-border);
903
+ border-radius: 10px;
904
+ background: #fff;
905
+ overflow: hidden;
906
+ }
907
+
908
+ .rendered-markdown .studio-pdf-preview {
909
+ display: block;
910
+ max-width: 100%;
911
+ border: 1px solid var(--md-table-border);
912
+ border-radius: 10px;
913
+ background: #fff;
914
+ overflow: hidden;
915
+ line-height: 0;
916
+ }
917
+
918
+ .rendered-markdown .studio-pdf-preview[data-fig-align="center"] {
919
+ margin-left: auto;
920
+ margin-right: auto;
921
+ }
922
+
923
+ .rendered-markdown .studio-pdf-preview canvas {
924
+ display: block;
925
+ width: 100%;
926
+ height: auto;
927
+ max-width: 100%;
928
+ }
929
+
772
930
  .rendered-markdown .studio-subfigure-group {
773
931
  margin: 1.25em auto;
774
932
  }