Skip to content

Pages, rows and panels

A page spec is a page-level block of settings followed by rows:. Each row is a list of panels:, laid out left to right. That is the whole layout model — there are no nested grids and no free-form panel shapes.

title: "Optional page title"
page: A4
library: "../characters"

rows:
  - panels: [ … ]      # first row, top of the page
  - panels: [ … ]      # second row, below it

The page

Key Default Meaning
page A4 A4, A5, letter, or [width_mm, height_mm]
bg #ffffff Paper colour, behind everything
px_per_mm 4 Raster scale. Only affects .png output
margin_mm 12 White border around the whole grid
gutter_mm 5 Gap between panels, and between rows
title — A bold caption strip across the top
title_style — {font_size, color, font}

Millimetres are the one unit in the spec, and they only appear here — paper is physical, so page geometry is too. Everything inside a panel is a fraction.

page: A4                # 210 × 297 mm
page: [190, 95]         # a custom landscape strip
px_per_mm: 6            # a PNG at 1140 × 570

px_per_mm is a raster knob only. A .pdf is true vector and ignores it entirely; raise it when you want a bigger PNG, not when you want better print quality.

Where a title eats space

A title: takes its font size plus 14 px off the top of the grid, so the panels start lower. It is one strip for the whole page — there is no per-row heading.

Rows

Rows divide the vertical space left after margins, the title and the gutters.

rows:
  - height: 2          # this row is twice as tall as…
    panels: [ … ]
  - height: 1          # …this one
    panels: [ … ]

height is a relative weight, not a unit. Omit it and it is 1, so a page of plain rows splits the height evenly.

For a row that must be a specific size, use height_mm instead:

rows:
  - height_mm: 40      # exactly 40 mm tall
    panels: [ … ]
  - height: 1          # …and the weighted rows share whatever is left
    panels: [ … ]

Fixed rows are measured out first; the weighted rows divide the remainder. If every row is fixed and they do not add up to the page, the leftover space at the bottom simply stays blank.

Rows sized by their pictures

When the panels are raster images, height: auto sizes the row from the art itself: each panel wants its image's aspect ratio at the panel's width (after any crop:), plus its caption band, and the row is as tall as its hungriest panel.

One full-width cropped image, then two images squeezed and anchored to the top and bottom

# `height: auto` rows take their height from the image (after `crop:`) plus
# the caption band. Too tall for the page together, so the art is squeezed and
# `fit: cover` crops the rest — `at:` picks which edge survives.
page: [120, 95]
px_per_mm: 5
margin_mm: 5
gutter_mm: 4
library: "../../examples/pes/characters"
caption_style: {font_size: 12}

rows:
  - height: auto
    panels:
      - caption: "crop: {top: 60, bottom: 40} — sky and ground trimmed first"
        image: {src: "raster-art/backdrop.png", crop: {top: 60, bottom: 40}}

  - height: auto
    panels:
      - caption: "at: t — squeezed, keeps the top"
        image: {src: "raster-art/backdrop.png", at: t}
      - caption: "at: b — keeps the bottom"
        image: {src: "raster-art/backdrop.png", at: b}

If the auto rows do not fit the page, their art is scaled down by one common factor until they do; caption bands never shrink, so the text stays legible and the numbers add up. fit: cover then crops the excess from every squeezed picture — from the edge the image's at: does not name, centred by default. Squeezing crops a little off each picture; when one picture has empty space to spare, trimming it with crop: first means the others lose less.

Fixed (height_mm) rows are measured out before the auto rows; weighted rows share whatever the auto rows leave, which may be nothing. A height: auto row needs at least one panel with an image: to measure.

To paginate a long story, ask how hard a candidate page squeezes before you add another row to it:

from comicforge import page_squeeze
page_squeeze(spec)   # 1.0 = fits as is; 0.9 = every picture loses 10% of its height

A generator can then keep adding rows while the squeeze stays above whatever it tolerates, and start a new page when it would not.

Panels

Inside a row, width works exactly like height does for rows — a relative weight sharing the row's width after gutters.

Two rows: a wide panel beside a narrow one, then three equal panels

# Rows stack down the page; panels run left to right inside a row.
# `height` and `width` are relative weights, not units.
page: [180, 120]
px_per_mm: 5
margin_mm: 8
gutter_mm: 5
library: "../../examples/pes/characters"

rows:
  - height: 2                       # this row gets 2/3 of the usable height
    panels:
      - width: 2                    # …and 2/3 of the row's width
        bg: "#fdf6e3"
        caption: "row 0 · col 0 — width 2"
      - bg: "#eef4fb"
        caption: "row 0 · col 1"
  - height: 1                       # …the remaining 1/3
    panels:
      - bg: "#f6f0fb"
        caption: "row 1 · col 0"
      - bg: "#f0f8f2"
        caption: "row 1 · col 1"
      - bg: "#fbeff0"
        caption: "row 1 · col 2"

The first row's panels are weighted 2 and 1, so they take two thirds and one third of the width. The second row's three panels have no width at all, so they are equal. Vertically, the first row's height: 2 against the second's height: 1 splits the grid two-to-one.

Panel keys

Key Meaning
width Relative width weight inside the row (default 1)
bg Flat background colour (default #fbfaf6, a warm off-white)
frame Outline override for this panel — see Captions and frames
caption Narration band under the art, inside the frame
scene A scene background
image A raster background
actors Characters placed in the panel
pixel Pixel-art sprites
bubbles Speech, thought and shout bubbles

Any other key in a panel is a typo, and cmf validate says so. The renderer will not — it ignores what it does not recognise, so imge: costs you a background and no error message.

Panel contents are fractional

Every coordinate inside a panel is a fraction of that panel: x from left to right, y from top to bottom, and scale as a fraction of the panel's height.

That means panel contents survive layout changes. Re-weight a row, switch from A4 to a landscape strip, add a caption band that shrinks the art box — the figures stay in the same relative composition. Nothing has to be recomputed.

Looking at one panel

Once a page is full, re-rendering all of it to check one corner is a waste. Render the panel instead:

cmf panel pages/strip.yaml --row 0 --col 1        # 0-indexed, row then column
cmf panel pages/strip.yaml --all -o panels/       # every panel into a directory
cmf panel pages/strip.yaml --row 1 --col 0 --scale 1.0
cmf panel pages/strip.yaml --row 1 --col 0 --on-page  # exactly as on the page

A single panel from the kosticka demo page, rendered on its own

cmf panel examples/pes/pages/kosticka.yaml --row 0 --col 1

--scale defaults to 0.5, which is deliberately low-resolution: the point is a quick look, not a final render. This is the right way to inspect one panel — rendering the whole page and cropping it with an image tool gives you the same picture for much more work.

On its own canvas a panel loses the outer half of its frame stroke and its rounded corners go transparent. When you need it to look exactly as it does on the page, add --on-page: the panel is cropped out of the page render with half a gutter around it.

--all also writes a layout.json with every panel's position on the page, and --layout prints the same JSON without rendering — enough to lay the panel images out again, say in an HTML preview. See panel.

Output formats

The extension decides the format:

cmf render pages/strip.yaml -o strip.png    # raster at px_per_mm
cmf render pages/strip.yaml -o strip.pdf    # true vector
cmf render pages/strip.yaml -o strip.svg    # the composed document

Omit -o entirely and the render lands in output/ with a timestamp, so successive attempts accumulate side by side.