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`, …)

Open
#14,808 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug layout
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}
![](image.png)
 
Figure in `column-page-left`: caption is in the margin, but below the figure.
:::
 
![Body figure: caption is correctly top-aligned in the margin.](image.png){#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> spans screen-start → screen-end, i.e. the entire first grid row, and
    sparse auto-placement has to push the figcaption to row 2 — hence "in the margin, but below".

Two things combine to produce this:

  1. 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).
  2. ensureInGrid() in src/format/html/format-html-bootstrap.ts (~L1178) then walks up from the
    column-* element and adds page-columns and page-full to every ancestor up to
    #quarto-content. Because the class is on the <img>, the plain wrapper <div> inside
    <figure> becomes page-full and 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.