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.
- {comicforge-0.0.4 → comicforge-0.1.0}/.claude/skills/comicforge/SKILL.md +72 -11
- {comicforge-0.0.4 → comicforge-0.1.0}/.claude/skills/comicforge/reference.md +22 -5
- {comicforge-0.0.4 → comicforge-0.1.0}/.github/workflows/ci.yml +2 -2
- {comicforge-0.0.4 → comicforge-0.1.0}/.github/workflows/release.yml +1 -1
- {comicforge-0.0.4 → comicforge-0.1.0}/PKG-INFO +2 -2
- {comicforge-0.0.4 → comicforge-0.1.0}/comicforge/bubbles.py +58 -24
- {comicforge-0.0.4 → comicforge-0.1.0}/comicforge/cli.py +1 -3
- comicforge-0.1.0/comicforge/raster.py +149 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/comicforge/render.py +164 -55
- {comicforge-0.0.4 → comicforge-0.1.0}/comicforge/validate.py +59 -5
- {comicforge-0.0.4 → comicforge-0.1.0}/pyproject.toml +1 -1
- {comicforge-0.0.4 → comicforge-0.1.0}/skills/comicforge/SKILL.md +72 -11
- {comicforge-0.0.4 → comicforge-0.1.0}/skills/comicforge/reference.md +22 -5
- comicforge-0.1.0/tests/test_raster.py +241 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/tests/test_render.py +24 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/uv.lock +1 -1
- {comicforge-0.0.4 → comicforge-0.1.0}/.github/dependabot.yml +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/.gitignore +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/.python-version +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/CLAUDE.md +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/README.md +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/comicforge/__init__.py +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/comicforge/__main__.py +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/comicforge/inspire.py +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/comicforge/library.py +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/comicforge/pixelart.py +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/comicforge/scaffold.py +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/comicforge/scene.py +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/docs/starting-a-project.md +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/examples/README.md +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/examples/README.md.old +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/bara/character.yaml +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/bara/face-happy.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/bara/face-neutral.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/bara/poses/sit/base.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/bara/poses/sit/pose.yaml +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/bara/poses/walk/base.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/bara/poses/walk/pose.yaml +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/tom/arms-crossed.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/tom/arms-down.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/tom/arms-hips.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/tom/arms-point.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/tom/arms-thumbsup.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/tom/arms-wave.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/tom/base.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/tom/character.yaml +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/tom/face-angry.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/tom/face-happy.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/tom/face-laugh.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/tom/face-neutral.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/tom/face-sad.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/tom/face-surprised.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/characters/tom/face-wink.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/pages/dvur-scene.yaml +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/pages/kosticka.yaml +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/pages/slepice.yaml +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/pixel/bone.yaml +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/pixel/heart.yaml +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/pixel/star.yaml +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/pixel/sun.yaml +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/references.yaml +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/scenes/dvur/base.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/scenes/dvur/scene.yaml +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/scenes/dvur/weather-clear.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/scenes/dvur/weather-rain.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/scenes/pokoj/base.svg +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/scenes/pokoj/scene.yaml +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/examples/pes/theme.yaml +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/poe_tasks.toml +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/tests/__init__.py +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/tests/conftest.py +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/tests/test_bubbles.py +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/tests/test_cli.py +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/tests/test_inspire.py +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/tests/test_library.py +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/tests/test_pixelart.py +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/tests/test_scaffold.py +0 -0
- {comicforge-0.0.4 → comicforge-0.1.0}/tests/test_scene.py +0 -0
- {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
|
|
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`,
|
|
@@ -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
|
|
129
|
-
covers and single panels. Render with
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
@@ -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`,
|
|
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@
|
|
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
|
|
|
@@ -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
|
-
|
|
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 = (
|
|
57
|
-
|
|
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 = (
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
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 += (
|
|
89
|
-
|
|
90
|
-
|
|
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 = (
|
|
110
|
-
|
|
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 += (
|
|
116
|
-
|
|
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 = (
|
|
119
|
-
|
|
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 (
|
|
133
|
-
|
|
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
|
+
)
|