diff --git a/CHANGELOG.md b/CHANGELOG.md
index 357296f56..476091b4b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -60,6 +60,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
mystmd supports structured template options, so that migration is mechanical
([#205](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/205)) ([#224](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/224)).
+### Fixed
+- Images in a notebook cell's outputs are centred in the content column, as
+ they are on the Sphinx lecture sites. An inline matplotlib figure is narrower
+ than the column, so it sat against the left edge with all the spare width
+ beside it — 0px to its left and 232px to its right, for a 568px plot in the
+ 800px column. Both the stored outputs and the ones re-rendered when a reader
+ starts live compute are covered. Tables and text outputs stay left-aligned
+ ([#206](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/206)) ([#225](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/225)).
+
## [2.7.0] - 2026-09-11
### Added
diff --git a/styles/quantecon.css b/styles/quantecon.css
index 13509b0b8..1369f8128 100644
--- a/styles/quantecon.css
+++ b/styles/quantecon.css
@@ -326,6 +326,28 @@
.article li > p {
margin: 0;
}
+
+ /* Images in a cell's outputs are centred in the content column, matching the
+ `.cell_output img` rule the lecture builds' stylesheet carries; an inline
+ matplotlib figure is narrower than the column, so without this it sits
+ against the left edge with all the spare width beside it.
+
+ A plot output is a bare `` that upstream renders with neither class
+ nor style, inside a container the upstream code marks by `data-name`, so
+ the container attribute is what there is to select on. `text-align` on the
+ container cannot do it: Preflight makes the image a block. Both container
+ names are needed -- the stored outputs are re-rendered into
+ `active-outputs-container` once a reader starts live compute, and the plot
+ should not jump left at that moment.
+
+ Only the side margins are set, so the vertical rhythm is untouched, and
+ only images: tables and text outputs stay left-aligned, as they are on the
+ lecture sites. */
+ [data-name='outputs-container'] img,
+ [data-name='active-outputs-container'] img {
+ margin-left: auto;
+ margin-right: auto;
+ }
}
@layer components {
diff --git a/tests/visual/__snapshots__/desktop-chrome-darwin/notebook.png b/tests/visual/__snapshots__/desktop-chrome-darwin/notebook.png
index 1419bd0bd..aa13cc0ea 100644
Binary files a/tests/visual/__snapshots__/desktop-chrome-darwin/notebook.png and b/tests/visual/__snapshots__/desktop-chrome-darwin/notebook.png differ
diff --git a/tests/visual/__snapshots__/desktop-chrome-linux/notebook.png b/tests/visual/__snapshots__/desktop-chrome-linux/notebook.png
index b3adfceb7..638da315f 100644
Binary files a/tests/visual/__snapshots__/desktop-chrome-linux/notebook.png and b/tests/visual/__snapshots__/desktop-chrome-linux/notebook.png differ
diff --git a/tests/visual/__snapshots__/mobile-chrome-darwin/notebook.png b/tests/visual/__snapshots__/mobile-chrome-darwin/notebook.png
index 0c42f8e05..d95058d46 100644
Binary files a/tests/visual/__snapshots__/mobile-chrome-darwin/notebook.png and b/tests/visual/__snapshots__/mobile-chrome-darwin/notebook.png differ
diff --git a/tests/visual/__snapshots__/mobile-chrome-linux/notebook.png b/tests/visual/__snapshots__/mobile-chrome-linux/notebook.png
index 0615cf580..6336464e7 100644
Binary files a/tests/visual/__snapshots__/mobile-chrome-linux/notebook.png and b/tests/visual/__snapshots__/mobile-chrome-linux/notebook.png differ
diff --git a/tests/visual/fixture/notebook.ipynb b/tests/visual/fixture/notebook.ipynb
index cd2ffd450..9d9997235 100644
--- a/tests/visual/fixture/notebook.ipynb
+++ b/tests/visual/fixture/notebook.ipynb
@@ -68,6 +68,28 @@
"source": [
"raise ValueError(\"a deliberate error to render the traceback\")"
]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "image/png": "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",
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "fig, ax = plt.subplots()\n",
+ "ax.plot(x, y)\n",
+ "plt.show()"
+ ]
}
],
"metadata": {
diff --git a/tests/visual/theme.spec.ts b/tests/visual/theme.spec.ts
index 47a63d784..c283038d1 100644
--- a/tests/visual/theme.spec.ts
+++ b/tests/visual/theme.spec.ts
@@ -523,6 +523,57 @@ test.describe("Meta/SEO and notebook output", () => {
await expect(fold.locator("pre.jupyter-error")).toBeVisible();
await expect(fold.locator("pre.jupyter-error")).toContainText("a deliberate warning on stderr");
});
+
+ // Plot outputs are centred in the content column, as they are on the lecture
+ // sites; an inline figure narrower than the column would otherwise sit
+ // against its left edge. On the thebe-enabled fixture, whose notebook.ipynb
+ // carries the stored `image/png` cell -- the no-thebe copy does not.
+ test("output-image-centred", async ({ page }, testInfo) => {
+ await page.goto("/notebook", { waitUntil: "domcontentloaded" });
+ await settle(page);
+ const box = await page.evaluate(() => {
+ const img = document.querySelector('[data-name="outputs-container"] img');
+ if (!img) return null;
+ const container = img.closest('[data-name="outputs-container"]')!;
+ const i = img.getBoundingClientRect();
+ const c = container.getBoundingClientRect();
+ return {
+ left: i.left - c.left,
+ right: c.right - i.right,
+ imageWidth: i.width,
+ containerWidth: c.width,
+ scrollWidth: document.documentElement.scrollWidth,
+ clientWidth: document.documentElement.clientWidth,
+ };
+ });
+ expect(box, "the fixture notebook should render an image output").not.toBeNull();
+ // Never wider than the column it sits in, at either viewport.
+ expect(box!.imageWidth).toBeLessThanOrEqual(box!.containerWidth + 1);
+ // The same 1px allowance as the gaps below: both widths are integers
+ // rounded from sub-pixel layout, so a page that fits exactly can still
+ // report one more pixel of scrollWidth than clientWidth. The two values are
+ // returned rather than a boolean so a failure names them -- and the sweep
+ // across 1280/1300/1328px in `outline-within-viewport` is what actually
+ // guards page overflow; this is a sanity check on the page holding an image.
+ expect(box!.scrollWidth, "no horizontal page overflow").toBeLessThanOrEqual(
+ box!.clientWidth + 1
+ );
+ if (testInfo.project.name === "desktop-chrome") {
+ // Desktop has room to spare, so the gaps must match.
+ expect(box!.left).toBeGreaterThan(1);
+ expect(Math.abs(box!.left - box!.right)).toBeLessThanOrEqual(1);
+ }
+ // Text outputs are untouched: only images are centred, as on the lecture
+ // sites, where a DataFrame table stays left-aligned.
+ const stream = await page.evaluate(() => {
+ const pre = document.querySelector('[data-name="outputs-container"] pre');
+ if (!pre) return null;
+ const container = pre.closest('[data-name="outputs-container"]')!;
+ return pre.getBoundingClientRect().left - container.getBoundingClientRect().left;
+ });
+ expect(stream, "the fixture notebook should render a text output").not.toBeNull();
+ expect(Math.abs(stream!)).toBeLessThanOrEqual(1);
+ });
});
test.describe("Site options reach the theme", () => {