Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions news/changelog-1.10.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
21 changes: 18 additions & 3 deletions src/core/jupyter/jupyter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1757,7 +1757,7 @@ async function mdFromCodeCell(
return md;
}

function isDiscardableTextExecuteResult(
export function isDiscardableTextExecuteResult(
output: JupyterOutput,
haveImage: boolean,
) {
Expand All @@ -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("<matplotlib."));
} 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("<matplotlib."));
}
} else {
return [
"[<matplotlib",
Expand Down
2 changes: 2 additions & 0 deletions tests/docs/smoke-all/2026/07/02/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/.quarto/
**/*.quarto_ipynb
57 changes: 57 additions & 0 deletions tests/docs/smoke-all/2026/07/02/11150.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
title: "Intermediate matplotlib output shown in dashboard (#11150)"
format:
html: default
dashboard: default
engine: jupyter
echo: true
_quarto:
tests:
dashboard:
ensureHtmlElements:
- ['img'] # the matplotlib figure is still rendered
ensureHtmlElementCount:
selectors: ['table', '.plotly-graph-div', '.cell-output-display pre']
counts: [1, 1, 0] # table + graph both survive in one card (all); zero spurious text-output reprs
html:
ensureHtmlElements:
- ['img'] # the matplotlib figure is still rendered
ensureHtmlElementCount:
selectors: ['table', '.plotly-graph-div', '.cell-output-display pre']
counts: [0, 1, 0] # last_expr drops the table; one graph; zero spurious text-output reprs
---

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. 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
import matplotlib.pyplot as plt
x = [1, 2, 3, 4, 5, 10]
plt.boxplot(x)
plt.title("Test")
```

```{python}
#| 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")
```
161 changes: 161 additions & 0 deletions tests/unit/jupyter/discardable-text.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/*
* discardable-text.test.ts
*
* Copyright (C) 2026 Posit Software, PBC
*/

import { unitTest } from "../../test.ts";
import { assertEquals } from "testing/asserts";
import { isDiscardableTextExecuteResult } from "../../../src/core/jupyter/jupyter.ts";
import { kTextPlain } from "../../../src/core/mime.ts";
import { JupyterOutput } from "../../../src/core/jupyter/types.ts";

// An execute_result whose only mime bundle is text/plain, stored as the
// per-line array Jupyter emits.
const execResult = (textPlainLines: string[]): JupyterOutput => ({
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': [<matplotlib.lines.Line2D at 0x17685b54980>,\n",
" <matplotlib.lines.Line2D at 0x17685b54ad0>],\n",
" 'caps': [<matplotlib.lines.Line2D at 0x17685b54c20>,\n",
" <matplotlib.lines.Line2D at 0x17685b54d70>],\n",
" 'boxes': [<matplotlib.lines.Line2D at 0x17685b54830>],\n",
" 'medians': [<matplotlib.lines.Line2D at 0x17685b54ec0>],\n",
" 'fliers': [<matplotlib.lines.Line2D at 0x17685b55010>],\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': <matplotlib.lines.Line2D at 0x1407>}"];
const titleText = ["Text(0.5, 1.0, 'Test')"];
const axesRepr = ["<Axes: title={'center': 'Test'}>"];
const line2DRepr = ["<matplotlib.lines.Line2D at 0x14071bc7e00>"];
// 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": ["<table>"] },
},
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",
);
},
);
Loading