Skip to content

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, …) #14808

Description

@x1o

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...

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workinglayout

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions