Skip to content

Commit 6ca904a

Browse files
authored
Merge pull request #12842 from quarto-dev/fix/plotly-subfigures
Support for plotly.py 6+
2 parents 84c6cba + ce64e49 commit 6ca904a

File tree

7 files changed

+131
-3
lines changed

7 files changed

+131
-3
lines changed

news/changelog-1.8.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ All changes included in 1.8:
6868
### `jupyter`
6969

7070
- ([#12753](https://github.com/quarto-dev/quarto-cli/issues/12753)): Support change in IPython 9+ and import `set_matplotlib_formats` from `matplotlib_inline.backend_inline` in the internal `setup.py` script used to initialize rendering with Jupyter engine.
71+
- ([#12839](https://github.com/quarto-dev/quarto-cli/issues/12839)): Support for `plotly.py` 6+ which now loads plotly.js using a cdn in script as a module.
7172

7273
## Other fixes and improvements
7374

src/core/jupyter/widgets.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,9 +190,29 @@ function isWidgetIncludeHtml(html: string) {
190190
}
191191

192192
function isPlotlyLibrary(html: string) {
193-
return /^\s*<script type="text\/javascript">/.test(html) &&
193+
// Plotly before version 6 used require.js to load the library
194+
const hasRequireScript = (
195+
html: string,
196+
) => (/^\s*<script type="text\/javascript">/.test(html) &&
194197
(/require\.undef\(["']plotly["']\)/.test(html) ||
195-
/define\('plotly'/.test(html));
198+
/define\('plotly'/.test(html)));
199+
// Plotly 6+ uses the new module syntax
200+
const hasModuleScript = (html: string) =>
201+
/\s*<script type=\"module\">import .*plotly.*<\/script>/.test(html);
202+
// notebook mode non connected embed plotly.js scripts like this:
203+
// * plotly.js v3.0.1
204+
// * Copyright 2012-2025, Plotly, Inc.
205+
// * All rights reserved.
206+
// * Licensed under the MIT license
207+
const hasEmbedScript = (
208+
html: string,
209+
) => (/\* plotly\.js v\d+\.\d+\.\d+\s*\n\s*\* Copyright \d{4}-\d{4}, Plotly, Inc\.\s*\n\s*\* All rights reserved\.\s*\n\s*\* Licensed under the MIT license/
210+
.test(html));
211+
return hasRequireScript(html) ||
212+
// also handle new module syntax from plotly.py 6+
213+
hasModuleScript(html) ||
214+
// detect plotly by its copyright header
215+
hasEmbedScript(html);
196216
}
197217

198218
function htmlLibrariesText(htmlText: string) {
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
title: "Bugged plotly figure: phantom subfigure"
3+
_quarto:
4+
tests:
5+
html:
6+
ensureHtmlElements:
7+
-
8+
- 'figure.quarto-float-fig div#fig-gapminder-1 figure.quarto-subfloat-fig div.plotly-graph-div'
9+
- 'figure.quarto-float-fig div#fig-gapminder-2 figure.quarto-subfloat-fig div.plotly-graph-div'
10+
ensureHtmlElementContents:
11+
selectors:
12+
- 'div#fig-gapminder-1 figcaption.quarto-subfloat-caption'
13+
- 'div#fig-gapminder-2 figcaption.quarto-subfloat-caption'
14+
matches: ['\((a|b)\) Gapminder: (1957|2007)']
15+
ensureHtmlElementCount:
16+
selectors: ['figure.quarto-float-fig figure.quarto-subfloat-fig']
17+
counts: [2]
18+
dashboard:
19+
ensureHtmlElements:
20+
-
21+
- 'figure.quarto-float-fig div#fig-gapminder-1 figure.quarto-subfloat-fig div.plotly-graph-div'
22+
- 'figure.quarto-float-fig div#fig-gapminder-2 figure.quarto-subfloat-fig div.plotly-graph-div'
23+
ensureHtmlElementContents:
24+
selectors:
25+
- 'div#fig-gapminder-1 figcaption.quarto-subfloat-caption'
26+
- 'div#fig-gapminder-2 figcaption.quarto-subfloat-caption'
27+
matches: ['\((a|b)\) Gapminder: (1957|2007)']
28+
ensureHtmlElementCount:
29+
selectors: ['figure.quarto-float-fig figure.quarto-subfloat-fig']
30+
counts: [2]
31+
---
32+
33+
```{python}
34+
#| label: fig-gapminder
35+
#| fig-cap: "Life Expectancy and GDP"
36+
#| fig-subcap:
37+
#| - "Gapminder: 1957"
38+
#| - "Gapminder: 2007"
39+
#| layout-ncol: 2
40+
#| column: page
41+
42+
import plotly.express as px
43+
import plotly.io as pio
44+
gapminder = px.data.gapminder()
45+
def gapminder_plot(year):
46+
gapminderYear = gapminder.query("year == " +
47+
str(year))
48+
fig = px.scatter(gapminderYear,
49+
x="gdpPercap", y="lifeExp",
50+
size="pop", size_max=60,
51+
hover_name="country")
52+
fig.show()
53+
54+
gapminder_plot(1957)
55+
gapminder_plot(2007)
56+
```

tests/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,5 @@ dependencies = [
1818
"great-tables>=0.17.0",
1919
"polars>=1.29.0",
2020
"pyarrow>=20.0.0",
21+
"plotly>=6.1.1",
2122
]

tests/smoke/smoke-all.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ import {
3636
ensureLatexFileRegexMatches,
3737
printsMessage,
3838
shouldError,
39-
ensureHtmlElementContents
39+
ensureHtmlElementContents,
40+
ensureHtmlElementCount,
4041
} from "../verify.ts";
4142
import { readYamlFromMarkdown } from "../../src/core/yaml.ts";
4243
import { findProjectDir, findProjectOutputDir, outputForInput } from "../utils.ts";
@@ -130,6 +131,7 @@ function resolveTestSpecs(
130131
ensureEpubFileRegexMatches,
131132
ensureHtmlElements,
132133
ensureHtmlElementContents,
134+
ensureHtmlElementCount,
133135
ensureFileRegexMatches,
134136
ensureLatexFileRegexMatches,
135137
ensureTypstFileRegexMatches,

tests/uv.lock

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/verify.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,39 @@ export const ensureHtmlElementContents = (
402402

403403
}
404404

405+
export const ensureHtmlElementCount = (
406+
file: string,
407+
options: {
408+
selectors: string[] | string,
409+
counts: number[] | number
410+
}
411+
): Verify => {
412+
return {
413+
name: "Verify number of elements for selectors",
414+
verify: async (_output: ExecuteOutput[]) => {
415+
const htmlInput = await Deno.readTextFile(file);
416+
const doc = new DOMParser().parseFromString(htmlInput, "text/html")!;
417+
418+
// Convert single values to arrays for unified processing
419+
const selectorsArray = Array.isArray(options.selectors) ? options.selectors : [options.selectors];
420+
const countsArray = Array.isArray(options.counts) ? options.counts : [options.counts];
421+
422+
if (selectorsArray.length !== countsArray.length) {
423+
throw new Error("Selectors and counts arrays must have the same length");
424+
}
425+
426+
selectorsArray.forEach((selector, index) => {
427+
const expectedCount = countsArray[index];
428+
const elements = doc.querySelectorAll(selector);
429+
assert(
430+
elements.length === expectedCount,
431+
`Selector '${selector}' matched ${elements.length} elements, expected ${expectedCount}.`
432+
);
433+
});
434+
}
435+
};
436+
};
437+
405438
export const ensureSnapshotMatches = (
406439
file: string,
407440
): Verify => {

0 commit comments

Comments
 (0)