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", {}