quarto-dev / quarto-dev/quarto-cli
HTML: `cap-location: margin` puts figure captions in the margin but one grid row too low when the figure uses a left-extending `column` (`page-left`, `screen-inset-left`, …)
Nobody has claimed this yet.
- Dominant language
- JavaScript
- Stars
- 6k
- Forks
- 458
- Avg merge
- 1d 9h
- Merged PRs (30d)
- 41
Description
I have:
- searched the issue tracker for similar issues
- installed the latest version of Quarto CLI
- formatted my issue following the Bug Reports guide
Bug description
In HTML output, combining cap-location: margin with a left-extending column
(column: page-left, column: screen-inset-left, column: screen-left, …) on a figure
places the caption in the correct margin columns but in the next grid row, so it appears
below the figure instead of aligned with its top.
Tables are not affected: with the same column class, a table's margin caption is correctly
top-aligned. The asymmetry comes from where the column-* class ends up in the DOM (details below).
Possibly related: #4150, #8136, #3829.
Steps to reproduce
No engine needed; image.png can be any image.
---
title: "Margin caption test"
format:
html:
cap-location: margin
tbl-cap-location: margin
---
::: {#fig-left .column-page-left}

Figure in `column-page-left`: caption is in the margin, but below the figure.
:::
{#fig-body}
::: {#tbl-left .column-page-left}
| a | b |
|---|---|
| 1 | 2 |
Table in `column-page-left`: caption is correctly top-aligned in the margin.
:::
The same happens with a computational cell (#| column: page-left + #| fig-cap) and with
column: screen-inset-left.
Actual behavior
The caption is in the margin columns but shifted down by the height of the figure row.
Measured bounding boxes (Chromium 141, viewport 1600×1200, quarto 1.10.18):
| element | image x, y |
caption x, y |
|---|---|---|
#fig-left (column-page-left) |
232.5, 116.3 | 1152.5, 324.8 |
#fig-body (no column) |
527.5, 468.5 | 1152.5, 468.5 |
#tbl-left (column-page-left) |
232.5, 745.0 | 1152.5, 736.5 |
Caption y should match the figure y in the first row, as it does in rows 2 and 3.
Diagnosis
Generated markup for the figure case:
<figure class="quarto-float quarto-float-fig figure page-columns page-full">
<div aria-describedby="…" class="page-columns page-full"> <!-- wrapper -->
<img … class="figure-img column-page-left">
</div>
<figcaption class="… margin-caption">Figure 1: …</figcaption>
</figure>
The <figure> is a page-columns grid with two children. The relevant rules
(src/resources/formats/html/bootstrap/_bootstrap-rules.scss) are:
.page-full { grid-column: screen-start / screen-end !important; }.margin-caption { grid-column: body-end / page-end !important; }
So the wrapper<div>spansscreen-start → screen-end, i.e. the entire first grid row, and
sparse auto-placement has to push thefigcaptionto row 2 — hence "in the margin, but below".
Two things combine to produce this:
- For figures, the
column-*class is attached to the innermost element (the<img>), whereas
for tables it stays on the outer#tbl-…container
(src/resources/filters/layout/columns.lua,add_column_classes_and_attributes). ensureInGrid()insrc/format/html/format-html-bootstrap.ts(~L1178) then walks up from the
column-*element and addspage-columnsandpage-fullto every ancestor up to
#quarto-content. Because the class is on the<img>, the plain wrapper<div>inside
<figure>becomespage-fulland consumes the whole row.
In the table case,ensureInGrid()starts above the float container, so the inner wrapper keeps the
default.page-columns > *span (body-content-start / body-content-end) and the caption fits in
the same row — which is why tables look right.
Right-extending columns don't show the symptom because the caption cannot be placed in the margin
there anyway (cf. #4150).
Workaround
Taking the wrapper out of the grid restores top alignment; the image keeps its exact position and
size, since the nested grid used the same column template:
figure:has(> figcaption.margin-caption) > div.page-full {
display: contents;
}
Verified to fix column: page-left and column: screen-inset-left, and to leave body figures,
column: page-right, column: margin figures (caption stays below the image, as intended),
bottom captions and tables unchanged, including below the lg/md breakpoints where margin
captions fall back into the body flow.
Expected behavior
The figure's margin caption starts at the top of the figure, as it does for a body figure and
as it does for a table in the same column.
Your environment
- Quarto 1.10.18, linux-amd64
- Chromium 141.0.7390.37 (also reproducible in any browser, the layout is pure CSS grid)
Quarto check output
% quarto check
Quarto 1.10.18
[✓] Checking environment information...
Quarto cache location: /Users/[...]/Library/Caches/quarto
[✓] Checking versions of quarto binary dependencies...
Pandoc version 3.10.0: OK
Dart Sass version 1.101.0: OK
Deno version 2.7.14: OK
Typst version 0.15.1: OK
[✓] Checking versions of quarto dependencies......OK
[✓] Checking Quarto installation......OK
Version: 1.10.18
Path: /Applications/Positron.app/Contents/Resources/app/quarto/bin
[✓] Checking tools....................OK
TinyTeX: (not installed)
Chrome Headless Shell: (not installed)
VeraPDF: (not installed)
[✓] Checking LaTeX....................OK
Using: Installation From Path
Path: /Library/TeX/texbin
Version: 2026
[✓] Checking Chrome Headless....................OK
Using: Chrome found on system
Path: /Applications/Google Chrome.app/Contents/MacOS/Google Chrome
Source: MacOS known location
[✓] Checking basic markdown render....OK
[✓] Checking R installation...........OK
Version: 4.5.2
Path: /Library/Frameworks/R.framework/Versions/4.5-arm64/Resources
LibPaths:
- /Users/[...]/Library/R/arm64/4.5/library
- /Library/Frameworks/R.framework/Versions/4.5-arm64/Resources/library
knitr: 1.50
rmarkdown: 2.30
[✓] Checking Knitr engine render......OK
[✓] Checking Python 3 installation....OK
Version: 3.14.0
Path: /Users/[...]/.venv/bin/python3
Jupyter: 5.9.1
Kernels: python3
(/) Checking Jupyter engine render....[IPKernelApp] WARNING | Kernel is running over TCP without encryption. All communication (including code and outputs) is sent in plain text and is susceptible to eavesdropping. Use IPC transport or launch with kernel manager-provisioned CurveZMQ keys to enable transport encryption.
[✓] Checking Jupyter engine render....OK
[✓] Checking Julia installation...
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Render the supplied QMD reproduction, then inspect ensureInGrid() in src/format/html/format-html-bootstrap.ts, add_column_classes_and_attributes in src/resources/filters/layout/columns.lua, and the grid rules in src/resources/formats/html/bootstrap/_bootstrap-rules.scss. Done means left-extending figure captions align with the figure’s top while body figures, right and bottom captions, tables, and responsive fallbacks remain unchanged.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- css, html, lua, scss, typescript
- Domain
- frontend, web-dev
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100