comicforge 0.0.4__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {comicforge-0.0.4 → comicforge-0.2.0}/.claude/skills/comicforge/SKILL.md +101 -12
- {comicforge-0.0.4 → comicforge-0.2.0}/.claude/skills/comicforge/reference.md +38 -7
- {comicforge-0.0.4 → comicforge-0.2.0}/.github/workflows/ci.yml +2 -2
- {comicforge-0.0.4 → comicforge-0.2.0}/.github/workflows/release.yml +1 -1
- {comicforge-0.0.4 → comicforge-0.2.0}/PKG-INFO +2 -2
- {comicforge-0.0.4 → comicforge-0.2.0}/comicforge/__init__.py +1 -1
- comicforge-0.2.0/comicforge/bubbles.py +232 -0
- comicforge-0.2.0/comicforge/caption.py +91 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/comicforge/cli.py +1 -3
- comicforge-0.2.0/comicforge/raster.py +149 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/comicforge/render.py +307 -74
- {comicforge-0.0.4 → comicforge-0.2.0}/comicforge/validate.py +80 -6
- {comicforge-0.0.4 → comicforge-0.2.0}/pyproject.toml +1 -1
- {comicforge-0.0.4 → comicforge-0.2.0}/skills/comicforge/SKILL.md +101 -12
- {comicforge-0.0.4 → comicforge-0.2.0}/skills/comicforge/reference.md +38 -7
- comicforge-0.2.0/tests/test_bubbles.py +62 -0
- comicforge-0.2.0/tests/test_caption.py +68 -0
- comicforge-0.2.0/tests/test_raster.py +241 -0
- comicforge-0.2.0/tests/test_render.py +262 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/uv.lock +59 -55
- comicforge-0.0.4/comicforge/bubbles.py +0 -133
- comicforge-0.0.4/tests/test_bubbles.py +0 -32
- comicforge-0.0.4/tests/test_render.py +0 -117
- {comicforge-0.0.4 → comicforge-0.2.0}/.github/dependabot.yml +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/.gitignore +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/.python-version +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/CLAUDE.md +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/README.md +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/comicforge/__main__.py +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/comicforge/inspire.py +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/comicforge/library.py +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/comicforge/pixelart.py +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/comicforge/scaffold.py +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/comicforge/scene.py +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/docs/starting-a-project.md +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/examples/README.md +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/examples/README.md.old +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/bara/character.yaml +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/bara/face-happy.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/bara/face-neutral.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/bara/poses/sit/base.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/bara/poses/sit/pose.yaml +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/bara/poses/walk/base.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/bara/poses/walk/pose.yaml +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/tom/arms-crossed.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/tom/arms-down.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/tom/arms-hips.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/tom/arms-point.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/tom/arms-thumbsup.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/tom/arms-wave.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/tom/base.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/tom/character.yaml +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/tom/face-angry.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/tom/face-happy.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/tom/face-laugh.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/tom/face-neutral.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/tom/face-sad.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/tom/face-surprised.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/tom/face-wink.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/pages/dvur-scene.yaml +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/pages/kosticka.yaml +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/pages/slepice.yaml +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/pixel/bone.yaml +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/pixel/heart.yaml +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/pixel/star.yaml +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/pixel/sun.yaml +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/references.yaml +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/scenes/dvur/base.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/scenes/dvur/scene.yaml +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/scenes/dvur/weather-clear.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/scenes/dvur/weather-rain.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/scenes/pokoj/base.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/scenes/pokoj/scene.yaml +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/theme.yaml +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/poe_tasks.toml +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/tests/__init__.py +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/tests/conftest.py +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/tests/test_cli.py +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/tests/test_inspire.py +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/tests/test_library.py +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/tests/test_pixelart.py +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/tests/test_scaffold.py +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/tests/test_scene.py +0 -0
- {comicforge-0.0.4 → comicforge-0.2.0}/tests/test_validate.py +0 -0
|
@@ -42,13 +42,19 @@ needs no Python (see [`reference.md`](reference.md)).
|
|
|
42
42
|
pixel_dir: "../pixel"
|
|
43
43
|
```
|
|
44
44
|
3. Validate before rendering: `cmf validate mystrip.yaml` — checks every actor,
|
|
45
|
-
scene, pixel sprite, slot variant, pose, and bubble speaker
|
|
46
|
-
libraries without drawing anything, and lists *all* problems at
|
|
47
|
-
`render`, it flags mis-spelled keys (e.g. `fcae:`) that
|
|
48
|
-
ignores. Exits non-zero when anything is wrong. Works on page
|
|
45
|
+
scene, raster `image:`, pixel sprite, slot variant, pose, and bubble speaker
|
|
46
|
+
against the libraries without drawing anything, and lists *all* problems at
|
|
47
|
+
once. Unlike `render`, it flags mis-spelled keys (e.g. `fcae:`, `imge:`) that
|
|
48
|
+
render silently ignores. Exits non-zero when anything is wrong. Works on page
|
|
49
|
+
and `scene` specs.
|
|
49
50
|
4. Render:
|
|
50
51
|
- comic page: `cmf render mystrip.yaml -o out.pdf`
|
|
51
52
|
- single illustration: `cmf scene myscene.yaml -o out.png`
|
|
53
|
+
- one panel of a page (to iterate on a single panel without re-reading the
|
|
54
|
+
whole page): `cmf panel mystrip.yaml --row 0 --col 1 -o panel.png`
|
|
55
|
+
(0-indexed; defaults to row 0 col 0), or `--all` to write every panel into a
|
|
56
|
+
directory. This is the right way to inspect one panel — **do not** render the
|
|
57
|
+
full page and crop it with ImageMagick or other tooling.
|
|
52
58
|
- one character on its own (to eyeball a pose/expression):
|
|
53
59
|
`cmf character bara sit happy --library examples/pes/characters`
|
|
54
60
|
— extra args are bare pose/variant names or `key=value` (`pose=walk`,
|
|
@@ -69,21 +75,33 @@ Every position inside a panel is a **fraction 0..1 of that panel**:
|
|
|
69
75
|
|
|
70
76
|
```yaml
|
|
71
77
|
title: "Optional page title" # bold caption strip at the top
|
|
78
|
+
title_style: {font_size: 26, color: "#21304a"} # optional
|
|
72
79
|
type: page # page (default) | scene — see below
|
|
73
80
|
page: A4 # A4 | A5 | letter | [w_mm, h_mm]
|
|
81
|
+
bg: "#ffffff" # paper colour
|
|
74
82
|
px_per_mm: 4 # raster scale (vector PDF ignores it)
|
|
75
83
|
margin_mm: 14
|
|
76
84
|
gutter_mm: 6
|
|
85
|
+
frame: # panel outline, page-wide; a panel's own
|
|
86
|
+
width: 3.5 # `frame:` overrides. width 0 = no line
|
|
87
|
+
color: "#21304a"
|
|
88
|
+
radius: 10 # corner radius (also clips the art)
|
|
77
89
|
library: "../characters" # path to character dir
|
|
78
90
|
scenes_dir: "../scenes" # path to scenes dir (omit if no scenes used)
|
|
79
91
|
pixel_dir: "../pixel" # path to pixel-art dir (omit if inline only)
|
|
80
92
|
|
|
81
93
|
rows: # page is a stack of rows…
|
|
82
|
-
- height: 1.0 # relative row height (default 1)
|
|
94
|
+
- height: 1.0 # relative row height (default 1), or
|
|
95
|
+
# height_mm: 60 for a fixed height —
|
|
96
|
+
# weighted rows share what is left
|
|
83
97
|
panels: # …each row is a left→right list of panels
|
|
84
98
|
- width: 1.0 # relative panel width (default 1)
|
|
85
99
|
bg: "#fbfaf6" # optional flat panel background
|
|
100
|
+
frame: {width: 0} # optional per-panel outline override
|
|
101
|
+
caption: "Rain came." # narration band under the art, inside the
|
|
102
|
+
# frame; or {text:, max_chars:}
|
|
86
103
|
scene: dvur # optional scene background (see below)
|
|
104
|
+
image: "art/01.png" # optional raster background (see below)
|
|
87
105
|
actors: [ ... ] # characters (drawn back→front in list order)
|
|
88
106
|
pixel: [ ... ] # pixel-art sprites (drawn behind actors)
|
|
89
107
|
bubbles:[ ... ] # speech/thought/shout (drawn on top)
|
|
@@ -123,22 +141,62 @@ Confirm scenes/slots with `comicforge scenes --scenes <project>/scenes`.
|
|
|
123
141
|
Seeds in `examples/pes/`: `dvur` (farmyard, slot `weather: clear|rain`),
|
|
124
142
|
`pokoj` (room, no slots). Both live in `examples/pes/scenes/`.
|
|
125
143
|
|
|
144
|
+
### image (raster panel background)
|
|
145
|
+
|
|
146
|
+
When the panel art is a finished bitmap — a generated or photographed image
|
|
147
|
+
rather than a `scene:` built from SVG — point the panel at it with `image:`.
|
|
148
|
+
Everything else works unchanged: bubbles, actors and pixel art all compose on
|
|
149
|
+
top of it.
|
|
150
|
+
|
|
151
|
+
```yaml
|
|
152
|
+
image: "../refs/panels/01.png" # path, relative to the *spec file*
|
|
153
|
+
image: {src: "01.png", fit: contain} # or the long form, to pick the fit
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
- `fit: cover` (the default) scales the image to fill the panel and
|
|
157
|
+
centre-crops the overflow — a 3:2 image in a 4:3 panel crops rather than
|
|
158
|
+
distorts, exactly like a `scene:` background.
|
|
159
|
+
- `fit: contain` scales it to fit *inside* the panel; the leftover strips show
|
|
160
|
+
the panel's `bg:` colour.
|
|
161
|
+
- `.png`, `.jpg`/`.jpeg`, `.gif` and `.webp` are supported.
|
|
162
|
+
- The image is **embedded** as a base64 data URI, so an `.svg` or `.pdf` render
|
|
163
|
+
is one self-contained file that still works when moved. That also means the
|
|
164
|
+
output carries the full byte weight of every image on the page — expect large
|
|
165
|
+
files for a page of full-res panels.
|
|
166
|
+
- `image:` and `scene:` can coexist; the scene draws over the image.
|
|
167
|
+
|
|
168
|
+
A standalone illustration can be image-backed too — set `type: scene` with an
|
|
169
|
+
`image:` and no `scene:`, and the canvas is sized from the image's own pixel
|
|
170
|
+
dimensions times `scale:` (default `1`, i.e. one output px per image px).
|
|
171
|
+
|
|
172
|
+
**Where bubbles land on a raster panel.** There are no actors to anchor to, so
|
|
173
|
+
`speaker:` is meaningless — use `x`/`y`/`to` fractions, or leave them out. A
|
|
174
|
+
bubble with no `y` is placed below the measured bottom of the bubble before it,
|
|
175
|
+
starting near the panel top, so any number of bubbles of any length stack
|
|
176
|
+
without overlapping. A bubble with no `x` is horizontally centred. Every
|
|
177
|
+
bubble — explicit or automatic — is then nudged so it stays inside the panel
|
|
178
|
+
(one wider or taller than the panel is centred instead). A generated spec can
|
|
179
|
+
therefore emit dialogue in order and omit the coordinates entirely.
|
|
180
|
+
|
|
126
181
|
## Standalone illustration (no comic grid)
|
|
127
182
|
|
|
128
|
-
Render one
|
|
129
|
-
covers and single panels. Render with
|
|
183
|
+
Render one background filling the whole canvas with actors/bubbles on top —
|
|
184
|
+
good for covers and single panels. Render with
|
|
185
|
+
`comicforge scene file.yaml -o out.png`.
|
|
130
186
|
|
|
131
187
|
Set `type: scene` so the spec declares which command renders it: `render`
|
|
132
188
|
rejects a scene spec (and `scene` rejects a page spec) with a clear message
|
|
133
189
|
instead of a confusing crash, and `validate` checks the type matches the
|
|
134
190
|
structure. `type:` is optional — when omitted it's inferred (a top-level
|
|
135
|
-
`scene` with no `rows` == a scene) — but declare it
|
|
191
|
+
background — `scene` or `image` — with no `rows` == a scene) — but declare it
|
|
192
|
+
on standalone illustrations.
|
|
136
193
|
|
|
137
194
|
```yaml
|
|
138
195
|
title: "Optional"
|
|
139
196
|
type: scene
|
|
140
|
-
scene: {name: dvur, weather: clear}
|
|
141
|
-
scale: 3 # px per scene unit (canvas = scene viewbox × scale)
|
|
197
|
+
scene: {name: dvur, weather: clear} # …or `image: cover.png` instead
|
|
198
|
+
scale: 3 # px per scene unit (canvas = scene viewbox × scale);
|
|
199
|
+
# with `image:` it's output px per image px, default 1
|
|
142
200
|
library: "../characters"
|
|
143
201
|
scenes_dir: "../scenes"
|
|
144
202
|
actors: [ ... ] # same actor grammar; x/y/scale are canvas fractions
|
|
@@ -153,13 +211,44 @@ bubbles: [ ... ]
|
|
|
153
211
|
kind: speech # speech | thought | shout
|
|
154
212
|
speaker: tom # OPTIONAL: auto-place above this actor + aim the tail at
|
|
155
213
|
# their head. Prefer this over manual x/y/to.
|
|
214
|
+
at: tr # OPTIONAL corner/edge to hug: tl t tr l c r bl b br.
|
|
215
|
+
# Each column (l/c/r) stacks its own top and
|
|
216
|
+
# bottom, so tl + tr sit side by side.
|
|
156
217
|
x: 0.5 # OPTIONAL bubble centre (panel fraction); else from speaker
|
|
157
|
-
y: 0.18 # OPTIONAL; omit and bubbles stack downward
|
|
218
|
+
y: 0.18 # OPTIONAL; omit and bubbles stack downward by measured
|
|
219
|
+
# height, so they never overlap. All bubbles are
|
|
220
|
+
# kept inside the panel.
|
|
158
221
|
to: [0.4, 0.5] # OPTIONAL tail target (panel fraction); else speaker's head
|
|
159
222
|
max_chars: 22 # wrap width (optional)
|
|
160
|
-
fs: 16 # font size px (optional)
|
|
223
|
+
fs: 16 # font size px (optional; overrides bubble_style.font_size)
|
|
224
|
+
uppercase: true # OPTIONAL: force this bubble's text to CAPS
|
|
225
|
+
# (overrides bubble_style.uppercase)
|
|
161
226
|
```
|
|
162
227
|
|
|
228
|
+
**Page-wide bubble defaults** — set `bubble_style` at the *top level* of a
|
|
229
|
+
page or scene spec to style every bubble at once; per-bubble keys override:
|
|
230
|
+
|
|
231
|
+
```yaml
|
|
232
|
+
bubble_style:
|
|
233
|
+
uppercase: true # render all bubble text in CAPS (classic comic lettering)
|
|
234
|
+
font_size: 16 # default font size px for all bubbles
|
|
235
|
+
pad: 14 # text inset from the outline
|
|
236
|
+
radius: 18 # speech-bubble corner radius
|
|
237
|
+
stroke: "#21304a" # outline colour; stroke_width: 3; fill: "#ffffff"
|
|
238
|
+
ink: "#21304a" # text colour; font: "DejaVu Sans, sans-serif"
|
|
239
|
+
em: 1.0 # width scale of the text measure — 0.8 for a narrow
|
|
240
|
+
# handwriting font, so bubbles hug the words
|
|
241
|
+
rows: [ ... ]
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
**Captions** — narration, as opposed to a character's bubble — go in a band
|
|
245
|
+
along the bottom of the panel, inside the frame, separated from the art by a
|
|
246
|
+
hairline in the frame colour. The art box shrinks to make room, so bubble and
|
|
247
|
+
actor coordinates stay relative to the picture. Page-wide look via
|
|
248
|
+
`caption_style` (`font`, `font_size`, `ink`, `bg`, `pad`, `max_chars`,
|
|
249
|
+
`align: left|center`, `rule`, `uppercase`); `comicforge.caption.height(text, style)` tells
|
|
250
|
+
you how tall a band will be, for sizing rows.
|
|
251
|
+
|
|
163
252
|
`thought` draws an ellipse with a trail of dots; `shout` draws a spiky burst.
|
|
164
253
|
The tail is a slim line dropping from the bubble underside; its tip is capped
|
|
165
254
|
short so it never overlaps the figure.
|
|
@@ -435,10 +435,14 @@ renders a default-faced, default-posed actor with no error. `validate` flags:
|
|
|
435
435
|
- poses an actor asks for that the character lacks
|
|
436
436
|
- slot variants that don't exist for the chosen character+pose / scene
|
|
437
437
|
- actor / scene keys that aren't reserved and aren't a real slot (likely typos)
|
|
438
|
+
- panel keys the renderer doesn't know — `imge:` for `image:`, say
|
|
439
|
+
- raster `image:` files that are missing, unreadable, or of an unsupported type;
|
|
440
|
+
an `image:` with no `src`, an unknown image key, an unknown `fit`
|
|
438
441
|
- bubble `speaker` naming no actor in the panel; unknown bubble `kind`
|
|
439
442
|
- structural holes (a page with no `rows`, a row with no `panels`, a bubble with
|
|
440
|
-
no `text
|
|
441
|
-
(a `scene` spec carrying
|
|
443
|
+
no `text`, a `scene` spec with neither `scene:` nor `image:`); an unknown
|
|
444
|
+
`type:`, or a `type:` that contradicts the structure (a `scene` spec carrying
|
|
445
|
+
`rows`)
|
|
442
446
|
|
|
443
447
|
Exits `0` when the spec is sound, `1` (with a bulleted problem list) otherwise —
|
|
444
448
|
so it works in a pre-render check or CI.
|
|
@@ -588,14 +592,41 @@ Do **not** auto-vectorize the result — hand/LLM-author the SVG using it as a g
|
|
|
588
592
|
|
|
589
593
|
For the full spec grammar, see [SKILL.md](SKILL.md). Key points:
|
|
590
594
|
|
|
591
|
-
- **Page-level keys**: `title`, `
|
|
592
|
-
`
|
|
593
|
-
|
|
594
|
-
(
|
|
595
|
+
- **Page-level keys**: `title`, `title_style` (`font_size`, `color`, `font`),
|
|
596
|
+
`page` (A4/A5/letter/[w,h]), `bg` (paper colour), `px_per_mm`, `margin_mm`,
|
|
597
|
+
`gutter_mm`, `library`, `scenes_dir`, `pixel_dir`,
|
|
598
|
+
`frame` (panel outline: `width` in px, 0 for none; `color`; `radius` — the
|
|
599
|
+
corner radius also clips the art),
|
|
600
|
+
`bubble_style` (page-wide bubble look: `font`, `font_size`, `pad`, `radius`,
|
|
601
|
+
`stroke`, `stroke_width`, `fill`, `ink`, `uppercase`, `em` — width scale of
|
|
602
|
+
the text measure for narrower fonts)
|
|
603
|
+
- **Rows and panels**: `rows[].height` (relative weight) or `rows[].height_mm`
|
|
604
|
+
(fixed; weighted rows share the rest, all-fixed leaves the bottom blank),
|
|
605
|
+
`rows[].panels[].width` (relative weight), panel keys: `bg`, `frame`
|
|
606
|
+
(per-panel override), `caption` (narration band under the art, inside the
|
|
607
|
+
frame: a string or `{text, max_chars}`; page-wide `caption_style` with
|
|
608
|
+
`font`, `font_size`, `ink`, `bg`, `pad`, `max_chars`, `align`, `rule`, `uppercase`),
|
|
609
|
+
`scene`, `image`, `actors`, `pixel`, `bubbles` — `validate` flags any other
|
|
610
|
+
panel key as a typo
|
|
611
|
+
- **Image keys**: `image: path.png` or `image: {src:, fit:}` with
|
|
612
|
+
`fit: cover` (default; scale-to-fill + centre-crop) or `contain`
|
|
613
|
+
(fit inside + letterbox). The path resolves against the spec file's dir and
|
|
614
|
+
the image is embedded as a base64 data URI, so SVG/PDF output stays
|
|
615
|
+
self-contained. A `type: scene` spec can use `image:` in place of `scene:`,
|
|
616
|
+
and takes its canvas size from the image
|
|
595
617
|
- **Actor keys**: `char`, `pose` (optional; defaults to the character's default
|
|
596
618
|
pose), per-slot variant keys (`face`, `arms`, etc.), `x`, `y`, `scale`, `flip`
|
|
597
619
|
- **Bubble keys**: `text`, `kind` (speech/thought/shout), `speaker`, optional
|
|
598
|
-
`x`/`y`/`to`/`max_chars`/`fs`
|
|
620
|
+
`at`/`x`/`y`/`to`/`max_chars`/`fs`/`uppercase` (`fs` and `uppercase` override
|
|
621
|
+
the page-level `bubble_style`). Omit `y` and bubbles stack down by their
|
|
622
|
+
measured height without overlapping; omit `x` and they centre (or sit above
|
|
623
|
+
their `speaker`). `at` hugs a corner or edge instead — `tl`, `t`, `tr`, `l`,
|
|
624
|
+
`c`, `r`, `bl`, `b`, `br` — and each column keeps its own top and bottom
|
|
625
|
+
stack, so `tl` + `tr` sit side by side and `bl` climbs up from the bottom.
|
|
626
|
+
`to: [x, y]` (panel fractions) aims a tail without a speaker. Every bubble is
|
|
627
|
+
kept inside the panel — so a generated spec over raster panels, which has no
|
|
628
|
+
`speaker` to anchor to, can omit coordinates entirely, or place each with `at`
|
|
629
|
+
to keep it off the faces
|
|
599
630
|
- **Pixel keys**: `art` (library name) or `grid`+`palette`, plus `x`/`y`/`scale`
|
|
600
631
|
|
|
601
632
|
---
|
|
@@ -21,7 +21,7 @@ jobs:
|
|
|
21
21
|
run: sudo apt-get update && sudo apt-get install -y libcairo2
|
|
22
22
|
|
|
23
23
|
- name: Install uv
|
|
24
|
-
uses: astral-sh/setup-uv@
|
|
24
|
+
uses: astral-sh/setup-uv@v10.0.1
|
|
25
25
|
with:
|
|
26
26
|
enable-cache: true
|
|
27
27
|
|
|
@@ -70,7 +70,7 @@ jobs:
|
|
|
70
70
|
run: sudo apt-get update && sudo apt-get install -y libcairo2
|
|
71
71
|
|
|
72
72
|
- name: Install uv
|
|
73
|
-
uses: astral-sh/setup-uv@
|
|
73
|
+
uses: astral-sh/setup-uv@v10.0.1
|
|
74
74
|
with:
|
|
75
75
|
enable-cache: true
|
|
76
76
|
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Speech bubbles: speech, thought, and shout, with naive word-wrap + tails.
|
|
2
|
+
|
|
3
|
+
All coordinates here are absolute page px. A bubble is positioned by its centre
|
|
4
|
+
(bx, by); the tail points toward `tail` (also page px).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
from xml.sax.saxutils import escape
|
|
11
|
+
|
|
12
|
+
FONT = "DejaVu Sans, Helvetica, Arial, sans-serif"
|
|
13
|
+
INK = "#21304a"
|
|
14
|
+
|
|
15
|
+
# Every knob a bubble's look has. A page's `bubble_style:` overrides any of
|
|
16
|
+
# these for the whole page; a bubble's own keys override again.
|
|
17
|
+
DEFAULT_STYLE = {
|
|
18
|
+
"font": FONT,
|
|
19
|
+
"font_size": 16,
|
|
20
|
+
"pad": 14, # text inset from the outline
|
|
21
|
+
"radius": 18, # corner radius of a speech bubble (capped at half height)
|
|
22
|
+
"stroke": INK, # outline colour
|
|
23
|
+
"stroke_width": 3,
|
|
24
|
+
"fill": "#ffffff",
|
|
25
|
+
"ink": INK, # text colour
|
|
26
|
+
"uppercase": False,
|
|
27
|
+
"em": 1.0, # width scale for the text measure: <1 for a narrower font
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def resolve_style(*layers) -> dict:
|
|
32
|
+
"""Merge style dicts over ``DEFAULT_STYLE``; later layers win, ``None`` skipped."""
|
|
33
|
+
out = dict(DEFAULT_STYLE)
|
|
34
|
+
for layer in layers:
|
|
35
|
+
if layer:
|
|
36
|
+
out.update({k: v for k, v in layer.items() if v is not None})
|
|
37
|
+
return out
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _wrap(text: str, max_chars: int) -> list[str]:
|
|
41
|
+
lines, cur = [], ""
|
|
42
|
+
for word in text.split():
|
|
43
|
+
if cur and len(cur) + 1 + len(word) > max_chars:
|
|
44
|
+
lines.append(cur)
|
|
45
|
+
cur = word
|
|
46
|
+
else:
|
|
47
|
+
cur = f"{cur} {word}".strip()
|
|
48
|
+
if cur:
|
|
49
|
+
lines.append(cur)
|
|
50
|
+
return lines or [""]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# Rough advance widths in em for a humanist sans (DejaVu Sans is the default
|
|
54
|
+
# font): capitals are a good third wider than lowercase, so an all-caps bubble
|
|
55
|
+
# must be measured as such or the text runs past its outline.
|
|
56
|
+
_EM = {"upper": 0.70, "lower": 0.56, "digit": 0.64, "space": 0.32, "other": 0.34}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def text_width(text: str, fs: float) -> float:
|
|
60
|
+
"""Estimated rendered width of *text* at font size *fs*, in px."""
|
|
61
|
+
|
|
62
|
+
def em(ch):
|
|
63
|
+
if ch.isupper():
|
|
64
|
+
return _EM["upper"]
|
|
65
|
+
if ch.islower():
|
|
66
|
+
return _EM["lower"]
|
|
67
|
+
if ch.isdigit():
|
|
68
|
+
return _EM["digit"]
|
|
69
|
+
if ch.isspace():
|
|
70
|
+
return _EM["space"]
|
|
71
|
+
return _EM["other"]
|
|
72
|
+
|
|
73
|
+
return sum(em(ch) for ch in text) * fs
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _box(text, max_chars, fs, pad, em=1.0):
|
|
77
|
+
"""Wrap *text* and return (lines, line_height, body_width, body_height)."""
|
|
78
|
+
lines = _wrap(text, max_chars)
|
|
79
|
+
lh = fs * 1.25
|
|
80
|
+
longest = max((text_width(ln, fs) * em for ln in lines), default=fs)
|
|
81
|
+
w = max(longest + 2 * pad, 60)
|
|
82
|
+
h = len(lines) * lh + 2 * pad
|
|
83
|
+
return lines, lh, w, h
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# how far each bubble kind's outline reaches beyond the text body box
|
|
87
|
+
_OUTSET = {"thought": (12, 16), "shout": (16, 16)}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def bubble_size(text, kind="speech", max_chars=22, fs=None, pad=None, style=None):
|
|
91
|
+
"""Outer (width, height) a `bubble` call will occupy, tail excluded.
|
|
92
|
+
|
|
93
|
+
`thought` and `shout` draw outside the text body, so callers that stack
|
|
94
|
+
bubbles or keep them inside a panel need this, not just the body box.
|
|
95
|
+
"""
|
|
96
|
+
st = resolve_style(style)
|
|
97
|
+
fs = st["font_size"] if fs is None else fs
|
|
98
|
+
pad = st["pad"] if pad is None else pad
|
|
99
|
+
_lines, _lh, w, h = _box(text, max_chars, fs, pad, st["em"])
|
|
100
|
+
ow, oh = _OUTSET.get(kind, (0, 0))
|
|
101
|
+
return w + ow, h + oh
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _text_block(lines, cx, top, fs, lh, st):
|
|
105
|
+
spans = []
|
|
106
|
+
for i, ln in enumerate(lines):
|
|
107
|
+
spans.append(
|
|
108
|
+
f'<tspan x="{cx:.1f}" y="{top + fs + i * lh:.1f}">{escape(ln)}</tspan>'
|
|
109
|
+
)
|
|
110
|
+
return (
|
|
111
|
+
f'<text text-anchor="middle" font-family="{st["font"]}" '
|
|
112
|
+
f'font-size="{fs}" fill="{st["ink"]}">{"".join(spans)}</text>'
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _paint(st, scale=1.0):
|
|
117
|
+
"""fill/stroke attributes shared by every outline a bubble draws."""
|
|
118
|
+
return (
|
|
119
|
+
f'fill="{st["fill"]}" stroke="{st["stroke"]}" '
|
|
120
|
+
f'stroke-width="{st["stroke_width"] * scale:.2f}"'
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def bubble(
|
|
125
|
+
text, bx, by, tail=None, kind="speech", max_chars=22, fs=None, pad=None, style=None
|
|
126
|
+
):
|
|
127
|
+
st = resolve_style(style)
|
|
128
|
+
fs = st["font_size"] if fs is None else fs
|
|
129
|
+
pad = st["pad"] if pad is None else pad
|
|
130
|
+
lines, lh, w, h = _box(text, max_chars, fs, pad, st["em"])
|
|
131
|
+
x, y = bx - w / 2, by - h / 2
|
|
132
|
+
txt = _text_block(lines, bx, y + pad, fs, lh, st)
|
|
133
|
+
|
|
134
|
+
if kind == "shout":
|
|
135
|
+
body = _burst(x, y, w, h, st)
|
|
136
|
+
elif kind == "thought":
|
|
137
|
+
rx, ry = w / 2 + 6, h / 2 + 8
|
|
138
|
+
body = (
|
|
139
|
+
f'<ellipse cx="{bx:.1f}" cy="{by:.1f}" rx="{rx:.1f}" ry="{ry:.1f}" '
|
|
140
|
+
f"{_paint(st)}/>"
|
|
141
|
+
)
|
|
142
|
+
else:
|
|
143
|
+
body = (
|
|
144
|
+
f'<rect x="{x:.1f}" y="{y:.1f}" width="{w:.1f}" height="{h:.1f}" '
|
|
145
|
+
f'rx="{min(st["radius"], h / 2):.1f}" {_paint(st)}/>'
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
tail_svg = ""
|
|
149
|
+
if tail is not None:
|
|
150
|
+
tail_svg = _tail(bx, by, w, h, tail, kind, st)
|
|
151
|
+
|
|
152
|
+
return f"<g>{body}{tail_svg}{txt}</g>"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _tail(bx, by, w, h, tail, kind, st):
|
|
156
|
+
"""A slim tail from the bubble's underside (or its top, when the speaker is
|
|
157
|
+
above it) pointing toward the target — but stopping well short of it, so
|
|
158
|
+
the tip never reaches the figure."""
|
|
159
|
+
tx, ty = tail
|
|
160
|
+
# exit from the edge facing the target: a side edge when the target lies
|
|
161
|
+
# further out beside the bubble than above or below it (relative to the
|
|
162
|
+
# body's own size), else top/bottom — nudged toward the target but kept
|
|
163
|
+
# within the middle of that edge
|
|
164
|
+
over_x = (abs(tx - bx) - w / 2) / (w / 2)
|
|
165
|
+
over_y = (abs(ty - by) - h / 2) / (h / 2)
|
|
166
|
+
if over_x > 0 and over_x > over_y:
|
|
167
|
+
ex = bx - w / 2 if tx < bx else bx + w / 2
|
|
168
|
+
ey = min(max(ty, by - h * 0.3), by + h * 0.3)
|
|
169
|
+
else:
|
|
170
|
+
ex = min(max(tx, bx - w * 0.3), bx + w * 0.3)
|
|
171
|
+
ey = by - h / 2 if ty < by - h / 2 else by + h / 2
|
|
172
|
+
dx, dy = tx - ex, ty - ey
|
|
173
|
+
dist = math.hypot(dx, dy) or 1.0
|
|
174
|
+
reach = min(dist * 0.45, 46) # capped length keeps the tip off the figure
|
|
175
|
+
ux, uy = dx / dist, dy / dist
|
|
176
|
+
tipx, tipy = ex + ux * reach, ey + uy * reach
|
|
177
|
+
|
|
178
|
+
if kind == "thought":
|
|
179
|
+
dots = ""
|
|
180
|
+
for f in (0.45, 0.74, 1.0):
|
|
181
|
+
r = 6 * (1 - f) + 2.5
|
|
182
|
+
dots += (
|
|
183
|
+
f'<circle cx="{ex + ux * reach * f:.1f}" '
|
|
184
|
+
f'cy="{ey + uy * reach * f:.1f}" r="{r:.1f}" {_paint(st, 0.85)}/>'
|
|
185
|
+
)
|
|
186
|
+
return dots
|
|
187
|
+
# narrow tapered tail for speech/shout
|
|
188
|
+
perp = math.atan2(uy, ux) + math.pi / 2
|
|
189
|
+
base = 6
|
|
190
|
+
ax = ex + math.cos(perp) * base
|
|
191
|
+
ay = ey + math.sin(perp) * base
|
|
192
|
+
bx2 = ex - math.cos(perp) * base
|
|
193
|
+
by2 = ey - math.sin(perp) * base
|
|
194
|
+
return (
|
|
195
|
+
f'<path d="M{ax:.1f} {ay:.1f} L{tipx:.1f} {tipy:.1f} '
|
|
196
|
+
f'L{bx2:.1f} {by2:.1f} Z" {_paint(st, 0.85)} stroke-linejoin="round"/>'
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _cloud(x, y, w, h):
|
|
201
|
+
# rounded body + scalloped top edge via overlapping circles
|
|
202
|
+
rx = min(20, h / 2)
|
|
203
|
+
body = (
|
|
204
|
+
f'<rect x="{x:.1f}" y="{y:.1f}" width="{w:.1f}" height="{h:.1f}" '
|
|
205
|
+
f'rx="{rx:.1f}" fill="#ffffff" stroke="{INK}" stroke-width="3"/>'
|
|
206
|
+
)
|
|
207
|
+
bumps = ""
|
|
208
|
+
n = max(3, int(w // 34))
|
|
209
|
+
for i in range(n):
|
|
210
|
+
cx = x + (i + 0.5) * w / n
|
|
211
|
+
bumps += (
|
|
212
|
+
f'<circle cx="{cx:.1f}" cy="{y:.1f}" r="13" '
|
|
213
|
+
f'fill="#ffffff" stroke="{INK}" stroke-width="3"/>'
|
|
214
|
+
)
|
|
215
|
+
# mask the inner stroke segments by redrawing body fill on top edge
|
|
216
|
+
cover = (
|
|
217
|
+
f'<rect x="{x + 3:.1f}" y="{y:.1f}" width="{w - 6:.1f}" height="14" '
|
|
218
|
+
f'fill="#ffffff" stroke="none"/>'
|
|
219
|
+
)
|
|
220
|
+
return body + bumps + cover + body.replace('fill="#ffffff"', 'fill="none"')
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _burst(x, y, w, h, st):
|
|
224
|
+
cx, cy = x + w / 2, y + h / 2
|
|
225
|
+
rx, ry = w / 2 + 8, h / 2 + 8
|
|
226
|
+
n = 18
|
|
227
|
+
pts = []
|
|
228
|
+
for i in range(n * 2):
|
|
229
|
+
a = math.pi * i / n
|
|
230
|
+
rr = 1.0 if i % 2 == 0 else 0.78
|
|
231
|
+
pts.append(f"{cx + math.cos(a) * rx * rr:.1f},{cy + math.sin(a) * ry * rr:.1f}")
|
|
232
|
+
return f'<polygon points="{" ".join(pts)}" {_paint(st)} stroke-linejoin="round"/>'
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Narration captions: a text band inside the panel frame, under the art.
|
|
2
|
+
|
|
3
|
+
A panel's ``caption:`` is the narrator's voice ("Rain came. Snow came.") as
|
|
4
|
+
opposed to a bubble, which is a character's. It is drawn as a flat band along
|
|
5
|
+
the bottom of the panel, separated from the art by a hairline; the art box
|
|
6
|
+
shrinks to make room, so bubbles and actors keep their coordinates relative to
|
|
7
|
+
the picture, not the band. A page's ``caption_style:`` sets the look for every
|
|
8
|
+
caption; a panel can give ``caption: {text:, max_chars:}`` to wrap differently.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from xml.sax.saxutils import escape
|
|
14
|
+
|
|
15
|
+
from .bubbles import FONT, INK, _wrap
|
|
16
|
+
|
|
17
|
+
DEFAULT_STYLE = {
|
|
18
|
+
"font": FONT,
|
|
19
|
+
"font_size": 13,
|
|
20
|
+
"ink": INK, # text colour
|
|
21
|
+
"bg": "#ffffff", # band colour
|
|
22
|
+
"pad": 8, # text inset from the band edge
|
|
23
|
+
"max_chars": 60, # wrap width; a panel's caption can override
|
|
24
|
+
"align": "left", # left | center
|
|
25
|
+
"rule": True, # hairline between art and band (in the frame colour)
|
|
26
|
+
"uppercase": False,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def normalize(value) -> dict | None:
|
|
31
|
+
"""``caption: text`` or ``caption: {text, max_chars}`` -> dict, or None."""
|
|
32
|
+
if value is None:
|
|
33
|
+
return None
|
|
34
|
+
if isinstance(value, str):
|
|
35
|
+
value = {"text": value}
|
|
36
|
+
if not isinstance(value, dict) or not value.get("text"):
|
|
37
|
+
raise ValueError("caption must be a string or a {text, max_chars} mapping")
|
|
38
|
+
return value
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def resolve_style(*layers) -> dict:
|
|
42
|
+
out = dict(DEFAULT_STYLE)
|
|
43
|
+
for layer in layers:
|
|
44
|
+
if layer:
|
|
45
|
+
out.update({k: v for k, v in layer.items() if v is not None})
|
|
46
|
+
return out
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def lines(caption: dict, style: dict) -> list[str]:
|
|
50
|
+
text = " ".join(caption["text"].split())
|
|
51
|
+
if caption.get("uppercase", style["uppercase"]):
|
|
52
|
+
text = text.upper()
|
|
53
|
+
return _wrap(text, caption.get("max_chars", style["max_chars"]))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def height(caption, style=None) -> float:
|
|
57
|
+
"""Band height in px a caption will take, 0 when there is none."""
|
|
58
|
+
cap = normalize(caption)
|
|
59
|
+
if cap is None:
|
|
60
|
+
return 0.0
|
|
61
|
+
st = resolve_style(style)
|
|
62
|
+
return len(lines(cap, st)) * st["font_size"] * 1.25 + 2 * st["pad"]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def band(caption: dict, style: dict, x, y, w, h, rule_color, rule_width) -> str:
|
|
66
|
+
"""SVG for the band occupying the (x, y, w, h) box."""
|
|
67
|
+
st = style
|
|
68
|
+
fs, lh = st["font_size"], st["font_size"] * 1.25
|
|
69
|
+
parts = [
|
|
70
|
+
f'<rect x="{x:.1f}" y="{y:.1f}" width="{w:.1f}" height="{h:.1f}" '
|
|
71
|
+
f'fill="{st["bg"]}"/>'
|
|
72
|
+
]
|
|
73
|
+
if st["rule"] and rule_width > 0:
|
|
74
|
+
parts.append(
|
|
75
|
+
f'<line x1="{x:.1f}" y1="{y:.1f}" x2="{x + w:.1f}" y2="{y:.1f}" '
|
|
76
|
+
f'stroke="{rule_color}" stroke-width="{rule_width}"/>'
|
|
77
|
+
)
|
|
78
|
+
if st["align"] == "center":
|
|
79
|
+
tx, anchor = x + w / 2, "middle"
|
|
80
|
+
else:
|
|
81
|
+
tx, anchor = x + st["pad"], "start"
|
|
82
|
+
top = y + st["pad"] + fs
|
|
83
|
+
spans = "".join(
|
|
84
|
+
f'<tspan x="{tx:.1f}" y="{top + i * lh:.1f}">{escape(ln)}</tspan>'
|
|
85
|
+
for i, ln in enumerate(lines(caption, st))
|
|
86
|
+
)
|
|
87
|
+
parts.append(
|
|
88
|
+
f'<text text-anchor="{anchor}" font-family="{st["font"]}" font-size="{fs}" '
|
|
89
|
+
f'fill="{st["ink"]}">{spans}</text>'
|
|
90
|
+
)
|
|
91
|
+
return "".join(parts)
|
|
@@ -323,9 +323,7 @@ def main(argv=None): # noqa: PLR0912, PLR0915 — flat CLI dispatcher; clearer
|
|
|
323
323
|
):
|
|
324
324
|
rich_print(f"wrote {o}")
|
|
325
325
|
else:
|
|
326
|
-
out = args.out or _default_out(
|
|
327
|
-
f"{args.spec.stem}-r{args.row}c{args.col}"
|
|
328
|
-
)
|
|
326
|
+
out = args.out or _default_out(f"{args.spec.stem}-r{args.row}c{args.col}")
|
|
329
327
|
render_panel(
|
|
330
328
|
args.spec,
|
|
331
329
|
out,
|