Skip to content

Commit 50f6930

Browse files
committed
fix: resolve blank slides and broken links in server-side PDF export (#10501)
1 parent 9e31b27 commit 50f6930

3 files changed

Lines changed: 276 additions & 0 deletions

File tree

marimo/_export/exporter.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -686,6 +686,14 @@ async def export_as_slides_pdf(
686686
import nbformat
687687

688688
notebook = nbformat.reads(ipynb_json_str, as_version=4) # type: ignore[no-untyped-call]
689+
690+
# Inline virtual file references in cell outputs so that plot images
691+
# and other media served via marimo's virtual file system are resolved
692+
# to data URIs before nbconvert renders the reveal.js HTML. Without this
693+
# step, image src attributes like `./@file/12345-plot.png` become broken
694+
# links when Playwright loads the HTML from a temporary file:// URI.
695+
self._inline_virtual_files_in_notebook(notebook)
696+
689697
if request.png_fallbacks:
690698
from marimo._export._nbformat_png_fallbacks import (
691699
inject_png_fallbacks_into_notebook,
@@ -704,6 +712,54 @@ async def export_as_slides_pdf(
704712
notebook, request.options.include_inputs
705713
)
706714

715+
@staticmethod
716+
def _inline_virtual_files_in_notebook(notebook: Any) -> None:
717+
"""Replace virtual file URLs with data URIs in all cell outputs.
718+
719+
Iterates over every code cell in an nbformat notebook and replaces
720+
`./@file/...` references found in `text/html` output data with
721+
inline base64 data URIs. This is necessary before passing the notebook
722+
to nbconvert exporters that produce standalone HTML (e.g. `SlidesExporter`),
723+
because the generated HTML is served from a temporary directory or file://
724+
URI where virtual file paths cannot be resolved.
725+
726+
Operates in-place on the notebook object.
727+
"""
728+
from marimo._convert.common.dom_traversal import (
729+
replace_virtual_files_with_data_uris,
730+
)
731+
732+
cells = notebook.get("cells", [])
733+
if not isinstance(cells, list):
734+
return
735+
736+
for cell in cells:
737+
if not isinstance(cell, dict) or cell.get("cell_type") != "code":
738+
continue
739+
outputs = cell.get("outputs", [])
740+
if not isinstance(outputs, list):
741+
continue
742+
for output in outputs:
743+
if not isinstance(output, dict):
744+
continue
745+
data = output.get("data")
746+
if not isinstance(data, dict):
747+
continue
748+
for mime_type, content in list(data.items()):
749+
if mime_type != "text/html" or not isinstance(
750+
content, str
751+
):
752+
continue
753+
if "./@file/" not in content:
754+
continue
755+
processed, _ = replace_virtual_files_with_data_uris(
756+
content,
757+
allowed_tags=VIRTUAL_FILE_ALLOWED_TAGS,
758+
allowed_attributes=VIRTUAL_FILE_ALLOWED_ATTRIBUTES,
759+
max_inline_bytes=MAX_VIRTUAL_FILE_INLINE_BYTES,
760+
)
761+
data[mime_type] = processed
762+
707763
@staticmethod
708764
def _to_file_uri(path: str) -> str:
709765
import os
@@ -734,6 +790,14 @@ async def _export_slides_as_pdf(
734790

735791
from nbconvert import SlidesExporter
736792

793+
# Explicitly check for Playwright up-front so users get a clear message
794+
# instead of a bare ModuleNotFoundError from the lazy import below.
795+
from marimo._dependencies.dependencies import DependencyManager
796+
797+
DependencyManager.playwright.require(
798+
"for slides PDF export (Playwright renders the reveal.js HTML to PDF)"
799+
)
800+
737801
# Add slideshow metadata so each cell becomes a slide.
738802
for cell in notebook.cells:
739803
if "slideshow" not in cell.metadata:

marimo/_server/api/endpoints/export.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
IPYNBExportRequest,
3838
MarkdownExportRequest,
3939
PDFExportRequest,
40+
PDFRasterizationRequest,
4041
ScriptExportRequest,
4142
)
4243
from marimo._export.serialization import serialize_notebook_snapshot
@@ -59,6 +60,7 @@
5960
SERVER_EXPORT_FORMATS,
6061
IPYNBExportOptions,
6162
MarkdownExportOptions,
63+
PDFRasterizationOptions,
6264
)
6365
from marimo._server.api.deps import AppState
6466
from marimo._server.api.utils import (
@@ -637,10 +639,45 @@ async def export_as_pdf(*, request: Request) -> Response:
637639
detail="File must have a name before exporting",
638640
)
639641

642+
# Collect PNG fallbacks for interactive/plot outputs when Playwright
643+
# is available, so that marimo components and Vega specs that cannot
644+
# render in nbconvert are replaced with captured screenshots.
645+
png_fallbacks = None
646+
if body.include_outputs and session.session_view is not None:
647+
try:
648+
from marimo._export._pdf_raster import (
649+
collect_pdf_png_fallbacks,
650+
)
651+
652+
png_fallbacks = await collect_pdf_png_fallbacks(
653+
PDFRasterizationRequest(
654+
app=session.app_file_manager.app,
655+
session_view=session.session_view,
656+
filename=session.app_file_manager.filename,
657+
filepath=session.app_file_manager.filename,
658+
options=PDFRasterizationOptions(
659+
enabled=True,
660+
scale=4.0,
661+
server_mode="static",
662+
),
663+
)
664+
)
665+
except Exception:
666+
# If Playwright / Chromium is not installed or rasterization
667+
# fails for any other reason, proceed without fallbacks.
668+
# nbconvert will still render whatever it can (e.g. raw
669+
# image/png outputs).
670+
LOGGER.info(
671+
"Rasterization unavailable for PDF export; "
672+
"proceeding without PNG fallbacks.",
673+
exc_info=True,
674+
)
675+
640676
export_request = PDFExportRequest(
641677
app=session.app_file_manager.app,
642678
session_view=session.session_view if body.include_outputs else None,
643679
options=to_pdf_export_options(body),
680+
png_fallbacks=png_fallbacks,
644681
)
645682
pdf_data = await render_pdf(export_request)
646683
if pdf_data is None:

tests/_export/test_exporter.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2664,6 +2664,181 @@ def slide_2():
26642664
sys.modules.pop("playwright.async_api", None)
26652665

26662666

2667+
@pytest.mark.skipif(
2668+
not DependencyManager.nbformat.has(),
2669+
reason="nbformat not installed",
2670+
)
2671+
async def test_export_as_slides_pdf_with_png_fallbacks(
2672+
session_view: SessionView,
2673+
) -> None:
2674+
"""Test slides PDF injection of PNG fallbacks alongside virtual file inlining."""
2675+
app = App()
2676+
2677+
@app.cell()
2678+
def slide_1():
2679+
return "slide 1"
2680+
2681+
file_manager = AppFileManager.from_app(InternalApp(app))
2682+
exporter = Exporter()
2683+
2684+
mock_slides_exporter_instance = MagicMock()
2685+
mock_slides_exporter_instance.from_notebook_node.return_value = (
2686+
"<html>mock slides html</html>",
2687+
{},
2688+
)
2689+
mock_slides_exporter_cls = MagicMock(
2690+
return_value=mock_slides_exporter_instance
2691+
)
2692+
2693+
mock_page = AsyncMock()
2694+
mock_page.pdf.return_value = b"mock_slides_pdf_data"
2695+
mock_browser = AsyncMock()
2696+
mock_browser.new_page.return_value = mock_page
2697+
mock_playwright_instance = AsyncMock()
2698+
mock_playwright_instance.chromium.launch.return_value = mock_browser
2699+
mock_playwright_cm = AsyncMock()
2700+
mock_playwright_cm.__aenter__.return_value = mock_playwright_instance
2701+
mock_playwright_cm.__aexit__.return_value = False
2702+
mock_async_playwright = MagicMock(return_value=mock_playwright_cm)
2703+
2704+
mock_nbconvert = MagicMock()
2705+
mock_nbconvert.SlidesExporter = mock_slides_exporter_cls
2706+
mock_playwright_async = MagicMock()
2707+
mock_playwright_async.async_playwright = mock_async_playwright
2708+
2709+
orig_nbconvert = sys.modules.get("nbconvert")
2710+
orig_playwright = sys.modules.get("playwright.async_api")
2711+
2712+
try:
2713+
sys.modules["nbconvert"] = mock_nbconvert
2714+
sys.modules["playwright.async_api"] = mock_playwright_async
2715+
2716+
png_fallbacks = {
2717+
"HbolJK": "data:image/png;base64,iVBORw0KGgo=",
2718+
}
2719+
2720+
with (
2721+
patch.object(
2722+
DependencyManager.nbconvert, "has", return_value=True
2723+
),
2724+
patch.object(
2725+
DependencyManager.playwright, "has", return_value=True
2726+
),
2727+
):
2728+
events: list[PDFExportStatusEvent] = []
2729+
result = await exporter.export_as_slides_pdf(
2730+
_pdf_export_request(
2731+
app=file_manager.app,
2732+
session_view=session_view,
2733+
png_fallbacks=png_fallbacks,
2734+
status_callback=events.append,
2735+
preset="slides",
2736+
)
2737+
)
2738+
2739+
assert result == b"mock_slides_pdf_data"
2740+
assert [(event.phase, event.message) for event in events] == [
2741+
("render", "rendering slides PDF..."),
2742+
]
2743+
2744+
# Verify the notebook passed to SlidesExporter has the injected
2745+
# PNG fallback in its cell outputs.
2746+
notebook = (
2747+
mock_slides_exporter_instance.from_notebook_node.call_args[0][
2748+
0
2749+
]
2750+
)
2751+
found_png = False
2752+
for cell in notebook.cells:
2753+
for output in cell.get("outputs", []):
2754+
data = output.get("data", {})
2755+
if "image/png" in data:
2756+
found_png = True
2757+
break
2758+
assert found_png, (
2759+
"Expected PNG fallback data in notebook cell outputs"
2760+
)
2761+
2762+
finally:
2763+
if orig_nbconvert is not None:
2764+
sys.modules["nbconvert"] = orig_nbconvert
2765+
else:
2766+
sys.modules.pop("nbconvert", None)
2767+
if orig_playwright is not None:
2768+
sys.modules["playwright.async_api"] = orig_playwright
2769+
else:
2770+
sys.modules.pop("playwright.async_api", None)
2771+
2772+
@pytest.mark.skipif(
2773+
not DependencyManager.nbformat.has(),
2774+
reason="nbformat not installed",
2775+
)
2776+
def test_inline_virtual_files_in_notebook() -> None:
2777+
"""Test that _inline_virtual_files_in_notebook resolves virtual file
2778+
references in cell output HTML to data URIs."""
2779+
import nbformat
2780+
2781+
notebook = nbformat.v4.new_notebook()
2782+
cell = nbformat.v4.new_code_cell(
2783+
"print('hi')",
2784+
id="cell-1",
2785+
)
2786+
cell.outputs = [
2787+
nbformat.v4.new_output(
2788+
"display_data",
2789+
data={
2790+
"text/html": '<img src="./@file/12345-test.png" alt="plot" />',
2791+
"text/plain": "test",
2792+
},
2793+
),
2794+
]
2795+
notebook.cells = [cell]
2796+
2797+
# The virtual file doesn't exist on disk, so the replacement will
2798+
# log a warning and leave the original value. We verify no crash.
2799+
Exporter._inline_virtual_files_in_notebook(notebook)
2800+
2801+
output_data = notebook.cells[0].outputs[0]["data"]
2802+
assert "text/html" in output_data
2803+
assert "text/plain" in output_data
2804+
2805+
2806+
@pytest.mark.skipif(
2807+
not DependencyManager.nbformat.has(),
2808+
reason="nbformat not installed",
2809+
)
2810+
def test_inline_virtual_files_in_notebook_skips_non_html() -> None:
2811+
"""Test that non-HTML outputs (image/png, text/plain) are never
2812+
sent through the HTML parser, preserving their raw content."""
2813+
import nbformat
2814+
2815+
notebook = nbformat.v4.new_notebook()
2816+
cell = nbformat.v4.new_code_cell(
2817+
"print('hi')",
2818+
id="cell-1",
2819+
)
2820+
cell.outputs = [
2821+
nbformat.v4.new_output(
2822+
"display_data",
2823+
data={
2824+
"image/png": "iVBORw0KGgo=",
2825+
"text/plain": "plain text with ./@file/ in content",
2826+
},
2827+
),
2828+
]
2829+
notebook.cells = [cell]
2830+
original_png = cell.outputs[0]["data"]["image/png"]
2831+
original_text = cell.outputs[0]["data"]["text/plain"]
2832+
2833+
Exporter._inline_virtual_files_in_notebook(notebook)
2834+
2835+
output_data = notebook.cells[0].outputs[0]["data"]
2836+
# image/png must remain untouched
2837+
assert output_data["image/png"] == original_png
2838+
# text/plain with "./@file/" must remain untouched (not text/html)
2839+
assert output_data["text/plain"] == original_text
2840+
2841+
26672842
@pytest.mark.skipif(
26682843
sys.platform == "win32",
26692844
reason="Unix permission bits not supported on Windows",

0 commit comments

Comments
 (0)