diff --git a/news/changelog-1.10.md b/news/changelog-1.10.md index 4655b2db75..c26356127d 100644 --- a/news/changelog-1.10.md +++ b/news/changelog-1.10.md @@ -89,6 +89,7 @@ All changes included in 1.10: ### Jupyter +- ([#11150](https://github.com/quarto-dev/quarto-cli/issues/11150)): Fix `dashboard` format showing spurious intermediate text output (e.g. a `Text(...)` repr or a matplotlib object repr) next to a figure when a cell's top-level plotting calls echo their return values. - ([#13582](https://github.com/quarto-dev/quarto-cli/pull/13582)): Fix `application/pdf` and `text/latex` MIME types not being preferred over `image/svg+xml` when rendering Jupyter notebooks to PDF, which caused errors when `rsvg-convert` was not available. (author: @jkrumbiegel) - ([#14374](https://github.com/quarto-dev/quarto-cli/pull/14374)): Avoid a crash when a third-party Jupyter kernel (observed with Maple 2025, built on XEUS) returns `execute_reply` without the required `status` field. The failing cell is recorded as an error instead of aborting the render. (author: @ChrisJefferson) diff --git a/src/core/jupyter/jupyter.ts b/src/core/jupyter/jupyter.ts index 340a661966..64e2a7ee00 100644 --- a/src/core/jupyter/jupyter.ts +++ b/src/core/jupyter/jupyter.ts @@ -1757,7 +1757,7 @@ async function mdFromCodeCell( return md; } -function isDiscardableTextExecuteResult( +export function isDiscardableTextExecuteResult( output: JupyterOutput, haveImage: boolean, ) { @@ -1766,8 +1766,23 @@ function isDiscardableTextExecuteResult( if (Object.keys(data).length === 1) { const textPlain = data?.[kTextPlain] as string[] | undefined; if (textPlain && textPlain.length) { - if (haveImage && textPlain.length === 1) { - return /^([<(\[]).*?([>)\]])$/.test(textPlain[0].trim()); + if (haveImage) { + if (textPlain.length === 1) { + // single-line object reprs echoed next to the figure: <...>/(...)/[...] + // wrappers (Axes, Line2D, tuples) plus matplotlib Text from + // title()/xlabel()/ylabel()/set_title() — whose repr leads with a + // numeric coordinate (Text(0.5, ...)), unlike other libraries' Text + const first = textPlain[0].trim(); + return /^([<(\[]).*?([>)\]])$/.test(first) || + /^Text\([-\d]/.test(first) || + (first.startsWith("{") && first.includes(" line.includes(" ({ + output_type: "execute_result", + data: { [kTextPlain]: textPlainLines }, +}); + +// text/plain reprs that top-level plotting expressions echo under +// ipynb-shell-interactivity: all. The matplotlib reprs below are captured +// verbatim from the executed 11150.quarto_ipynb (keep-ipynb: true) — Jupyter +// emits text/plain as a per-line array, so multi-line reprs have length > 1. +const boxplotDict = [ + "{'whiskers': [,\n", + " ],\n", + " 'caps': [,\n", + " ],\n", + " 'boxes': [],\n", + " 'medians': [],\n", + " 'fliers': [],\n", + " 'means': []}", +]; +// A single-line dict of matplotlib artists (small enough that IPython does not +// pretty-print it across lines). Unambiguous plotting noise. +const singleLineDict = ["{'line': }"]; +const titleText = ["Text(0.5, 1.0, 'Test')"]; +const axesRepr = [""]; +const line2DRepr = [""]; +// A DataFrame echoed alongside the figure: multi-line text/plain plus text/html +// (also captured from 11150.quarto_ipynb). Two mime keys -> must be kept. +const dataFrameText = [ + " day total_bill\n", + "0 Fri 17.151579\n", + "1 Sat 20.441379\n", + "2 Sun 21.410000\n", + "3 Thur 17.682742", +]; + +unitTest( + "jupyter - isDiscardableTextExecuteResult drops plotting-call reprs only alongside an image", + // deno-lint-ignore require-await + async () => { + // Discard plotting-object noise when the cell also emitted an image. + assertEquals( + isDiscardableTextExecuteResult(execResult(titleText), true), + true, + "plt.title()/set_title() Text repr should be discarded", + ); + assertEquals( + isDiscardableTextExecuteResult(execResult(boxplotDict), true), + true, + "plt.boxplot() multi-line dict of Line2D should be discarded", + ); + assertEquals( + isDiscardableTextExecuteResult(execResult(axesRepr), true), + true, + "seaborn/pandas Axes repr should be discarded", + ); + assertEquals( + isDiscardableTextExecuteResult(execResult(line2DRepr), true), + true, + "bare Line2D repr should be discarded", + ); + assertEquals( + isDiscardableTextExecuteResult(execResult(singleLineDict), true), + true, + "single-line dict of matplotlib artists should be discarded", + ); + + // Gate: with no image in the cell, nothing here is discarded — a cell + // that only echoed these values (no figure) must keep them. + assertEquals( + isDiscardableTextExecuteResult(execResult(titleText), false), + false, + "Text repr must be kept when the cell has no image", + ); + assertEquals( + isDiscardableTextExecuteResult(execResult(boxplotDict), false), + false, + "boxplot dict must be kept when the cell has no image", + ); + + // No over-suppression: legitimate output survives even alongside an image. + // A Name(...) repr that is not a matplotlib Text must not be swept up. + assertEquals( + isDiscardableTextExecuteResult( + execResult(["datetime.datetime(2020, 1, 1, 0, 0)"]), + true, + ), + false, + "non-matplotlib single-line Name(...) repr must be kept", + ); + // A non-matplotlib Text( repr (e.g. rich.text.Text -> Text('...')) has no + // leading coordinate, so it must be kept. + assertEquals( + isDiscardableTextExecuteResult(execResult(["Text('hello world')"]), true), + false, + "non-matplotlib Text('...') repr must be kept", + ); + // A multi-line bracketed value (list/tuple) with no matplotlib reference + // must survive — the discard heuristic must not widen to arbitrary brackets. + assertEquals( + isDiscardableTextExecuteResult( + execResult(["[1, 2, 3,\n", " 4, 5, 6]"]), + true, + ), + false, + "multi-line list with no matplotlib reference must be kept", + ); + // An arbitrary multi-line repr with no matplotlib reference must survive. + assertEquals( + isDiscardableTextExecuteResult( + execResult(["MyResult(\n", " value=42,\n", ")"]), + true, + ), + false, + "multi-line custom repr with no matplotlib reference must be kept", + ); + // Multiple mime types (e.g. a DataFrame's text/plain + text/html) -> keep. + assertEquals( + isDiscardableTextExecuteResult( + { + output_type: "execute_result", + data: { [kTextPlain]: dataFrameText, "text/html": [""] }, + }, + true, + ), + false, + "multi-mime output (DataFrame text/plain + text/html) must be kept", + ); + + // Only execute_result is ever discardable. + assertEquals( + isDiscardableTextExecuteResult( + { output_type: "display_data", data: { [kTextPlain]: titleText } }, + true, + ), + false, + "display_data must never be discarded", + ); + assertEquals( + isDiscardableTextExecuteResult( + { output_type: "stream", name: "stdout", text: titleText }, + true, + ), + false, + "stream output must never be discarded", + ); + }, +);