@marimo-team/islands 0.20.3-dev76 → 0.20.3-dev77

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marimo-team/islands",
3
- "version": "0.20.3-dev76",
3
+ "version": "0.20.3-dev77",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -0,0 +1,42 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { runServerSidePDFDownload } from "../pdf-export";
5
+
6
+ describe("runServerSidePDFDownload", () => {
7
+ it("downloads document preset via backend PDF endpoint", async () => {
8
+ const downloadPDF = vi.fn().mockResolvedValue(undefined);
9
+
10
+ await runServerSidePDFDownload({
11
+ filename: "slides.py",
12
+ preset: "document",
13
+ downloadPDF,
14
+ });
15
+
16
+ expect(downloadPDF).toHaveBeenCalledWith({
17
+ filename: "slides.py",
18
+ webpdf: true,
19
+ preset: "document",
20
+ includeInputs: false,
21
+ rasterServer: "live",
22
+ });
23
+ });
24
+
25
+ it("downloads slides preset via backend PDF endpoint", async () => {
26
+ const downloadPDF = vi.fn().mockResolvedValue(undefined);
27
+
28
+ await runServerSidePDFDownload({
29
+ filename: "slides.py",
30
+ preset: "slides",
31
+ downloadPDF,
32
+ });
33
+
34
+ expect(downloadPDF).toHaveBeenCalledWith({
35
+ filename: "slides.py",
36
+ webpdf: true,
37
+ preset: "slides",
38
+ includeInputs: false,
39
+ rasterServer: "live",
40
+ });
41
+ });
42
+ });
@@ -0,0 +1,26 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ type Preset = "document" | "slides";
4
+ type DownloadPDF = (opts: {
5
+ filename: string;
6
+ webpdf: boolean;
7
+ preset: Preset;
8
+ includeInputs: boolean;
9
+ rasterServer: "live" | "static";
10
+ }) => Promise<void>;
11
+
12
+ export async function runServerSidePDFDownload(opts: {
13
+ filename: string;
14
+ preset: Preset;
15
+ downloadPDF: DownloadPDF;
16
+ }): Promise<void> {
17
+ const { filename, preset, downloadPDF } = opts;
18
+
19
+ await downloadPDF({
20
+ filename,
21
+ webpdf: true,
22
+ preset,
23
+ includeInputs: false,
24
+ rasterServer: "live",
25
+ });
26
+ }
@@ -56,10 +56,6 @@ import { disabledCellIds } from "@/core/cells/utils";
56
56
  import { useResolvedMarimoConfig } from "@/core/config/config";
57
57
  import { getFeatureFlag } from "@/core/config/feature-flag";
58
58
  import { Constants } from "@/core/constants";
59
- import {
60
- updateCellOutputsWithScreenshots,
61
- useEnrichCellOutputs,
62
- } from "@/core/export/hooks";
63
59
  import { useLayoutActions, useLayoutState } from "@/core/layout/layout";
64
60
  import { useTogglePresenting } from "@/core/layout/useTogglePresenting";
65
61
  import { kioskModeAtom, viewStateAtom } from "@/core/mode";
@@ -78,7 +74,6 @@ import {
78
74
  } from "@/utils/download";
79
75
  import { Filenames } from "@/utils/filenames";
80
76
  import { Objects } from "@/utils/objects";
81
- import type { ProgressState } from "@/utils/progress";
82
77
  import { newNotebookURL } from "@/utils/urls";
83
78
  import { useRunAllCells } from "../cell/useRunCells";
84
79
  import { useChromeActions, useChromeState } from "../chrome/state";
@@ -88,6 +83,7 @@ import { commandPaletteAtom } from "../controls/state";
88
83
  import { AddDatabaseDialogContent } from "../database/add-database-form";
89
84
  import { displayLayoutName, getLayoutIcon } from "../renderers/layout-select";
90
85
  import { LAYOUT_TYPES } from "../renderers/types";
86
+ import { runServerSidePDFDownload } from "./pdf-export";
91
87
  import type { ActionButton } from "./types";
92
88
  import { useCopyNotebook } from "./useCopyNotebook";
93
89
  import { useHideAllMarkdownCode } from "./useHideAllMarkdownCode";
