From 0030bed5109b139f2f5da805fad30d84c9d67a02 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 2 Jul 2026 12:28:28 +0200 Subject: [PATCH 1/7] test(dashboard): regression test for intermediate matplotlib output (#11150) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashboard runs the Jupyter kernel with ipynb-shell-interactivity: all, so every top-level expression in a cell echoes its value. matplotlib/pandas plotting calls return objects (plt.title -> Text, plt.boxplot -> dict of Line2D) whose reprs then leak into the rendered output next to the figure — the html-vs-dashboard difference reported in #11150. HTML is affected too, just less: under last_expr only the final expression's repr echoes. The test asserts those reprs are suppressed in both formats while the figure still renders, and guards the intentional multi-output capability that 'all' gives dashboards (both non-last and last cell values survive). Written test-first: currently fails until the output discard heuristic is broadened to cover these reprs. --- tests/docs/smoke-all/2026/07/02/11150.qmd | 58 +++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 tests/docs/smoke-all/2026/07/02/11150.qmd diff --git a/tests/docs/smoke-all/2026/07/02/11150.qmd b/tests/docs/smoke-all/2026/07/02/11150.qmd new file mode 100644 index 0000000000..0382071e8d --- /dev/null +++ b/tests/docs/smoke-all/2026/07/02/11150.qmd @@ -0,0 +1,58 @@ +--- +title: "Intermediate matplotlib output shown in dashboard (#11150)" +format: + html: default + dashboard: default +engine: jupyter +echo: true +_quarto: + tests: + dashboard: + ensureHtmlElements: + - ['img'] # the figure is still rendered + ensureFileRegexMatches: + - + - '123' # non-last expression value preserved (shell-interactivity: all) + - '234' # last expression value preserved + - + - 'Text\(0\.5' # plt.title() Text repr suppressed + - 'whiskers' # plt.boxplot() dict repr suppressed + html: + ensureHtmlElements: + - ['img'] # the figure is still rendered + ensureFileRegexMatches: + - + - '234' # last expression value shown (last_expr) + - + - 'Text\(0\.5' # plt.title() Text repr suppressed + - '123' # non-last expression not echoed under last_expr +--- + +Dashboard format runs the Jupyter kernel with +`ipynb-shell-interactivity: all`, so every top-level expression statement in a +cell echoes its value, not just the last one. matplotlib/pandas/seaborn +plotting calls return objects (`plt.title()` returns a `Text`, `plt.boxplot()` +returns a dict of `Line2D`), and those object reprs leaked into the rendered +output as spurious intermediate text next to the figure — the difference from +HTML that #11150 reported. HTML is affected too, just less: under the default +`last_expr` it only echoes the final expression's repr (`Text(...)`). + +The first cell exercises the bug: it must render the figure but must not show +the `Text(...)` or boxplot-dict reprs, in either format. The second cell guards +the intentional multi-output capability that `shell-interactivity: all` gives +dashboards — both `123` and `234` must survive in the dashboard output (HTML, +being `last_expr`, keeps only `234`). + +```{python} +#| title: plot with a title +import matplotlib.pyplot as plt +x = [1, 2, 3, 4, 5, 10] +plt.boxplot(x) +plt.title("Test") +``` + +```{python} +#| title: multiple values in one cell +100 + 23 +200 + 34 +``` From b9528d9f76255142741d8e7c4a4676fdcc466dae Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 2 Jul 2026 13:03:38 +0200 Subject: [PATCH 2/7] test(dashboard): use a real all-default case for the #11150 multi-output guard The second cell asserted that two scalar expressions both echo under shell-interactivity: all. That proved multiplicity but not why all is the dashboard default, and the trivial values stretched awkwardly in the card fill layout. Replace it with the documented motivation: in a dashboard one cell is one card, so a card holding both a summary table and a plot must emit both from a single cell. Under all both survive; under HTML's last_expr only the last expression (the graph) is kept and the table is dropped. Element-count assertions on the table and the plotly div capture that contrast directly. --- tests/docs/smoke-all/2026/07/02/11150.qmd | 34 ++++++++++++++--------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/tests/docs/smoke-all/2026/07/02/11150.qmd b/tests/docs/smoke-all/2026/07/02/11150.qmd index 0382071e8d..43e8fcb1a0 100644 --- a/tests/docs/smoke-all/2026/07/02/11150.qmd +++ b/tests/docs/smoke-all/2026/07/02/11150.qmd @@ -9,23 +9,25 @@ _quarto: tests: dashboard: ensureHtmlElements: - - ['img'] # the figure is still rendered + - ['img'] # the matplotlib figure is still rendered + ensureHtmlElementCount: + selectors: ['table', '.plotly-graph-div'] + counts: [1, 1] # table AND graph both survive in one card (shell-interactivity: all) ensureFileRegexMatches: - - - - '123' # non-last expression value preserved (shell-interactivity: all) - - '234' # last expression value preserved + - [] # nothing required beyond the element checks - - 'Text\(0\.5' # plt.title() Text repr suppressed - 'whiskers' # plt.boxplot() dict repr suppressed html: ensureHtmlElements: - - ['img'] # the figure is still rendered + - ['img'] # the matplotlib figure is still rendered + ensureHtmlElementCount: + selectors: ['table', '.plotly-graph-div'] + counts: [0, 1] # last_expr keeps only the last expression (the graph); table dropped ensureFileRegexMatches: - - - - '234' # last expression value shown (last_expr) + - [] # nothing required beyond the element checks - - 'Text\(0\.5' # plt.title() Text repr suppressed - - '123' # non-last expression not echoed under last_expr --- Dashboard format runs the Jupyter kernel with @@ -40,8 +42,10 @@ HTML that #11150 reported. HTML is affected too, just less: under the default The first cell exercises the bug: it must render the figure but must not show the `Text(...)` or boxplot-dict reprs, in either format. The second cell guards the intentional multi-output capability that `shell-interactivity: all` gives -dashboards — both `123` and `234` must survive in the dashboard output (HTML, -being `last_expr`, keeps only `234`). +dashboards. In a dashboard one cell becomes one card, so composing a card that +holds both a summary table and a plot requires emitting both from a single +cell. Under `all` both survive in that one card; under HTML's `last_expr` only +the last expression (the graph) is kept and the table is dropped. ```{python} #| title: plot with a title @@ -52,7 +56,11 @@ plt.title("Test") ``` ```{python} -#| title: multiple values in one cell -100 + 23 -200 + 34 +#| title: table and graph in one card +#| echo: false +#| layout-ncol: 2 +import plotly.express as px +df = px.data.tips() +df.groupby("day", observed=True)["total_bill"].mean().reset_index() +px.box(df, x="day", y="total_bill") ``` From 01707b861b3e4f72bfc854990b07f83b95724bb7 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 2 Jul 2026 13:05:30 +0200 Subject: [PATCH 3/7] chore(tests): ignore quarto render artifacts in the #11150 test dir Matches the per-directory convention used across smoke-all: keep .quarto scratch and intermediate .quarto_ipynb files out of git. --- tests/docs/smoke-all/2026/07/02/.gitignore | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 tests/docs/smoke-all/2026/07/02/.gitignore diff --git a/tests/docs/smoke-all/2026/07/02/.gitignore b/tests/docs/smoke-all/2026/07/02/.gitignore new file mode 100644 index 0000000000..ad293093b0 --- /dev/null +++ b/tests/docs/smoke-all/2026/07/02/.gitignore @@ -0,0 +1,2 @@ +/.quarto/ +**/*.quarto_ipynb From 0d2a259c276e7d412db18d0d46e066e8282a0b18 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 2 Jul 2026 13:44:55 +0200 Subject: [PATCH 4/7] fix(dashboard): stop matplotlib plotting-call reprs leaking next to figures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashboards run the Jupyter kernel with ipynb-shell-interactivity: all, so every top-level expression in a cell echoes its value. matplotlib/pandas plotting calls return objects — plt.title() a Text, plt.boxplot() a dict of Line2D — whose text/plain reprs leaked into the rendered output as spurious text beside the figure (#11150). HTML saw a milder version under last_expr. The existing discard heuristic only caught single-line bracket-wrapped reprs and a few library prefixes, missing Text(...) (no leading bracket) and the multi-line boxplot/hist dict. Broaden it to also drop Text(...) and {...} collections of matplotlib artists, still only when the same cell emits an image, so the intentional multi-output-per-card capability that 'all' enables is preserved. Guarded by a unit test on the heuristic (contract inputs captured verbatim from a real executed notebook) plus the existing dashboard+html smoke-all regression. --- src/core/jupyter/jupyter.ts | 14 ++- tests/docs/smoke-all/2026/07/02/11150.qmd | 17 +-- tests/unit/jupyter/discardable-text.test.ts | 128 ++++++++++++++++++++ 3 files changed, 143 insertions(+), 16 deletions(-) create mode 100644 tests/unit/jupyter/discardable-text.test.ts diff --git a/src/core/jupyter/jupyter.ts b/src/core/jupyter/jupyter.ts index 340a661966..37e20a4016 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,16 @@ 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) { + // object reprs echoed next to the figure. Bracketed wrappers + // (Axes, Line2D, tuples) plus matplotlib Text from + // title()/xlabel()/ylabel()/set_title(); and the dict of Line2D + // returned by boxplot()/hist(). text/plain may arrive as one + // multi-line string, so match against the joined text. + const text = textPlain.join("").trim(); + return /^([<(\[])[\s\S]*?([>)\]])$/.test(text) || + text.startsWith("Text(") || + (text.startsWith("{") && text.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': []}", +]; +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. + // plt.title()/set_title() -> Text(...): single line, not bracket-wrapped. + assertEquals( + isDiscardableTextExecuteResult(execResult(titleText), true), + true, + ); + // plt.boxplot() -> multi-line dict of Line2D. + assertEquals( + isDiscardableTextExecuteResult(execResult(boxplotDict), true), + true, + ); + // seaborn/pandas Axes and bare Line2D: single-line <...>. + assertEquals( + isDiscardableTextExecuteResult(execResult(axesRepr), true), + true, + ); + assertEquals( + isDiscardableTextExecuteResult(execResult(line2DRepr), true), + true, + ); + + // 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, + ); + assertEquals( + isDiscardableTextExecuteResult(execResult(boxplotDict), false), + false, + ); + + // No over-suppression: legitimate output survives even with 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, + ); + // An arbitrary multi-line repr with no matplotlib reference must survive. + assertEquals( + isDiscardableTextExecuteResult( + execResult(["MyResult(\n", " value=42,\n", ")"]), + true, + ), + false, + ); + // 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, + ); + + // Only execute_result is ever discardable. + assertEquals( + isDiscardableTextExecuteResult( + { output_type: "display_data", data: { [kTextPlain]: titleText } }, + true, + ), + false, + ); + assertEquals( + isDiscardableTextExecuteResult( + { output_type: "stream", name: "stdout", text: titleText }, + true, + ), + false, + ); + }, +); From e1a442253a47c05c85cfd357baefe7d8962e101b Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 2 Jul 2026 13:56:55 +0200 Subject: [PATCH 5/7] fix(dashboard): keep the repr-discard heuristic from dropping real output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The broadened heuristic matched any bracket-wrapped text across newlines and any repr starting with "Text(", so a cell that emitted a figure alongside a legitimate multi-line list/tuple — or a non-matplotlib value such as rich's Text('...') — had that output silently discarded. Restore the single-line gate for the bracket match, suppress multi-line output only when it references a matplotlib object (the boxplot dict of Line2D), and require matplotlib's leading numeric coordinate for the Text(...) case. Adds unit negatives covering the multi-line-bracket and non-matplotlib Text( paths. --- src/core/jupyter/jupyter.ts | 24 ++++++++------ tests/unit/jupyter/discardable-text.test.ts | 35 ++++++++++++++++++--- 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/src/core/jupyter/jupyter.ts b/src/core/jupyter/jupyter.ts index 37e20a4016..268103d83b 100644 --- a/src/core/jupyter/jupyter.ts +++ b/src/core/jupyter/jupyter.ts @@ -1767,15 +1767,21 @@ export function isDiscardableTextExecuteResult( const textPlain = data?.[kTextPlain] as string[] | undefined; if (textPlain && textPlain.length) { if (haveImage) { - // object reprs echoed next to the figure. Bracketed wrappers - // (Axes, Line2D, tuples) plus matplotlib Text from - // title()/xlabel()/ylabel()/set_title(); and the dict of Line2D - // returned by boxplot()/hist(). text/plain may arrive as one - // multi-line string, so match against the joined text. - const text = textPlain.join("").trim(); - return /^([<(\[])[\s\S]*?([>)\]])$/.test(text) || - text.startsWith("Text(") || - (text.startsWith("{") && text.includes("/(...)/[...] + // 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); + } else { + // multi-line reprs that are collections of matplotlib artists, e.g. + // the dict of Line2D returned by boxplot(). Only suppress when a + // matplotlib object is referenced, leaving ordinary multi-line + // output (lists, tuples, custom reprs) untouched + return textPlain.some((line) => line.includes(" { // Discard plotting-object noise when the cell also emitted an image. - // plt.title()/set_title() -> Text(...): single line, not bracket-wrapped. assertEquals( isDiscardableTextExecuteResult(execResult(titleText), true), true, + "plt.title()/set_title() Text repr should be discarded", ); - // plt.boxplot() -> multi-line dict of Line2D. assertEquals( isDiscardableTextExecuteResult(execResult(boxplotDict), true), true, + "plt.boxplot() multi-line dict of Line2D should be discarded", ); - // seaborn/pandas Axes and bare Line2D: single-line <...>. 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", ); // Gate: with no image in the cell, nothing here is discarded — a cell @@ -74,13 +75,15 @@ unitTest( 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 with an 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( @@ -88,6 +91,24 @@ unitTest( 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( @@ -96,6 +117,7 @@ unitTest( 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( @@ -107,6 +129,7 @@ unitTest( true, ), false, + "multi-mime output (DataFrame text/plain + text/html) must be kept", ); // Only execute_result is ever discardable. @@ -116,6 +139,7 @@ unitTest( true, ), false, + "display_data must never be discarded", ); assertEquals( isDiscardableTextExecuteResult( @@ -123,6 +147,7 @@ unitTest( true, ), false, + "stream output must never be discarded", ); }, ); From c1798aa4501013c453b16fac2c5c51cd4b44edde Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 2 Jul 2026 15:03:04 +0200 Subject: [PATCH 6/7] docs(changelog): note dashboard spurious text-output fix (#11150) --- news/changelog-1.10.md | 1 + 1 file changed, 1 insertion(+) 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) From 98006fc07ecf8465c9e34b92cf10990bff8e84ca Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 2 Jul 2026 15:56:42 +0200 Subject: [PATCH 7/7] fix(dashboard): catch single-line matplotlib artist dicts alongside a figure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The multi-line branch of the discard heuristic already caught boxplot's dict-of-Line2D repr when IPython pretty-prints it across lines, but a short dict small enough to stay on one line (e.g. a single-entry boxplot/hist result) fell into the single-line branch, which only matched bracket wrappers starting with <, (, or [ — not {. Extend the single-line case to also discard a {...} repr that references a matplotlib object. --- src/core/jupyter/jupyter.ts | 3 ++- tests/unit/jupyter/discardable-text.test.ts | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/core/jupyter/jupyter.ts b/src/core/jupyter/jupyter.ts index 268103d83b..64e2a7ee00 100644 --- a/src/core/jupyter/jupyter.ts +++ b/src/core/jupyter/jupyter.ts @@ -1774,7 +1774,8 @@ export function isDiscardableTextExecuteResult( // numeric coordinate (Text(0.5, ...)), unlike other libraries' Text const first = textPlain[0].trim(); return /^([<(\[]).*?([>)\]])$/.test(first) || - /^Text\([-\d]/.test(first); + /^Text\([-\d]/.test(first) || + (first.startsWith("{") && first.includes("],\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 = [""]; @@ -69,6 +72,11 @@ unitTest( 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.