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", () => {