diff --git a/marimo/_server/export/exporter.py b/marimo/_server/export/exporter.py index f3ff2bb4647..8c6dcaa15c7 100644 --- a/marimo/_server/export/exporter.py +++ b/marimo/_server/export/exporter.py @@ -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 `
` 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
@@ -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
diff --git a/tests/_server/export/test_exporter.py b/tests/_server/export/test_exporter.py
index ed74957d66f..fe34df94c50 100644
--- a/tests/_server/export/test_exporter.py
+++ b/tests/_server/export/test_exporter.py
@@ -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,
@@ -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", {}