comicforge 0.0.4__tar.gz → 0.1.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 (79) hide show
  1. {comicforge-0.0.4 → comicforge-0.1.0}/.claude/skills/comicforge/SKILL.md +72 -11
  2. {comicforge-0.0.4 → comicforge-0.1.0}/.claude/skills/comicforge/reference.md +22 -5
  3. {comicforge-0.0.4 → comicforge-0.1.0}/.github/workflows/ci.yml +2 -2
  4. {comicforge-0.0.4 → comicforge-0.1.0}/.github/workflows/release.yml +1 -1
  5. {comicforge-0.0.4 → comicforge-0.1.0}/PKG-INFO +2 -2
  6. {comicforge-0.0.4 → comicforge-0.1.0}/comicforge/bubbles.py +58 -24
  7. {comicforge-0.0.4 → comicforge-0.1.0}/comicforge/cli.py +1 -3
  8. comicforge-0.1.0/comicforge/raster.py +149 -0
  9. {comicforge-0.0.4 → comicforge-0.1.0}/comicforge/render.py +164 -55
  10. {comicforge-0.0.4 → comicforge-0.1.0}/comicforge/validate.py +59 -5
  11. {comicforge-0.0.4 → comicforge-0.1.0}/pyproject.toml +1 -1
  12. {comicforge-0.0.4 → comicforge-0.1.0}/skills/comicforge/SKILL.md +72 -11
  13. {comicforge-0.0.4 → comicforge-0.1.0}/skills/comicforge/reference.md +22 -5
  14. comicforge-0.1.0/tests/test_raster.py +241 -0
  15. {comicforge-0.0.4 → comicforge-0.1.0}/tests/test_render.py +24 -0
  16. {comicforge-0.0.4 → comicforge-0.1.0}/uv.lock +1 -1
  17. {comicforge-0.0.4 → comicforge-0.1.0}/.github/dependabot.yml +0 -0
  18. {comicforge-0.0.4 → comicforge-0.1.0}/.gitignore +0 -0
  19. {comicforge-0.0.4 → comicforge-0.1.0}/.python-version +0 -0
  20. {comicforge-0.0.4 → comicforge-0.1.0}/CLAUDE.md +0 -0
  21. {comicforge-0.0.4 → comicforge-0.1.0}/README.md +0 -0
  22. {comicforge-0.0.4 → comicforge-0.1.0}/comicforge/__init__.py +0 -0
  23. {comicforge-0.0.4 → comicforge-0.1.0}/comicforge/__main__.py +0 -0
  24. {comicforge-0.0.4 → comicforge-0.1.0}/comicforge/inspire.py +0 -0
  25. {comicforge-0.0.4 → comicforge-0.1.0}/comicforge/library.py +0 -0
  26. {comicforge-0.0.4 → comicforge-0.1.0}/comicforge/pixelart.py +0 -0
  27. {comicforge-0.0.4 → comicforge-0.1.0}/comicforge/scaffold.py +0 -0
  28. {comicforge-0.0.4 → comicforge-0.1.0}/comicforge/scene.py +0 -0
  29. {comicforge-0.0.4 → comicforge-0.1.0}/docs/starting-a-project.md +0 -0
  30. {comicforge-0.0.4 → comicforge-0.1.0}/examples/README.md +0 -0
  31. {comicforge-0.0.4 → comicforge-0.1.0}/examples/README.md.old +0 -0
  32. {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/bara/character.yaml +0 -0
  33. {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/bara/face-happy.svg +0 -0
  34. {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/bara/face-neutral.svg +0 -0
  35. {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/bara/poses/sit/base.svg +0 -0
  36. {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/bara/poses/sit/pose.yaml +0 -0
  37. {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/bara/poses/walk/base.svg +0 -0
  38. {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/bara/poses/walk/pose.yaml +0 -0
  39. {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/tom/arms-crossed.svg +0 -0
  40. {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/tom/arms-down.svg +0 -0
  41. {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/tom/arms-hips.svg +0 -0
  42. {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/tom/arms-point.svg +0 -0
  43. {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/tom/arms-thumbsup.svg +0 -0
  44. {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/tom/arms-wave.svg +0 -0
  45. {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/tom/base.svg +0 -0
  46. {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/tom/character.yaml +0 -0
  47. {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/tom/face-angry.svg +0 -0
  48. {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/tom/face-happy.svg +0 -0
  49. {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/tom/face-laugh.svg +0 -0
  50. {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/tom/face-neutral.svg +0 -0
  51. {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/tom/face-sad.svg +0 -0
  52. {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/tom/face-surprised.svg +0 -0
  53. {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/tom/face-wink.svg +0 -0
  54. {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/pages/dvur-scene.yaml +0 -0
  55. {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/pages/kosticka.yaml +0 -0
  56. {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/pages/slepice.yaml +0 -0
  57. {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/pixel/bone.yaml +0 -0
  58. {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/pixel/heart.yaml +0 -0
  59. {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/pixel/star.yaml +0 -0
  60. {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/pixel/sun.yaml +0 -0
  61. {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/references.yaml +0 -0
  62. {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/scenes/dvur/base.svg +0 -0
  63. {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/scenes/dvur/scene.yaml +0 -0
  64. {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/scenes/dvur/weather-clear.svg +0 -0
  65. {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/scenes/dvur/weather-rain.svg +0 -0
  66. {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/scenes/pokoj/base.svg +0 -0
  67. {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/scenes/pokoj/scene.yaml +0 -0
  68. {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/theme.yaml +0 -0
  69. {comicforge-0.0.4 → comicforge-0.1.0}/poe_tasks.toml +0 -0
  70. {comicforge-0.0.4 → comicforge-0.1.0}/tests/__init__.py +0 -0
  71. {comicforge-0.0.4 → comicforge-0.1.0}/tests/conftest.py +0 -0
  72. {comicforge-0.0.4 → comicforge-0.1.0}/tests/test_bubbles.py +0 -0
  73. {comicforge-0.0.4 → comicforge-0.1.0}/tests/test_cli.py +0 -0
  74. {comicforge-0.0.4 → comicforge-0.1.0}/tests/test_inspire.py +0 -0
  75. {comicforge-0.0.4 → comicforge-0.1.0}/tests/test_library.py +0 -0
  76. {comicforge-0.0.4 → comicforge-0.1.0}/tests/test_pixelart.py +0 -0
  77. {comicforge-0.0.4 → comicforge-0.1.0}/tests/test_scaffold.py +0 -0
  78. {comicforge-0.0.4 → comicforge-0.1.0}/tests/test_scene.py +0 -0
  79. {comicforge-0.0.4 → comicforge-0.1.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`,
@@ -84,6 +90,7 @@ rows: # page is a stack of rows…
84
90
  - width: 1.0 # relative panel width (default 1)
85
91
  bg: "#fbfaf6" # optional flat panel background
86
92
  scene: dvur # optional scene background (see below)
93
+ image: "art/01.png" # optional raster background (see below)
87
94
  actors: [ ... ] # characters (drawn back→front in list order)
88
95
  pixel: [ ... ] # pixel-art sprites (drawn behind actors)
89
96
  bubbles:[ ... ] # speech/thought/shout (drawn on top)
@@ -123,22 +130,62 @@ Confirm scenes/slots with `comicforge scenes --scenes <project>/scenes`.
123
130
  Seeds in `examples/pes/`: `dvur` (farmyard, slot `weather: clear|rain`),
124
131
  `pokoj` (room, no slots). Both live in `examples/pes/scenes/`.
125
132
 
133
+ ### image (raster panel background)
134
+
135
+ When the panel art is a finished bitmap — a generated or photographed image
136
+ rather than a `scene:` built from SVG — point the panel at it with `image:`.
137
+ Everything else works unchanged: bubbles, actors and pixel art all compose on
138
+ top of it.
139
+
140
+ ```yaml
141
+ image: "../refs/panels/01.png" # path, relative to the *spec file*
142
+ image: {src: "01.png", fit: contain} # or the long form, to pick the fit
143
+ ```
144
+
145
+ - `fit: cover` (the default) scales the image to fill the panel and
146
+ centre-crops the overflow — a 3:2 image in a 4:3 panel crops rather than
147
+ distorts, exactly like a `scene:` background.
148
+ - `fit: contain` scales it to fit *inside* the panel; the leftover strips show
149
+ the panel's `bg:` colour.
150
+ - `.png`, `.jpg`/`.jpeg`, `.gif` and `.webp` are supported.
151
+ - The image is **embedded** as a base64 data URI, so an `.svg` or `.pdf` render
152
+ is one self-contained file that still works when moved. That also means the
153
+ output carries the full byte weight of every image on the page — expect large
154
+ files for a page of full-res panels.
155
+ - `image:` and `scene:` can coexist; the scene draws over the image.
156
+
157
+ A standalone illustration can be image-backed too — set `type: scene` with an
158
+ `image:` and no `scene:`, and the canvas is sized from the image's own pixel
159
+ dimensions times `scale:` (default `1`, i.e. one output px per image px).
160
+
161
+ **Where bubbles land on a raster panel.** There are no actors to anchor to, so
162
+ `speaker:` is meaningless — use `x`/`y`/`to` fractions, or leave them out. A
163
+ bubble with no `y` is placed below the measured bottom of the bubble before it,
164
+ starting near the panel top, so any number of bubbles of any length stack
165
+ without overlapping. A bubble with no `x` is horizontally centred. Every
166
+ bubble — explicit or automatic — is then nudged so it stays inside the panel
167
+ (one wider or taller than the panel is centred instead). A generated spec can
168
+ therefore emit dialogue in order and omit the coordinates entirely.
169
+
126
170
  ## Standalone illustration (no comic grid)
127
171
 
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`.
172
+ Render one background filling the whole canvas with actors/bubbles on top —
173
+ good for covers and single panels. Render with
174
+ `comicforge scene file.yaml -o out.png`.
130
175
 
131
176
  Set `type: scene` so the spec declares which command renders it: `render`
132
177
  rejects a scene spec (and `scene` rejects a page spec) with a clear message
133
178
  instead of a confusing crash, and `validate` checks the type matches the
134
179
  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.
180
+ background — `scene` or `image` — with no `rows` == a scene) — but declare it
181
+ on standalone illustrations.
136
182
 
137
183
  ```yaml
138
184
  title: "Optional"
139
185
  type: scene
140
- scene: {name: dvur, weather: clear}
141
- scale: 3 # px per scene unit (canvas = scene viewbox × scale)
186
+ scene: {name: dvur, weather: clear} # …or `image: cover.png` instead
187
+ scale: 3 # px per scene unit (canvas = scene viewbox × scale);
188
+ # with `image:` it's output px per image px, default 1
142
189
  library: "../characters"
143
190
  scenes_dir: "../scenes"
144
191
  actors: [ ... ] # same actor grammar; x/y/scale are canvas fractions
@@ -154,10 +201,24 @@ bubbles: [ ... ]
154
201
  speaker: tom # OPTIONAL: auto-place above this actor + aim the tail at
155
202
  # their head. Prefer this over manual x/y/to.
156
203
  x: 0.5 # OPTIONAL bubble centre (panel fraction); else from speaker
157
- y: 0.18 # OPTIONAL; omit and bubbles stack downward without overlap
204
+ y: 0.18 # OPTIONAL; omit and bubbles stack downward by measured
205
+ # height, so they never overlap. All bubbles are
206
+ # kept inside the panel.
158
207
  to: [0.4, 0.5] # OPTIONAL tail target (panel fraction); else speaker's head
159
208
  max_chars: 22 # wrap width (optional)
160
- fs: 16 # font size px (optional)
209
+ fs: 16 # font size px (optional; overrides bubble_style.font_size)
210
+ uppercase: true # OPTIONAL: force this bubble's text to CAPS
211
+ # (overrides bubble_style.uppercase)
212
+ ```
213
+
214
+ **Page-wide bubble defaults** — set `bubble_style` at the *top level* of a
215
+ page or scene spec to style every bubble at once; per-bubble keys override:
216
+
217
+ ```yaml
218
+ bubble_style:
219
+ uppercase: true # render all bubble text in CAPS (classic comic lettering)
220
+ font_size: 16 # default font size px for all bubbles
221
+ rows: [ ... ]
161
222
  ```
162
223
 
163
224
  `thought` draws an ellipse with a trail of dots; `shout` draws a spiky burst.
@@ -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.
@@ -589,13 +593,26 @@ Do **not** auto-vectorize the result — hand/LLM-author the SVG using it as a g
589
593
  For the full spec grammar, see [SKILL.md](SKILL.md). Key points:
590
594
 
591
595
  - **Page-level keys**: `title`, `page` (A4/A5/letter/[w,h]), `px_per_mm`,
592
- `margin_mm`, `gutter_mm`, `library`, `scenes_dir`, `pixel_dir`
596
+ `margin_mm`, `gutter_mm`, `library`, `scenes_dir`, `pixel_dir`,
597
+ `bubble_style` (page-wide bubble defaults: `uppercase`, `font_size`)
593
598
  - **Rows and panels**: `rows[].height` (relative weight), `rows[].panels[].width`
594
- (relative weight), panel keys: `bg`, `scene`, `actors`, `pixel`, `bubbles`
599
+ (relative weight), panel keys: `bg`, `scene`, `image`, `actors`, `pixel`,
600
+ `bubbles` — `validate` flags any other panel key as a typo
601
+ - **Image keys**: `image: path.png` or `image: {src:, fit:}` with
602
+ `fit: cover` (default; scale-to-fill + centre-crop) or `contain`
603
+ (fit inside + letterbox). The path resolves against the spec file's dir and
604
+ the image is embedded as a base64 data URI, so SVG/PDF output stays
605
+ self-contained. A `type: scene` spec can use `image:` in place of `scene:`,
606
+ and takes its canvas size from the image
595
607
  - **Actor keys**: `char`, `pose` (optional; defaults to the character's default
596
608
  pose), per-slot variant keys (`face`, `arms`, etc.), `x`, `y`, `scale`, `flip`
597
609
  - **Bubble keys**: `text`, `kind` (speech/thought/shout), `speaker`, optional
598
- `x`/`y`/`to`/`max_chars`/`fs`
610
+ `x`/`y`/`to`/`max_chars`/`fs`/`uppercase` (`fs` and `uppercase` override
611
+ the page-level `bubble_style`). Omit `y` and bubbles stack down by their
612
+ measured height without overlapping; omit `x` and they centre (or sit above
613
+ their `speaker`). Every bubble is kept inside the panel — so a generated spec
614
+ over raster panels, which has no `speaker` to anchor to, can omit coordinates
615
+ entirely
599
616
  - **Pixel keys**: `art` (library name) or `grid`+`palette`, plus `x`/`y`/`scale`
600
617
 
601
618
  ---
@@ -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.1.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
@@ -26,6 +26,31 @@ def _wrap(text: str, max_chars: int) -> list[str]:
26
26
  return lines or [""]
27
27
 
28
28
 
29
+ def _box(text, max_chars, fs, pad):
30
+ """Wrap *text* and return (lines, line_height, body_width, body_height)."""
31
+ lines = _wrap(text, max_chars)
32
+ lh = fs * 1.25
33
+ longest = max((len(ln) for ln in lines), default=1)
34
+ w = max(longest * fs * 0.56 + 2 * pad, 60)
35
+ h = len(lines) * lh + 2 * pad
36
+ return lines, lh, w, h
37
+
38
+
39
+ # how far each bubble kind's outline reaches beyond the text body box
40
+ _OUTSET = {"thought": (12, 16), "shout": (16, 16)}
41
+
42
+
43
+ def bubble_size(text, kind="speech", max_chars=22, fs=16, pad=14):
44
+ """Outer (width, height) a `bubble` call will occupy, tail excluded.
45
+
46
+ `thought` and `shout` draw outside the text body, so callers that stack
47
+ bubbles or keep them inside a panel need this, not just the body box.
48
+ """
49
+ _lines, _lh, w, h = _box(text, max_chars, fs, pad)
50
+ ow, oh = _OUTSET.get(kind, (0, 0))
51
+ return w + ow, h + oh
52
+
53
+
29
54
  def _text_block(lines, cx, top, fs, lh):
30
55
  spans = []
31
56
  for i, ln in enumerate(lines):
@@ -39,13 +64,8 @@ def _text_block(lines, cx, top, fs, lh):
39
64
  )
40
65
 
41
66
 
42
- def bubble(text, bx, by, tail=None, kind="speech", max_chars=22,
43
- fs=16, pad=14):
44
- lines = _wrap(text, max_chars)
45
- lh = fs * 1.25
46
- longest = max((len(ln) for ln in lines), default=1)
47
- w = max(longest * fs * 0.56 + 2 * pad, 60)
48
- h = len(lines) * lh + 2 * pad
67
+ def bubble(text, bx, by, tail=None, kind="speech", max_chars=22, fs=16, pad=14):
68
+ lines, lh, w, h = _box(text, max_chars, fs, pad)
49
69
  x, y = bx - w / 2, by - h / 2
50
70
  txt = _text_block(lines, bx, y + pad, fs, lh)
51
71
 
@@ -53,18 +73,22 @@ def bubble(text, bx, by, tail=None, kind="speech", max_chars=22,
53
73
  body = _burst(x, y, w, h)
54
74
  elif kind == "thought":
55
75
  rx, ry = w / 2 + 6, h / 2 + 8
56
- body = (f'<ellipse cx="{bx:.1f}" cy="{by:.1f}" rx="{rx:.1f}" ry="{ry:.1f}" '
57
- f'fill="#ffffff" stroke="{INK}" stroke-width="3"/>')
76
+ body = (
77
+ f'<ellipse cx="{bx:.1f}" cy="{by:.1f}" rx="{rx:.1f}" ry="{ry:.1f}" '
78
+ f'fill="#ffffff" stroke="{INK}" stroke-width="3"/>'
79
+ )
58
80
  else:
59
- body = (f'<rect x="{x:.1f}" y="{y:.1f}" width="{w:.1f}" height="{h:.1f}" '
60
- f'rx="{min(18, h/2):.1f}" fill="#ffffff" stroke="{INK}" '
61
- f'stroke-width="3"/>')
81
+ body = (
82
+ f'<rect x="{x:.1f}" y="{y:.1f}" width="{w:.1f}" height="{h:.1f}" '
83
+ f'rx="{min(18, h / 2):.1f}" fill="#ffffff" stroke="{INK}" '
84
+ f'stroke-width="3"/>'
85
+ )
62
86
 
63
87
  tail_svg = ""
64
88
  if tail is not None:
65
89
  tail_svg = _tail(bx, by, w, h, tail, kind)
66
90
 
67
- return f'<g>{body}{tail_svg}{txt}</g>'
91
+ return f"<g>{body}{tail_svg}{txt}</g>"
68
92
 
69
93
 
70
94
  def _tail(bx, by, w, h, tail, kind):
@@ -85,9 +109,11 @@ def _tail(bx, by, w, h, tail, kind):
85
109
  dots = ""
86
110
  for f in (0.45, 0.74, 1.0):
87
111
  r = 6 * (1 - f) + 2.5
88
- dots += (f'<circle cx="{ex + ux * reach * f:.1f}" '
89
- f'cy="{ey + uy * reach * f:.1f}" r="{r:.1f}" '
90
- f'fill="#ffffff" stroke="{INK}" stroke-width="2.5"/>')
112
+ dots += (
113
+ f'<circle cx="{ex + ux * reach * f:.1f}" '
114
+ f'cy="{ey + uy * reach * f:.1f}" r="{r:.1f}" '
115
+ f'fill="#ffffff" stroke="{INK}" stroke-width="2.5"/>'
116
+ )
91
117
  return dots
92
118
  # narrow tapered tail for speech/shout
93
119
  perp = math.atan2(uy, ux) + math.pi / 2
@@ -106,17 +132,23 @@ def _tail(bx, by, w, h, tail, kind):
106
132
  def _cloud(x, y, w, h):
107
133
  # rounded body + scalloped top edge via overlapping circles
108
134
  rx = min(20, h / 2)
109
- body = (f'<rect x="{x:.1f}" y="{y:.1f}" width="{w:.1f}" height="{h:.1f}" '
110
- f'rx="{rx:.1f}" fill="#ffffff" stroke="{INK}" stroke-width="3"/>')
135
+ body = (
136
+ f'<rect x="{x:.1f}" y="{y:.1f}" width="{w:.1f}" height="{h:.1f}" '
137
+ f'rx="{rx:.1f}" fill="#ffffff" stroke="{INK}" stroke-width="3"/>'
138
+ )
111
139
  bumps = ""
112
140
  n = max(3, int(w // 34))
113
141
  for i in range(n):
114
142
  cx = x + (i + 0.5) * w / n
115
- bumps += (f'<circle cx="{cx:.1f}" cy="{y:.1f}" r="13" '
116
- f'fill="#ffffff" stroke="{INK}" stroke-width="3"/>')
143
+ bumps += (
144
+ f'<circle cx="{cx:.1f}" cy="{y:.1f}" r="13" '
145
+ f'fill="#ffffff" stroke="{INK}" stroke-width="3"/>'
146
+ )
117
147
  # mask the inner stroke segments by redrawing body fill on top edge
118
- cover = (f'<rect x="{x+3:.1f}" y="{y:.1f}" width="{w-6:.1f}" height="14" '
119
- f'fill="#ffffff" stroke="none"/>')
148
+ cover = (
149
+ f'<rect x="{x + 3:.1f}" y="{y:.1f}" width="{w - 6:.1f}" height="14" '
150
+ f'fill="#ffffff" stroke="none"/>'
151
+ )
120
152
  return body + bumps + cover + body.replace('fill="#ffffff"', 'fill="none"')
121
153
 
122
154
 
@@ -129,5 +161,7 @@ def _burst(x, y, w, h):
129
161
  a = math.pi * i / n
130
162
  rr = 1.0 if i % 2 == 0 else 0.78
131
163
  pts.append(f"{cx + math.cos(a) * rx * rr:.1f},{cy + math.sin(a) * ry * rr:.1f}")
132
- return (f'<polygon points="{" ".join(pts)}" fill="#ffffff" '
133
- f'stroke="{INK}" stroke-width="3" stroke-linejoin="round"/>')
164
+ return (
165
+ f'<polygon points="{" ".join(pts)}" fill="#ffffff" '
166
+ f'stroke="{INK}" stroke-width="3" stroke-linejoin="round"/>'
167
+ )
@@ -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,
@@ -0,0 +1,149 @@
1
+ """Embed a raster image (PNG / JPEG / GIF / WebP) as a panel background.
2
+
3
+ A panel's ``image:`` is the bitmap counterpart of ``scene:``: instead of an SVG
4
+ base plus stackable overlays, one finished picture fills the panel. It is
5
+ scaled to *cover* the panel and centre-cropped — the same behaviour
6
+ :func:`comicforge.scene.cover` gives a vector scene — so a 3:2 image in a 4:3
7
+ panel crops rather than distorts. ``fit: contain`` letterboxes it instead.
8
+
9
+ The image is embedded as a base64 ``data:`` URI rather than linked, so an
10
+ ``.svg`` / ``.pdf`` render is a single self-contained file that keeps working
11
+ when it is moved or mailed. That does mean the output carries the full weight
12
+ of every image it uses.
13
+
14
+ Cropping is done by the panel's clip path (see ``render._render_panel``), which
15
+ every panel and standalone scene already establishes.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import struct
21
+ from base64 import b64encode
22
+ from pathlib import Path
23
+
24
+ MIME = {
25
+ ".png": "image/png",
26
+ ".jpg": "image/jpeg",
27
+ ".jpeg": "image/jpeg",
28
+ ".gif": "image/gif",
29
+ ".webp": "image/webp",
30
+ }
31
+
32
+ # fit name -> SVG preserveAspectRatio: 'slice' crops the overflow, 'meet' fits
33
+ # the whole image inside the box and letterboxes the remainder.
34
+ FITS = {"cover": "xMidYMid slice", "contain": "xMidYMid meet"}
35
+
36
+
37
+ def normalize(value) -> dict:
38
+ """Normalise a panel's ``image:`` value to a ``{src, fit}``-shaped dict.
39
+
40
+ Accepts the short form (``image: art/01.png``) and the long form
41
+ (``image: {src: art/01.png, fit: contain}``).
42
+ """
43
+ if isinstance(value, str):
44
+ return {"src": value}
45
+ if isinstance(value, dict):
46
+ return value
47
+ raise ValueError(
48
+ f"image must be a path or a {{src, fit}} mapping, got {type(value).__name__}"
49
+ )
50
+
51
+
52
+ def resolve(value, spec_dir: Path | None) -> Path:
53
+ """Resolve an ``image:`` value to a path, relative to the spec file's dir.
54
+
55
+ Mirrors ``render._resolve_dir``: relative paths resolve against *spec_dir*
56
+ when it is known (i.e. the spec came from a file), absolute ones are used
57
+ as-is.
58
+ """
59
+ src = normalize(value).get("src")
60
+ if not src:
61
+ raise ValueError("image entry has no 'src' (use `image: path/to/art.png`)")
62
+ p = Path(src)
63
+ if not p.is_absolute() and spec_dir is not None:
64
+ p = spec_dir / p
65
+ return p.resolve()
66
+
67
+
68
+ def data_uri(path: Path) -> str:
69
+ """Read *path* and return it as a base64 ``data:`` URI."""
70
+ mime = MIME.get(path.suffix.lower())
71
+ if mime is None:
72
+ raise ValueError(
73
+ f"unsupported image type '{path.suffix}' for {path}. "
74
+ f"Supported: {sorted(MIME)}"
75
+ )
76
+ return f"data:{mime};base64,{b64encode(path.read_bytes()).decode('ascii')}"
77
+
78
+
79
+ def size(path: Path) -> tuple[int, int]:
80
+ """Read *path*'s pixel dimensions straight out of its header.
81
+
82
+ Only the header is parsed — no image library, so ComicForge stays a
83
+ three-dependency package. A standalone ``type: scene`` spec backed by an
84
+ ``image:`` needs this to size its canvas; a panel does not (the panel box
85
+ already has a size, and ``preserveAspectRatio`` does the fitting).
86
+ """
87
+ head = path.read_bytes()[:64]
88
+ try:
89
+ if head.startswith(b"\x89PNG\r\n\x1a\n"):
90
+ return struct.unpack(">II", head[16:24]) # IHDR width, height
91
+ if head.startswith(b"GIF8"):
92
+ return struct.unpack("<HH", head[6:10])
93
+ if head[:4] == b"RIFF" and head[8:12] == b"WEBP":
94
+ return _webp_size(head)
95
+ if head.startswith(b"\xff\xd8"):
96
+ return _jpeg_size(path)
97
+ except (struct.error, IndexError, ValueError) as e:
98
+ raise ValueError(f"cannot read image dimensions from {path}: {e}") from e
99
+ raise ValueError(f"cannot read image dimensions from {path}: unrecognised header")
100
+
101
+
102
+ def _webp_size(head: bytes) -> tuple[int, int]:
103
+ fmt = head[12:16]
104
+ if fmt == b"VP8X":
105
+ w = int.from_bytes(head[24:27], "little") + 1
106
+ h = int.from_bytes(head[27:30], "little") + 1
107
+ return w, h
108
+ if fmt == b"VP8L":
109
+ bits = int.from_bytes(head[21:25], "little")
110
+ return (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1
111
+ if fmt == b"VP8 ":
112
+ w, h = struct.unpack("<HH", head[26:30])
113
+ return w & 0x3FFF, h & 0x3FFF
114
+ raise ValueError(f"unknown WebP variant {fmt!r}")
115
+
116
+
117
+ # JPEG start-of-frame markers carry the dimensions; DHP/DAC/RSTn do not.
118
+ _SOF = {*range(0xC0, 0xD0)} - {0xC4, 0xC8, 0xCC}
119
+
120
+
121
+ def _jpeg_size(path: Path) -> tuple[int, int]:
122
+ """Walk the JPEG segment chain to the first start-of-frame marker."""
123
+ with path.open("rb") as fh:
124
+ fh.read(2) # SOI
125
+ while True:
126
+ byte = fh.read(1)
127
+ while byte == b"\xff": # markers may be padded with 0xff
128
+ byte = fh.read(1)
129
+ if not byte:
130
+ raise ValueError("no start-of-frame marker")
131
+ marker = byte[0]
132
+ (length,) = struct.unpack(">H", fh.read(2))
133
+ if marker in _SOF:
134
+ _precision, h, w = struct.unpack(">BHH", fh.read(5))
135
+ return w, h
136
+ fh.seek(length - 2, 1)
137
+
138
+
139
+ def place(value, spec_dir: Path | None, px, py, pw, ph) -> str:
140
+ """Place an ``image:`` value so it covers the (px, py, pw, ph) box, centred."""
141
+ fit = normalize(value).get("fit", "cover")
142
+ par = FITS.get(fit)
143
+ if par is None:
144
+ raise ValueError(f"unknown image fit '{fit}'. Use one of {sorted(FITS)}")
145
+ uri = data_uri(resolve(value, spec_dir))
146
+ return (
147
+ f'<image x="{px:.1f}" y="{py:.1f}" width="{pw:.1f}" height="{ph:.1f}" '
148
+ f'preserveAspectRatio="{par}" href="{uri}"/>'
149
+ )