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.
Files changed (84) hide show
  1. {comicforge-0.0.4 → comicforge-0.2.0}/.claude/skills/comicforge/SKILL.md +101 -12
  2. {comicforge-0.0.4 → comicforge-0.2.0}/.claude/skills/comicforge/reference.md +38 -7
  3. {comicforge-0.0.4 → comicforge-0.2.0}/.github/workflows/ci.yml +2 -2
  4. {comicforge-0.0.4 → comicforge-0.2.0}/.github/workflows/release.yml +1 -1
  5. {comicforge-0.0.4 → comicforge-0.2.0}/PKG-INFO +2 -2
  6. {comicforge-0.0.4 → comicforge-0.2.0}/comicforge/__init__.py +1 -1
  7. comicforge-0.2.0/comicforge/bubbles.py +232 -0
  8. comicforge-0.2.0/comicforge/caption.py +91 -0
  9. {comicforge-0.0.4 → comicforge-0.2.0}/comicforge/cli.py +1 -3
  10. comicforge-0.2.0/comicforge/raster.py +149 -0
  11. {comicforge-0.0.4 → comicforge-0.2.0}/comicforge/render.py +307 -74
  12. {comicforge-0.0.4 → comicforge-0.2.0}/comicforge/validate.py +80 -6
  13. {comicforge-0.0.4 → comicforge-0.2.0}/pyproject.toml +1 -1
  14. {comicforge-0.0.4 → comicforge-0.2.0}/skills/comicforge/SKILL.md +101 -12
  15. {comicforge-0.0.4 → comicforge-0.2.0}/skills/comicforge/reference.md +38 -7
  16. comicforge-0.2.0/tests/test_bubbles.py +62 -0
  17. comicforge-0.2.0/tests/test_caption.py +68 -0
  18. comicforge-0.2.0/tests/test_raster.py +241 -0
  19. comicforge-0.2.0/tests/test_render.py +262 -0
  20. {comicforge-0.0.4 → comicforge-0.2.0}/uv.lock +59 -55
  21. comicforge-0.0.4/comicforge/bubbles.py +0 -133
  22. comicforge-0.0.4/tests/test_bubbles.py +0 -32
  23. comicforge-0.0.4/tests/test_render.py +0 -117
  24. {comicforge-0.0.4 → comicforge-0.2.0}/.github/dependabot.yml +0 -0
  25. {comicforge-0.0.4 → comicforge-0.2.0}/.gitignore +0 -0
  26. {comicforge-0.0.4 → comicforge-0.2.0}/.python-version +0 -0
  27. {comicforge-0.0.4 → comicforge-0.2.0}/CLAUDE.md +0 -0
  28. {comicforge-0.0.4 → comicforge-0.2.0}/README.md +0 -0
  29. {comicforge-0.0.4 → comicforge-0.2.0}/comicforge/__main__.py +0 -0
  30. {comicforge-0.0.4 → comicforge-0.2.0}/comicforge/inspire.py +0 -0
  31. {comicforge-0.0.4 → comicforge-0.2.0}/comicforge/library.py +0 -0
  32. {comicforge-0.0.4 → comicforge-0.2.0}/comicforge/pixelart.py +0 -0
  33. {comicforge-0.0.4 → comicforge-0.2.0}/comicforge/scaffold.py +0 -0
  34. {comicforge-0.0.4 → comicforge-0.2.0}/comicforge/scene.py +0 -0
  35. {comicforge-0.0.4 → comicforge-0.2.0}/docs/starting-a-project.md +0 -0
  36. {comicforge-0.0.4 → comicforge-0.2.0}/examples/README.md +0 -0
  37. {comicforge-0.0.4 → comicforge-0.2.0}/examples/README.md.old +0 -0
  38. {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/bara/character.yaml +0 -0
  39. {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/bara/face-happy.svg +0 -0
  40. {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/bara/face-neutral.svg +0 -0
  41. {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/bara/poses/sit/base.svg +0 -0
  42. {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/bara/poses/sit/pose.yaml +0 -0
  43. {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/bara/poses/walk/base.svg +0 -0
  44. {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/bara/poses/walk/pose.yaml +0 -0
  45. {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/tom/arms-crossed.svg +0 -0
  46. {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/tom/arms-down.svg +0 -0
  47. {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/tom/arms-hips.svg +0 -0
  48. {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/tom/arms-point.svg +0 -0
  49. {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/tom/arms-thumbsup.svg +0 -0
  50. {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/tom/arms-wave.svg +0 -0
  51. {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/tom/base.svg +0 -0
  52. {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/tom/character.yaml +0 -0
  53. {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/tom/face-angry.svg +0 -0
  54. {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/tom/face-happy.svg +0 -0
  55. {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/tom/face-laugh.svg +0 -0
  56. {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/tom/face-neutral.svg +0 -0
  57. {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/tom/face-sad.svg +0 -0
  58. {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/tom/face-surprised.svg +0 -0
  59. {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/characters/tom/face-wink.svg +0 -0
  60. {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/pages/dvur-scene.yaml +0 -0
  61. {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/pages/kosticka.yaml +0 -0
  62. {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/pages/slepice.yaml +0 -0
  63. {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/pixel/bone.yaml +0 -0
  64. {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/pixel/heart.yaml +0 -0
  65. {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/pixel/star.yaml +0 -0
  66. {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/pixel/sun.yaml +0 -0
  67. {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/references.yaml +0 -0
  68. {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/scenes/dvur/base.svg +0 -0
  69. {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/scenes/dvur/scene.yaml +0 -0
  70. {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/scenes/dvur/weather-clear.svg +0 -0
  71. {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/scenes/dvur/weather-rain.svg +0 -0
  72. {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/scenes/pokoj/base.svg +0 -0
  73. {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/scenes/pokoj/scene.yaml +0 -0
  74. {comicforge-0.0.4 → comicforge-0.2.0}/examples/pes/theme.yaml +0 -0
  75. {comicforge-0.0.4 → comicforge-0.2.0}/poe_tasks.toml +0 -0
  76. {comicforge-0.0.4 → comicforge-0.2.0}/tests/__init__.py +0 -0
  77. {comicforge-0.0.4 → comicforge-0.2.0}/tests/conftest.py +0 -0
  78. {comicforge-0.0.4 → comicforge-0.2.0}/tests/test_cli.py +0 -0
  79. {comicforge-0.0.4 → comicforge-0.2.0}/tests/test_inspire.py +0 -0
  80. {comicforge-0.0.4 → comicforge-0.2.0}/tests/test_library.py +0 -0
  81. {comicforge-0.0.4 → comicforge-0.2.0}/tests/test_pixelart.py +0 -0
  82. {comicforge-0.0.4 → comicforge-0.2.0}/tests/test_scaffold.py +0 -0
  83. {comicforge-0.0.4 → comicforge-0.2.0}/tests/test_scene.py +0 -0
  84. {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 against the
46
- libraries without drawing anything, and lists *all* problems at once. Unlike
47
- `render`, it flags mis-spelled keys (e.g. `fcae:`) that render silently
48
- ignores. Exits non-zero when anything is wrong. Works on page and `scene` specs.
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 scene filling the whole canvas with actors/bubbles on top — good for
129
- covers and single panels. Render with `comicforge scene file.yaml -o out.png`.
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 on standalone illustrations.
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 without overlap
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`); an unknown `type:`, or a `type:` that contradicts the structure
441
- (a `scene` spec carrying `rows`)
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`, `page` (A4/A5/letter/[w,h]), `px_per_mm`,
592
- `margin_mm`, `gutter_mm`, `library`, `scenes_dir`, `pixel_dir`
593
- - **Rows and panels**: `rows[].height` (relative weight), `rows[].panels[].width`
594
- (relative weight), panel keys: `bg`, `scene`, `actors`, `pixel`, `bubbles`
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@v8.3.0
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@v8.3.0
73
+ uses: astral-sh/setup-uv@v10.0.1
74
74
  with:
75
75
  enable-cache: true
76
76
 
@@ -14,7 +14,7 @@ jobs:
14
14
  - uses: actions/checkout@v7
15
15
 
16
16
  - name: Install uv
17
- uses: astral-sh/setup-uv@v8.3.0
17
+ uses: astral-sh/setup-uv@v10.0.1
18
18
 
19
19
  - name: Build sdist + wheel
20
20
  run: uv build
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: comicforge
3
- Version: 0.0.4
3
+ Version: 0.2.0
4
4
  Summary: A tiny, scriptable comic-page engine — author comics as YAML, render to SVG / PNG / PDF
5
5
  Requires-Python: >=3.13
6
6
  Requires-Dist: cairosvg>=2.7
@@ -7,4 +7,4 @@ Designed so an LLM (or you) can author pages as plain declarative text.
7
7
  from .pixelart import PixelLibrary # noqa: F401
8
8
  from .render import load_spec, render_scene, render_spec # noqa: F401
9
9
 
10
- __version__ = "0.1.0"
10
+ __version__ = "0.2.0"
@@ -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,