Bubbles¶
A bubble is dialogue: an outline, word-wrapped text, and a tail pointing at whoever is talking. Bubbles are drawn last, on top of everything else in the panel.
bubbles:
- text: "Ahoj!"
kind: speech # speech (default) | thought | shout
speaker: tom # place it above this actor and aim the tail at their head
Almost always, that is all you write. Coordinates are optional and usually unnecessary.
The three kinds¶
# Three bubble kinds. `speaker:` places the bubble above that actor and aims
# the tail at their head — no coordinates needed.
page: [180, 75]
px_per_mm: 5
margin_mm: 6
gutter_mm: 4
library: "../../examples/pes/characters"
rows:
- panels:
- actors: [{char: tom, face: happy, arms: wave, x: 0.5, y: 0.72, scale: 0.72}]
bubbles:
- {text: "Ahoj!", kind: speech, speaker: tom}
- actors: [{char: tom, face: sad, x: 0.5, y: 0.72, scale: 0.72}]
bubbles:
- {text: "Kde je ta kost?", kind: thought, speaker: tom, max_chars: 12}
- actors: [{char: tom, face: angry, arms: hips, x: 0.5, y: 0.72, scale: 0.72}]
bubbles:
- {text: "Tady!", kind: shout, speaker: tom}
kind |
Drawn as |
|---|---|
speech |
A rounded rectangle with a slim tail (the default) |
thought |
An ellipse with a trail of shrinking dots |
shout |
A spiky burst |
The tail drops from the bubble's underside toward its target, and its tip is cut
short so it never lands on the figure's face. tail:
picks another look — a curved comic tail, a line all the way, or none — and
tail_from picks where it leaves the bubble.
Placing a bubble¶
Four things can decide where a bubble goes. In order of precedence:
x/y— explicit panel fractions for its centre.at— a corner or edge to hug.speaker— line it up over that actor, or, on a panel withspeakers:, place it in reading order on that speaker's side.- Nothing — centred horizontally, stacked from the top.
speaker: — the usual case¶
speaker: tom finds the first actor in the panel whose char is tom, aligns
the bubble to that actor's x, and aims the tail at the actor's head — computed
from their y and scale, so it keeps pointing at the right place when you
move or resize them.
actors: [{char: tom, face: happy, x: 0.35, y: 0.7, scale: 0.8}]
bubbles: [{text: "Ahoj!", speaker: tom}]
A speaker naming neither an actor nor a speakers: point in the panel is
caught by cmf validate.
speakers: — speaker points without actors¶
A panel over raster art has no actors for speaker: to find. Give the panel
the head of each speaking figure instead, in panel fractions:
image: "art/03.png"
speakers:
ema: [0.72, 0.30]
jan: [0.25, 0.42]
bubbles:
- {text: "Kde jsi byl?", speaker: ema}
- {text: "Venku.", speaker: jan}
speaker: ema now resolves to that point: the tail aims there (unless the
bubble has a to:). An actor with the same char wins over a point of the same
name, so the two can share a panel.
On a panel with speakers:, every bubble that has a speaker and none of
x, y or at is placed in reading order:
- it goes on its speaker's side of the panel — over the speaker, or hugging that side's edge;
- its top sits clearly below the previous bubble's top — at least half that bubble's height lower — so the panel reads top to bottom, then left to right;
- among the spots that satisfy that, it takes one that keeps clear of every speaker point and does not cross an earlier bubble's tail, staying as close to its speaker and as high up as it can.
So a right-hand speaker who talks first gets the top right, and the reply goes lower left.
# Speaker points on raster art: no actors, just where each head (or voice)
# is, in panel fractions. Bubbles with a `speaker` and no x/y/at are placed in
# reading order on their speaker's side, and their tails aim at the points.
# `height: auto` rows keep the whole picture, so the points match the image.
page: [180, 136]
px_per_mm: 6
margin_mm: 6
gutter_mm: 4
library: "../../examples/pes/characters"
bubble_style: {font_size: 12}
rows:
- height: auto
panels:
- caption: "speakers: — the right-hand voice first"
image: "raster-art/backdrop.png"
speakers:
door: [0.785, 0.78]
hen: [0.42, 0.78]
bubbles:
- {text: "Slepice, domů!", speaker: door, max_chars: 14}
- {text: "Ještě chvilku!", speaker: hen, max_chars: 14}
- caption: "tail: line"
image: "raster-art/backdrop.png"
speakers:
sun: [0.84, 0.21]
hen: [0.42, 0.78]
bubbles:
- {text: "Prší, schovej se.", speaker: sun, max_chars: 12, tail: line}
- {text: "Mně to nevadí.", speaker: hen, max_chars: 12, tail: line}
- height: auto
panels:
- caption: "tail_from: {edge: r, pos: 0.8}"
image: "raster-art/backdrop.png"
speakers:
hen: [0.42, 0.78]
bubbles:
- {text: "Kvok!", speaker: hen, at: tl, tail_from: {edge: r, pos: 0.8}}
- caption: "a thought with a line is dotted"
image: "raster-art/backdrop.png"
speakers:
hen: [0.42, 0.78]
bubbles:
- {text: "Kdy už přestane pršet?", kind: thought, speaker: hen, max_chars: 14, tail: line}
Bubbles with x/y or at keep their manual place — and still count as the
"previous bubble" for the next one. Panels without speakers: lay out exactly
as before.
Stacking — no coordinates at all¶
A bubble with no y is placed below the measured bottom of the bubbles already
drawn, starting near the panel top. Because it is measured, not guessed, the
stack never overlaps however long the lines run.
# No x, no y: bubbles stack downward by their measured height, so however
# long the lines get they never overlap — and they stay inside the panel.
page: [110, 85]
px_per_mm: 6
margin_mm: 8
library: "../../examples/pes/characters"
bubble_style: {font_size: 13}
rows:
- panels:
- bg: "#fdfaf3"
bubbles:
- {text: "First line.", kind: speech}
- {text: "Then a longer second line that has to wrap.", kind: speech, max_chars: 24}
- {text: "And a third.", kind: speech}
A bubble with no x is centred — or aligned to its speaker, if it has one. So a
panel with no actors at all, like a
raster-backed panel, can carry its dialogue in order and omit every
coordinate.
Whatever the placement, every bubble is finally nudged to stay inside the panel. One too wide or too tall to fit is centred instead.
at: — hugging a corner¶
# `at:` hugs a corner or edge — the nine anchors below. Each column (l / c / r)
# keeps its own top and bottom stack, so `tl` and `tr` sit side by side.
page: [120, 95]
px_per_mm: 6
margin_mm: 8
library: "../../examples/pes/characters"
bubble_style: {font_size: 13}
rows:
- panels:
- bg: "#fdfaf3"
bubbles:
- {text: "tl", kind: speech, at: tl}
- {text: "t", kind: speech, at: t}
- {text: "tr", kind: speech, at: tr}
- {text: "l", kind: speech, at: l}
- {text: "c", kind: speech, at: c}
- {text: "r", kind: speech, at: r}
- {text: "bl", kind: speech, at: bl}
- {text: "b", kind: speech, at: b}
- {text: "br", kind: speech, at: br}
The nine anchors are three columns (l, c, r) crossed with three edges
(t, c, b):
| left | centre | right | |
|---|---|---|---|
| top | tl |
t |
tr |
| middle | l |
c |
r |
| bottom | bl |
b |
br |
Each column keeps its own top and bottom stack, and a bubble only moves past the
ones it would actually overlap. So tl and tr sit side by side when there is
room, and bl climbs upward from the bottom edge. c is the exception: it
centres and does not stack.
at: is the tool of choice over busy art — put the dialogue in the corners and
keep it off the faces.
to: — aiming the tail by hand¶
Use to when the tail should point at something that is not an actor — an
off-panel voice, a radio, a hole in the ground. With a speaker and no to,
the head position is worked out for you.
tail_from: — where the tail leaves¶
By default the tail leaves the edge facing its target, slid toward the target
but kept within the middle of that edge. tail_from overrides either half:
- {text: "Kvok!", speaker: hen, at: tl, tail_from: r} # right edge, auto spot
- {text: "Kvok!", speaker: hen, at: tl, tail_from: 0.8} # auto edge, 80% along it
- {text: "Kvok!", speaker: hen, at: tl, tail_from: {edge: r, pos: 0.8}} # both
edge is t, b, l or r; pos runs from 0 (left end of a top/bottom
edge, top end of a side) to 1. Set it on a bubble, or page-wide in
bubble_style.
tail: — the tail's look¶
bubble_style: {tail: curve} # every bubble on the page
# …or per bubble:
- {text: "Mně to nevadí.", speaker: hen, tail: line, tail_bend: -0.5}
# Tail styles: `tail:` picks the look whatever the bubble kind; `tail_bend`
# bows a curve or a line (unset: a gentle bend away from the neighbour).
page: [180, 130]
px_per_mm: 6
margin_mm: 6
gutter_mm: 4
library: "../../examples/pes/characters"
bubble_style: {font_size: 13}
rows:
- panels:
- caption: "tail: wedge (default)"
bg: "#fbfaf6"
actors:
- {char: tom, face: happy, x: 0.28, y: 0.72, scale: 0.6}
- {char: tom, face: neutral, x: 0.72, y: 0.72, scale: 0.6, flip: true}
bubbles:
- {text: "Ahoj!", speaker: tom, at: tl, to: [0.28, 0.48]}
- {text: "Nazdar.", at: tr, to: [0.72, 0.48]}
- caption: "tail: curve — auto bend"
bg: "#fbfaf6"
actors:
- {char: tom, face: happy, x: 0.28, y: 0.72, scale: 0.6}
- {char: tom, face: neutral, x: 0.72, y: 0.72, scale: 0.6, flip: true}
bubbles:
- {text: "Ahoj!", at: tl, to: [0.28, 0.48], tail: curve}
- {text: "Nazdar.", at: tr, to: [0.72, 0.48], tail: curve}
- panels:
- caption: "tail: line — tail_bend: 0.8, then auto"
bg: "#fbfaf6"
actors:
- {char: tom, face: happy, x: 0.28, y: 0.72, scale: 0.6}
- {char: tom, face: neutral, x: 0.72, y: 0.72, scale: 0.6, flip: true}
bubbles:
- {text: "Ahoj!", at: tl, to: [0.28, 0.48], tail: line, tail_bend: 0.8}
- {text: "Nazdar.", at: tr, to: [0.72, 0.48], tail: line}
- caption: "thought + curve, shout + none"
bg: "#fbfaf6"
actors:
- {char: tom, face: happy, x: 0.28, y: 0.72, scale: 0.6}
- {char: tom, face: angry, x: 0.72, y: 0.72, scale: 0.6, flip: true}
bubbles:
- {text: "Hmm…", kind: thought, at: tl, to: [0.28, 0.48], tail: curve}
- {text: "TICHO!", kind: shout, at: tr, to: [0.72, 0.48], tail: none}
The look is independent of the bubble kind:
tail |
Drawn as |
|---|---|
wedge |
The default: a slim, straight tail, cut short of the figure |
curve |
The classic comic tail: a longer tapered wedge with curved sides, joined to the bubble without a line across its base |
line |
A thin ink line from the bubble to just short of the speaker, on a paper-coloured halo so it stays legible over busy art |
none |
No tail. The bubble is still placed by its speaker |
A thought keeps its trail of circles along whatever path the tail takes — three
shrinking circles for wedge and curve, a dotted trail for line.
tail_bend bows a curve or line through one control point: from -1 to
1, 0 straight, positive to the right of the tail's direction as it runs from
bubble to speaker. Leave it unset and the tail bends gently away from the
nearest other bubble in the panel — or from the panel centre, when the bubble is
alone — so neighbouring tails part instead of crossing. A wedge is always
straight.
tail_gap (default 12 px) is how far short of the target a line stops.
Layout warnings¶
cmf validate lays out every panel's bubbles and warns — without failing —
about things that render but read badly:
- a bubble out of reading order: its top above the previous bubble's, or level with it but further left;
- two tails that cross (the lines from each bubble to its target);
- a bubble covering a speaker point — an actor's head or a
speakers:point.
pages/strip.yaml: ok, 1 warning(s)
- warning: r0c1: bubble 1 "Venku." sits above bubble 0 "Kde jsi byl?" but comes after it (out of reading order)
Pass --strict to make warnings fail the check. The same geometry is
available from Python, without rendering, via
bubble_layout.
Text and wrapping¶
| Key | Default | Meaning |
|---|---|---|
text |
required | The line. Required — validate flags a bubble without it |
max_chars |
22 |
Wrap width, in characters |
fs |
from bubble_style |
Font size in px for this bubble |
uppercase |
from bubble_style |
Force this bubble's text to caps |
tail / tail_bend / tail_gap / tail_from |
from bubble_style |
This bubble's tail |
Wrapping breaks on spaces at max_chars, and the outline is sized from an
estimate of the rendered width — capitals are measured wider than lowercase,
which is why uppercase is safe to use. It is still an estimate: check long
lines in the render, and reach for max_chars or em when a line crowds its
outline.
Page-wide lettering¶
bubble_style at the top level of the spec sets the look of every bubble on the
page. Per-bubble keys still win.
# Page-wide bubble look. Per-bubble keys (fs, uppercase, max_chars) win.
page: [170, 70]
px_per_mm: 6
margin_mm: 6
gutter_mm: 4
library: "../../examples/pes/characters"
bubble_style:
uppercase: true
font_size: 14
radius: 6
stroke: "#b3402f"
stroke_width: 4
ink: "#b3402f"
fill: "#fff8ee"
rows:
- panels:
- actors: [{char: tom, face: laugh, arms: thumbsup, x: 0.5, y: 0.66, scale: 0.5}]
bubbles:
- {text: "Page style", kind: speech, speaker: tom}
- actors: [{char: tom, face: wink, x: 0.5, y: 0.66, scale: 0.5}]
bubbles:
- {text: "per-bubble override", kind: speech, speaker: tom, uppercase: false, fs: 11}
| Key | Default | Meaning |
|---|---|---|
font |
DejaVu Sans stack | Font family |
font_size |
16 |
Text size in px |
pad |
14 |
Inset from text to outline |
radius |
18 |
Speech-bubble corner radius (capped at half the height) |
stroke |
#21304a |
Outline colour |
stroke_width |
3 |
Outline weight |
fill |
#ffffff |
Bubble interior |
ink |
#21304a |
Text colour |
uppercase |
false |
Render all bubble text in caps |
em |
1.0 |
Width scale of the text measure |
tail |
wedge |
wedge, curve, line or none |
tail_bend |
auto | Bow of a curve / line tail, -1..1 |
tail_gap |
12 |
Px a line tail stops short of its target |
tail_from |
auto | Where tails leave: edge, position, or {edge, pos} |
uppercase: true is classic comic lettering, and it costs nothing — the
measurement accounts for the wider glyphs.
em is the escape hatch for a font whose glyphs are narrower than the default
measure assumes: set em: 0.8 for a narrow handwriting face and the outlines
hug the words instead of floating around them.
Common shapes¶
# a two-hander: each bubble anchored to its own speaker
actors:
- {char: tom, face: angry, arms: hips, x: 0.28, y: 0.68, scale: 0.8}
- {char: bara, pose: sit, face: sad, x: 0.74, y: 0.78, scale: 0.45}
bubbles:
- {text: "Kdo to snědl?", kind: shout, speaker: tom, at: tl}
- {text: "Já ne…", kind: speech, speaker: bara, at: br}
# an internal monologue over a full-bleed background, no actors needed
bubbles:
- {text: "Byla to dlouhá cesta.", kind: thought, at: tl, max_chars: 18}
# an off-panel voice
bubbles:
- {text: "Večeře!", kind: speech, at: tr, to: [1.0, 0.5]}
# raster art: mark the heads, let the layout do the rest
image: "art/03.png"
speakers: {ema: [0.72, 0.3], jan: [0.25, 0.42]}
bubbles:
- {text: "Kde jsi byl?", speaker: ema}
- {text: "Venku.", speaker: jan}