Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions marimo/_server/export/exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,38 @@ def _nbconvert_tag_remove_config() -> Config:
return config


# JupyterLab's stylesheet (shipped with nbconvert) styles the code input area
# with `overflow: hidden` and leaves the `<pre>` at the default `white-space:
# pre`. In JupyterLab that is fine because the editor scrolls; in a PDF there is
# no scrolling, so long lines are clipped and the text is lost entirely.
# Outputs already wrap (`.jp-OutputArea-output pre` sets `word-break`), so this
# only targets the input area.
#
# `!important` is required because this is inlined ahead of the JupyterLab
# rules; the slides PDF path overrides nbconvert styling the same way.
WEBPDF_CODE_WRAP_CSS = """\
/* marimo: wrap long code lines instead of clipping them (#9421) */
.jp-InputArea-editor {
overflow: visible !important;
}
.jp-InputArea-editor .highlight pre {
white-space: pre-wrap !important;
overflow-wrap: anywhere !important;
}
"""


def _inline_code_wrap_css(nb: Any, resources: Any) -> tuple[Any, Any]:
"""Inline `WEBPDF_CODE_WRAP_CSS`, as an nbconvert preprocessor.

Preprocessors run after nbconvert populates `resources`, so appending here
is what gets the stylesheet into the rendered HTML.
"""
inlining = resources.setdefault("inlining", {})
inlining.setdefault("css", []).append(WEBPDF_CODE_WRAP_CSS)
return nb, resources


def _render_webpdf_with_nbconvert(notebook: Any, include_inputs: bool) -> Any:
if sys.platform == "win32":
# marimo installs the Selector policy during import. The spawned render
Expand All @@ -105,6 +137,9 @@ def _render_webpdf_with_nbconvert(notebook: Any, include_inputs: bool) -> Any:
)
web_exporter.exclude_input = not include_inputs
web_exporter.allow_chromium_download = True
web_exporter.register_preprocessor( # type: ignore[no-untyped-call]
_inline_code_wrap_css, enabled=True
)
pdf_data, _resources = web_exporter.from_notebook_node(notebook) # type: ignore[no-untyped-call]
return pdf_data

Expand Down
60 changes: 60 additions & 0 deletions tests/_server/export/test_exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -1653,6 +1653,62 @@ def _():
f"{exporter_cls.__name__} dropped the visible cell source"
)

@pytest.mark.skipif(
not HAS_NBFORMAT or not DependencyManager.nbconvert.has(),
reason="nbformat and nbconvert are required to render ipynb",
)
def test_webpdf_inlines_code_wrapping_css(self) -> None:
"""WebPDF export inlines CSS that wraps long code lines.

JupyterLab's stylesheet clips the code input area, so long lines were
silently truncated in the PDF instead of wrapping.

Regression test for marimo-team/marimo#9421.
"""
import nbformat
from nbconvert import WebPDFExporter

from marimo._server.export.exporter import (
_render_webpdf_with_nbconvert,
)

notebook = nbformat.v4.new_notebook()
notebook.cells = [
nbformat.v4.new_code_cell(
"# a comment long enough to run past the printable width of a "
"PDF page and get clipped"
)
]

# `run_playwright` receives the fully rendered HTML, so stubbing it
# captures what Chromium would have been handed without needing a
# browser. Going through `_render_webpdf_with_nbconvert` means the
# test fails if the production code stops registering the
# preprocessor.
rendered: list[str] = []

def fake_run_playwright(self: Any, html: str) -> bytes:
del self
rendered.append(html)
return b"mock_pdf_data"

with patch.object(
WebPDFExporter, "run_playwright", fake_run_playwright
):
pdf_data = _render_webpdf_with_nbconvert(
notebook, include_inputs=True
)

assert pdf_data == b"mock_pdf_data"
assert len(rendered) == 1
# Assert on the distinctive selector and properties rather than the
# whole CSS constant, so the test is not brittle to nbconvert
# whitespace/comment handling when inlining the stylesheet.
html = rendered[0]
assert ".jp-InputArea-editor .highlight pre" in html
assert "white-space: pre-wrap !important" in html
assert "overflow-wrap: anywhere !important" in html

@pytest.mark.asyncio
async def test_run_app_then_export_as_pdf_ignores_status_callback_failures(
self,
Expand Down Expand Up @@ -1804,6 +1860,10 @@ def test_webpdf_render_preserves_parent_event_loop_policy(
class WebPDFExporter:
def __init__(self, config):
self.config = config
self.preprocessors = []

def register_preprocessor(self, preprocessor, enabled=False):
self.preprocessors.append((preprocessor, enabled))

def from_notebook_node(self, notebook):
return b"mock_webpdf_data", {}
Expand Down
Loading