@@ -122,16 +118,13 @@ export function useNotebookActions() {
122
118
  const setCommandPaletteOpen = useSetAtom(commandPaletteAtom);
123
119
  const setSettingsDialogOpen = useSetAtom(settingDialogAtom);
124
120
  const setKeyboardShortcutsOpen = useSetAtom(keyboardShortcutsAtom);
125
- const { exportAsMarkdown, readCode, saveCellConfig, updateCellOutputs } =
126
- useRequestClient();
121
+ const { exportAsMarkdown, readCode, saveCellConfig } = useRequestClient();
127
122
 
128
123
  const hasDisabledCells = useAtomValue(hasDisabledCellsAtom);
129
124
  const canUndoDeletes = useAtomValue(canUndoDeletesAtom);
130
125
  const { selectedLayout } = useLayoutState();
131
126
  const { setLayoutView } = useLayoutActions();
132
127
  const togglePresenting = useTogglePresenting();
133
- const takeScreenshots = useEnrichCellOutputs();
134
-
135
128
  // Fallback: if sharing is undefined, both are enabled by default
136
129
  const sharingHtmlEnabled = resolvedConfig.sharing?.html ?? true;
137
130
  const sharingWasmEnabled = resolvedConfig.sharing?.wasm ?? true;
@@ -141,6 +134,7 @@ export function useNotebookActions() {
141
134
  // Default export uses browser print, which is better in present mode
142
135
  const pdfDownloadEnabled =
143
136
  isServerSidePdfExportEnabled || viewState.mode === "present";
137
+ const isSlidesLayout = selectedLayout === "slides";
144
138
 
145
139
  const renderCheckboxElement = (checked: boolean) => (
146
140
  <div className="w-8 flex justify-end">
@@ -148,6 +142,39 @@ export function useNotebookActions() {
148
142
  </div>
149
143
  );
150
144
 
145
+ const renderRecommendedElement = (recommended: boolean) => {
146
+ if (!recommended) {
147
+ return null;
148
+ }
149
+ return (
150
+ <span className="ml-3 shrink-0 rounded-full border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-700">
151
+ Recommended
152
+ </span>
153
+ );
154
+ };
155
+
156
+ const downloadServerSidePDF = async ({
157
+ preset,
158
+ title,
159
+ }: {
160
+ preset: "document" | "slides";
161
+ title: string;
162
+ }) => {
163
+ if (!filename) {
164
+ toastNotebookMustBeNamed();
165
+ return;
166
+ }
167
+
168
+ const runDownload = async () => {
169
+ await runServerSidePDFDownload({
170
+ filename,
171
+ preset,
172
+ downloadPDF: downloadAsPDF,
173
+ });
174
+ };
175
+ await withLoadingToast(title, runDownload);
176
+ };
177
+
151
178
  const actions: ActionButton[] = [
152
179
  {
153
180
  icon: <DownloadIcon size={14} strokeWidth={1.5} />,
@@ -226,45 +253,60 @@ export function useNotebookActions() {
226
253
  },
227
254
  },
228
255
  {
256
+ divider: true,
229
257
  icon: <FileIcon size={14} strokeWidth={1.5} />,
230
258
  label: "Download as PDF",
231
- disabled: !pdfDownloadEnabled,
232
- tooltip: pdfDownloadEnabled ? undefined : (
233
- <span>
234
- Only available in app view. <br />
235
- Toggle with: {renderShortcut("global.hideCode", false)}
236
- </span>
237
- ),
238
- handle: async () => {
239
- if (isServerSidePdfExportEnabled) {
240
- if (!filename) {
241
- toastNotebookMustBeNamed();
242
- return;
243
- }
259
+ handle: NOOP_HANDLER,
260
+ disabled: !pdfDownloadEnabled && !isServerSidePdfExportEnabled,
261
+ dropdown: [
262
+ {
263
+ icon: <FileIcon size={14} strokeWidth={1.5} />,
264
+ label: "Document Layout",
265
+ rightElement: renderRecommendedElement(!isSlidesLayout),
266
+ disabled: !pdfDownloadEnabled,
267
+ tooltip: pdfDownloadEnabled ? undefined : (
268
+ <span>
269
+ Only available in app view. <br />
270
+ Toggle with: {renderShortcut("global.hideCode", false)}
271
+ </span>
272
+ ),
273
+ handle: async () => {
274
+ if (isServerSidePdfExportEnabled) {
275
+ await downloadServerSidePDF({
276
+ preset: "document",
277
+ title: "Downloading Document PDF...",
278
+ });
279
+ return;
280
+ }
244
281
 
245
- const downloadPDF = async (progress: ProgressState) => {
246
- await updateCellOutputsWithScreenshots({
247
- takeScreenshots: () => takeScreenshots({ progress }),
248
- updateCellOutputs,
249
- });
250
- await downloadAsPDF({
251
- filename: filename,
252
- webpdf: false,
282
+ const beforeprint = new Event("export-beforeprint");
283
+ const afterprint = new Event("export-afterprint");
284
+ function print() {
285
+ window.dispatchEvent(beforeprint);
286
+ setTimeout(() => window.print(), 0);
287
+ setTimeout(() => window.dispatchEvent(afterprint), 0);
288
+ }
289
+ print();
290
+ },
291
+ },
292
+ {
293
+ icon: <FileIcon size={14} strokeWidth={1.5} />,
294
+ label: "Slides Layout",
295
+ rightElement: renderRecommendedElement(isSlidesLayout),
296
+ disabled: !isServerSidePdfExportEnabled,
297
+ tooltip: isServerSidePdfExportEnabled ? undefined : (
298
+ <span>
299
+ Requires Better PDF Export in Settings &gt; Experimental.
300
+ </span>
301
+ ),
302
+ handle: async () => {
303
+ await downloadServerSidePDF({
304
+ preset: "slides",
305
+ title: "Downloading Slides PDF...",
253
306
  });
254
- };
255
- await withLoadingToast("Downloading PDF...", downloadPDF);
256
- return;
257
- }
258
-
259
- const beforeprint = new Event("export-beforeprint");
260
- const afterprint = new Event("export-afterprint");
261
- function print() {
262
- window.dispatchEvent(beforeprint);
263
- setTimeout(() => window.print(), 0);
264
- setTimeout(() => window.dispatchEvent(afterprint), 0);
265
- }
266
- print();
267
- },
307
+ },
308
+ },
309
+ ],
268
310
  },
269
311
  ],
270
312
  },
@@ -106,6 +106,37 @@ export const NotebookMenuDropdown: React.FC<Props> = ({
106
106
  return item;
107
107
  };
108
108
 
109
+ const renderAction = (action: ActionButton) => {
110
+ if (action.hidden || action.redundant) {
111
+ return null;
112
+ }
113
+
114
+ if (action.dropdown) {
115
+ return (
116
+ <DropdownMenuSub key={action.label}>
117
+ <DropdownMenuSubTrigger
118
+ data-testid={`notebook-menu-dropdown-${action.label}`}
119
+ disabled={action.disabled}
120
+ >
121
+ {renderLabel(action)}
122
+ </DropdownMenuSubTrigger>
123
+ <DropdownMenuPortal>
124
+ <DropdownMenuSubContent>
125
+ {action.dropdown.map((childAction) => (
126
+ <React.Fragment key={childAction.label}>
127
+ {childAction.divider && <DropdownMenuSeparator />}
128
+ {renderAction(childAction)}
129
+ </React.Fragment>
130
+ ))}
131
+ </DropdownMenuSubContent>
132
+ </DropdownMenuPortal>
133
+ </DropdownMenuSub>
134
+ );
135
+ }
136
+
137
+ return renderLeafAction(action);
138
+ };
139
+
109
140
  return (
110
141
  <DropdownMenu modal={false}>
111
142
  <DropdownMenuTrigger asChild={true} disabled={disabled}>
@@ -113,38 +144,10 @@ export const NotebookMenuDropdown: React.FC<Props> = ({
113
144
  </DropdownMenuTrigger>
114
145
  <DropdownMenuContent align="end" className="print:hidden w-[240px]">
115
146
  {actions.map((action) => {
116
- if (action.hidden || action.redundant) {
117
- return null;
118
- }
119
-
120
- if (action.dropdown) {
121
- return (
122
- <DropdownMenuSub key={action.label}>
123
- <DropdownMenuSubTrigger
124
- data-testid={`notebook-menu-dropdown-${action.label}`}
125
- >
126
- {renderLabel(action)}
127
- </DropdownMenuSubTrigger>
128
- <DropdownMenuPortal>
129
- <DropdownMenuSubContent>
130
- {action.dropdown.map((action) => {
131
- return (
132
- <React.Fragment key={action.label}>
133
- {action.divider && <DropdownMenuSeparator />}
134
- {renderLeafAction(action)}
135
- </React.Fragment>
136
- );
137
- })}
138
- </DropdownMenuSubContent>
139
- </DropdownMenuPortal>
140
- </DropdownMenuSub>
141
- );
142
- }
143
-
144
147
  return (
145
148
  <React.Fragment key={action.label}>
146
149
  {action.divider && <DropdownMenuSeparator />}
147
- {renderLeafAction(action)}
150
+ {renderAction(action)}
148
151
  </React.Fragment>
149
152
  );
150
153
  })}
@@ -92,5 +92,26 @@ describe("createNetworkRequests", () => {
92
92
 
93
93
  process.env.NODE_ENV = originalEnv;
94
94
  });
95
+
96
+ it("exportAsPDF should pass preset through to the API", async () => {
97
+ const requests = createNetworkRequests();
98
+ await requests.exportAsPDF({
99
+ webpdf: false,
100
+ preset: "slides",
101
+ includeInputs: false,
102
+ } as any);
103
+
104
+ expect(mockClient.POST).toHaveBeenCalledWith(
105
+ "/api/export/pdf",
106
+ expect.objectContaining({
107
+ body: expect.objectContaining({
108
+ webpdf: false,
109
+ preset: "slides",
110
+ includeInputs: false,
111
+ }),
112
+ parseAs: "blob",
113
+ }),
114
+ );
115
+ });
95
116
  });
96
117
  });
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
3
  import type { CellId } from "@/core/cells/ids";
4
4
  import { CellOutputId } from "@/core/cells/ids";
5
5
  import {
6
+ downloadAsPDF,
6
7
  downloadByURL,
7
8
  downloadCellOutputAsImage,
8
9
  downloadHTMLAsImage,
@@ -10,11 +11,21 @@ import {
10
11
  withLoadingToast,
11
12
  } from "../download";
12
13
 
14
+ const { mockExportAsPDF } = vi.hoisted(() => ({
15
+ mockExportAsPDF: vi.fn(),
16
+ }));
17
+
13
18
  // Mock html-to-image
14
19
  vi.mock("html-to-image", () => ({
15
20
  toPng: vi.fn(),
16
21
  }));
17
22
 
23
+ vi.mock("@/core/network/requests", () => ({
24
+ getRequestClient: () => ({
25
+ exportAsPDF: mockExportAsPDF,
26
+ }),
27
+ }));
28
+
18
29
  // Mock the toast module
19
30
  const mockDismiss = vi.fn();
20
31
  const mockUpdate = vi.fn();
@@ -41,6 +52,7 @@ vi.mock("@/utils/Logger", () => ({
41
52
  vi.mock("@/utils/filenames", () => ({
42
53
  Filenames: {
43
54
  toPNG: (name: string) => `${name}.png`,
55
+ toPDF: (name: string) => `${name}.pdf`,
44
56
  },
45
57
  }));
46
58
 
@@ -174,6 +186,33 @@ describe("withLoadingToast", () => {
174
186
  });
175
187
  });
176
188
 
189
+ describe("downloadAsPDF", () => {
190
+ beforeEach(() => {
191
+ vi.clearAllMocks();
192
+ });
193
+
194
+ it("should send the preset in export request payload", async () => {
195
+ mockExportAsPDF.mockRejectedValue(new Error("network"));
196
+
197
+ await expect(
198
+ downloadAsPDF({
199
+ filename: "path/to/notebook.py",
200
+ webpdf: false,
201
+ preset: "slides",
202
+ }),
203
+ ).rejects.toThrow("network");
204
+
205
+ expect(mockExportAsPDF).toHaveBeenCalledWith({
206
+ webpdf: false,
207
+ preset: "slides",
208
+ includeInputs: false,
209
+ rasterizeOutputs: true,
210
+ rasterScale: 4,
211
+ rasterServer: "static",
212
+ });
213
+ });
214
+ });
215
+
177
216
  describe("getImageDataUrlForCell", () => {
178
217
  const mockDataUrl = "data:image/png;base64,mockbase64data";
179
218
  let mockElement: HTMLElement;
@@ -188,6 +188,8 @@ export function downloadBlob(blob: Blob, filename: string) {
188
188
  URL.revokeObjectURL(url);
189
189
  }
190
190
 
191
+ export type PDFExportPreset = "document" | "slides";
192
+
191
193
  /**
192
194
  * Download the current notebook as a PDF file.
193
195
  *
@@ -197,13 +199,31 @@ export function downloadBlob(blob: Blob, filename: string) {
197
199
  export async function downloadAsPDF(opts: {
198
200
  filename: string;
199
201
  webpdf: boolean;
202
+ preset?: PDFExportPreset;
203
+ includeInputs?: boolean;
204
+ rasterizeOutputs?: boolean;
205
+ rasterScale?: number;
206
+ rasterServer?: "static" | "live";
200
207
  }) {
201
208
  const client = getRequestClient();
202
- const { filename, webpdf } = opts;
209
+ const {
210
+ filename,
211
+ webpdf,
212
+ preset = "document",
213
+ includeInputs = false,
214
+ rasterizeOutputs = true,
215
+ rasterScale = 4,
216
+ rasterServer = "static",
217
+ } = opts;
203
218
 
204
219
  try {
205
220
  const pdfBlob = await client.exportAsPDF({
206
221
  webpdf,
222
+ preset,
223
+ includeInputs,
224
+ rasterizeOutputs,
225
+ rasterScale,
226
+ rasterServer,
207
227
  });
208
228
 
209
229
  const filenameWithoutPath = Paths.basename(filename);