Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 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
49 changes: 49 additions & 0 deletions tests/_server/export/test_exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -1653,6 +1653,51 @@ 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 HTMLExporter

from marimo._server.export.exporter import (
WEBPDF_CODE_WRAP_CSS,
_inline_code_wrap_css,
_nbconvert_tag_remove_config,
)

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"
)
]

# WebPDFExporter renders through the `webpdf` template; asserting on
# the HTML avoids needing Chromium in CI.
exporter = HTMLExporter(
config=_nbconvert_tag_remove_config(), template_name="webpdf"
)
rendered_without, _ = exporter.from_notebook_node(notebook)
assert WEBPDF_CODE_WRAP_CSS not in rendered_without

exporter.register_preprocessor(_inline_code_wrap_css, enabled=True)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick, but could we directly test _render_webpdf_with_nbconvert here? This test registers _inline_code_wrap_css itself, so it would still pass if the production registration were removed. We could patch WebPDFExporter.run_playwright to capture the generated HTML and return dummy PDF bytes.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch β€” you're right, and it was a real gap rather than a nitpick: dropping the register_preprocessor call from _render_webpdf_with_nbconvert left the old test green.

Fixed in c8a212a. It now drives _render_webpdf_with_nbconvert directly and stubs WebPDFExporter.run_playwright, which receives the fully rendered HTML β€” so the assertion covers the real registration, still without needing Chromium.

Verified by mutation: with the production register_preprocessor call removed the test fails, and passes with it restored.

rendered, _ = exporter.from_notebook_node(notebook)

# The stylesheet must actually reach the document: a misresolved
# template or preprocessor fails silently, leaving the bug in place.
assert WEBPDF_CODE_WRAP_CSS in rendered
assert "white-space: pre-wrap !important" in rendered

@pytest.mark.asyncio
async def test_run_app_then_export_as_pdf_ignores_status_callback_failures(
self,
Expand Down Expand Up @@ -1804,6 +1849,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