comicforge 0.0.1__py3-none-any.whl
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/__init__.py +10 -0
- comicforge/__main__.py +5 -0
- comicforge/_skill/SKILL.md +325 -0
- comicforge/_skill/reference.md +644 -0
- comicforge/bubbles.py +133 -0
- comicforge/cli.py +396 -0
- comicforge/inspire.py +246 -0
- comicforge/library.py +235 -0
- comicforge/pixelart.py +80 -0
- comicforge/render.py +560 -0
- comicforge/scaffold.py +144 -0
- comicforge/scene.py +112 -0
- comicforge/validate.py +192 -0
- comicforge-0.0.1.dist-info/METADATA +136 -0
- comicforge-0.0.1.dist-info/RECORD +17 -0
- comicforge-0.0.1.dist-info/WHEEL +4 -0
- comicforge-0.0.1.dist-info/entry_points.txt +3 -0
comicforge/render.py
ADDED
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
"""Render a comic spec (dict or YAML) into SVG, PNG, and PDF.
|
|
2
|
+
|
|
3
|
+
Spec shape (all panel-relative coords are fractions 0..1 of the panel):
|
|
4
|
+
|
|
5
|
+
title: "..." # optional caption strip at top
|
|
6
|
+
page: A4 # A4 (default) or [w_mm, h_mm]
|
|
7
|
+
px_per_mm: 4 # render scale
|
|
8
|
+
margin_mm: 12
|
|
9
|
+
gutter_mm: 5
|
|
10
|
+
library: "../characters" # path to character dir
|
|
11
|
+
scenes_dir: "../scenes" # path to scenes dir
|
|
12
|
+
pixel_dir: "../pixel" # path to pixel-art dir
|
|
13
|
+
rows:
|
|
14
|
+
- height: 1.0 # relative weight (optional, default 1)
|
|
15
|
+
panels:
|
|
16
|
+
- bg: "#fbfaf6" # optional panel background
|
|
17
|
+
actors:
|
|
18
|
+
- char: tom
|
|
19
|
+
pose: walk # optional; defaults to character's default
|
|
20
|
+
face: happy # any slot -> variant
|
|
21
|
+
arms: wave
|
|
22
|
+
x: 0.35 y: 0.62 # centre, panel fraction
|
|
23
|
+
scale: 0.85 # height as fraction of panel height
|
|
24
|
+
flip: false
|
|
25
|
+
pixel: # optional, one per panel (or a list)
|
|
26
|
+
- art: heart
|
|
27
|
+
x: 0.8 y: 0.25 scale: 0.18
|
|
28
|
+
bubbles:
|
|
29
|
+
- text: "Ahoj!"
|
|
30
|
+
kind: speech # speech | thought | shout
|
|
31
|
+
speaker: tom # auto-place above this actor + aim the tail
|
|
32
|
+
# at their head; overrides below are optional
|
|
33
|
+
x: 0.5 y: 0.2 # explicit centre (else derived from speaker)
|
|
34
|
+
to: [0.4, 0.5] # explicit tail target (else the speaker's head)
|
|
35
|
+
|
|
36
|
+
When several bubbles in a panel omit `y`, they stack downward from the top so
|
|
37
|
+
they never overlap; omit `x` too and each sits above its own speaker.
|
|
38
|
+
|
|
39
|
+
PATH RESOLUTION
|
|
40
|
+
Relative paths in the spec (``library:``, ``scenes_dir:``, ``pixel_dir:``) are
|
|
41
|
+
resolved against the **spec file's directory** when the spec is loaded via a
|
|
42
|
+
path. CLI flags and absolute paths are used as-is.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
from pathlib import Path
|
|
48
|
+
|
|
49
|
+
import cairosvg
|
|
50
|
+
import yaml
|
|
51
|
+
|
|
52
|
+
from . import pixelart
|
|
53
|
+
from .bubbles import FONT, INK, bubble
|
|
54
|
+
from .library import Library
|
|
55
|
+
from .pixelart import PixelLibrary
|
|
56
|
+
from .scene import Scene, SceneLibrary
|
|
57
|
+
from .scene import cover as scene_cover
|
|
58
|
+
|
|
59
|
+
PAGE = {"A4": (210, 297), "A5": (148, 210), "letter": (216, 279)}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def load_spec(path: str | Path) -> dict:
|
|
63
|
+
return yaml.safe_load(Path(path).read_text(encoding="utf-8"))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
SPEC_TYPES = ("page", "scene")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def spec_type(spec: dict) -> str:
|
|
70
|
+
"""Return ``'page'`` or ``'scene'`` for a spec.
|
|
71
|
+
|
|
72
|
+
A ``page`` is a comic grid (``rows`` of ``panels``, rendered with
|
|
73
|
+
``comicforge render``); a ``scene`` is a single illustration filling the
|
|
74
|
+
canvas (top-level ``scene``, rendered with ``comicforge scene``).
|
|
75
|
+
|
|
76
|
+
An explicit ``type:`` key wins; otherwise the type is inferred from
|
|
77
|
+
structure (top-level ``scene`` and no ``rows`` == a scene), so specs written
|
|
78
|
+
before ``type:`` existed keep working.
|
|
79
|
+
"""
|
|
80
|
+
declared = spec.get("type")
|
|
81
|
+
if declared is not None:
|
|
82
|
+
if declared not in SPEC_TYPES:
|
|
83
|
+
raise ValueError(
|
|
84
|
+
f"unknown spec type {declared!r}; use one of {list(SPEC_TYPES)}"
|
|
85
|
+
)
|
|
86
|
+
return declared
|
|
87
|
+
return "scene" if "scene" in spec and "rows" not in spec else "page"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _resolve_dir(value: str | Path | None, spec_dir: Path | None) -> Path | None:
|
|
91
|
+
"""Resolve an asset-dir value that may be relative.
|
|
92
|
+
|
|
93
|
+
If *value* is a relative path and *spec_dir* is known, it is resolved
|
|
94
|
+
against *spec_dir*. Absolute paths and ``None`` are returned unchanged.
|
|
95
|
+
"""
|
|
96
|
+
if value is None:
|
|
97
|
+
return None
|
|
98
|
+
p = Path(value)
|
|
99
|
+
if not p.is_absolute() and spec_dir is not None:
|
|
100
|
+
return (spec_dir / p).resolve()
|
|
101
|
+
return p
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _require_dir(path: Path | None, label: str) -> Path:
|
|
105
|
+
"""Raise a clear error when a required asset directory is missing."""
|
|
106
|
+
if path is None:
|
|
107
|
+
raise ValueError(
|
|
108
|
+
f"{label} directory is required but was not provided. "
|
|
109
|
+
f"Set '{label}:' in the spec or pass the corresponding CLI flag."
|
|
110
|
+
)
|
|
111
|
+
if not path.is_dir():
|
|
112
|
+
raise ValueError(f"{label} directory does not exist: {path}")
|
|
113
|
+
return path
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class _NullSceneLibrary:
|
|
117
|
+
"""Placeholder used when no scenes_dir is configured."""
|
|
118
|
+
|
|
119
|
+
def get(self, name: str) -> Scene:
|
|
120
|
+
raise KeyError(
|
|
121
|
+
f"scene '{name}' requested but no scenes_dir was provided. "
|
|
122
|
+
"Set 'scenes_dir:' in the spec or pass --scenes on the CLI."
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def manifest(self) -> dict:
|
|
126
|
+
return {}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _panels(rows, x0, y0, W, H, gutter):
|
|
130
|
+
"""Yield (row_idx, col_idx, panel_dict, px, py, pw, ph)."""
|
|
131
|
+
wsum = sum(r.get("height", 1) for r in rows)
|
|
132
|
+
avail_h = H - gutter * (len(rows) - 1)
|
|
133
|
+
cy = y0
|
|
134
|
+
for ri, row in enumerate(rows):
|
|
135
|
+
ph = avail_h * row.get("height", 1) / wsum
|
|
136
|
+
cols = row["panels"]
|
|
137
|
+
cw_sum = sum(c.get("width", 1) for c in cols)
|
|
138
|
+
avail_w = W - gutter * (len(cols) - 1)
|
|
139
|
+
cx = x0
|
|
140
|
+
for ci, panel in enumerate(cols):
|
|
141
|
+
pw = avail_w * panel.get("width", 1) / cw_sum
|
|
142
|
+
yield ri, ci, panel, cx, cy, pw, ph
|
|
143
|
+
cx += pw + gutter
|
|
144
|
+
cy += ph + gutter
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _build_libs(
|
|
148
|
+
spec: dict,
|
|
149
|
+
spec_dir: Path | None,
|
|
150
|
+
library: Library | None,
|
|
151
|
+
scenes: SceneLibrary | _NullSceneLibrary | None,
|
|
152
|
+
pixel_library: PixelLibrary | None,
|
|
153
|
+
) -> tuple[Library, SceneLibrary | _NullSceneLibrary, PixelLibrary | None]:
|
|
154
|
+
"""Resolve / build the three asset libraries from spec keys + overrides."""
|
|
155
|
+
if library is None:
|
|
156
|
+
lib_path = _resolve_dir(spec.get("library"), spec_dir)
|
|
157
|
+
lib_path = _require_dir(lib_path, "library")
|
|
158
|
+
library = Library(lib_path)
|
|
159
|
+
if scenes is None:
|
|
160
|
+
sc_path = _resolve_dir(spec.get("scenes_dir"), spec_dir)
|
|
161
|
+
# scenes are optional — only required when a panel actually uses a scene
|
|
162
|
+
scenes = SceneLibrary(sc_path) if sc_path is not None else _NullSceneLibrary()
|
|
163
|
+
if pixel_library is None:
|
|
164
|
+
px_path = _resolve_dir(spec.get("pixel_dir"), spec_dir)
|
|
165
|
+
if px_path is not None:
|
|
166
|
+
pixel_library = PixelLibrary(px_path)
|
|
167
|
+
# else remains None — inline {grid, palette} still works
|
|
168
|
+
return library, scenes, pixel_library
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def build_svg(
|
|
172
|
+
spec: dict,
|
|
173
|
+
library: Library | None = None,
|
|
174
|
+
scenes: SceneLibrary | None = None,
|
|
175
|
+
pixel_library: PixelLibrary | None = None,
|
|
176
|
+
spec_dir: Path | None = None,
|
|
177
|
+
) -> str:
|
|
178
|
+
if spec_type(spec) == "scene":
|
|
179
|
+
raise ValueError(
|
|
180
|
+
"this is a 'scene' spec (single illustration) — render it with "
|
|
181
|
+
"`comicforge scene` instead of `render`."
|
|
182
|
+
)
|
|
183
|
+
lib, scn, pxlib = _build_libs(spec, spec_dir, library, scenes, pixel_library)
|
|
184
|
+
page = spec.get("page", "A4")
|
|
185
|
+
w_mm, h_mm = PAGE[page] if isinstance(page, str) else page
|
|
186
|
+
k = spec.get("px_per_mm", 4)
|
|
187
|
+
W, H = w_mm * k, h_mm * k
|
|
188
|
+
margin = spec.get("margin_mm", 12) * k
|
|
189
|
+
gutter = spec.get("gutter_mm", 5) * k
|
|
190
|
+
|
|
191
|
+
parts = [
|
|
192
|
+
f'<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" '
|
|
193
|
+
f'viewBox="0 0 {W} {H}">',
|
|
194
|
+
f'<rect width="{W}" height="{H}" fill="#ffffff"/>',
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
top = margin
|
|
198
|
+
title = spec.get("title")
|
|
199
|
+
if title:
|
|
200
|
+
ts = 26
|
|
201
|
+
parts.append(
|
|
202
|
+
f'<text x="{W / 2}" y="{margin + ts}" text-anchor="middle" '
|
|
203
|
+
f'font-family="{FONT}" font-size="{ts}" font-weight="bold" '
|
|
204
|
+
f'fill="{INK}">{title}</text>'
|
|
205
|
+
)
|
|
206
|
+
top = margin + ts + 14
|
|
207
|
+
|
|
208
|
+
grid_x, grid_y = margin, top
|
|
209
|
+
grid_w, grid_h = W - 2 * margin, H - top - margin
|
|
210
|
+
|
|
211
|
+
for _ri, _ci, panel, px, py, pw, ph in _panels(
|
|
212
|
+
spec["rows"], grid_x, grid_y, grid_w, grid_h, gutter
|
|
213
|
+
):
|
|
214
|
+
parts.append(_render_panel(panel, px, py, pw, ph, lib, scn, pxlib))
|
|
215
|
+
|
|
216
|
+
parts.append("</svg>")
|
|
217
|
+
return "\n".join(parts)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _layout(spec):
|
|
221
|
+
"""Yield (row, col, panel, px, py, pw, ph) for every panel, using the same
|
|
222
|
+
page metrics as build_svg."""
|
|
223
|
+
page = spec.get("page", "A4")
|
|
224
|
+
w_mm, h_mm = PAGE[page] if isinstance(page, str) else page
|
|
225
|
+
k = spec.get("px_per_mm", 4)
|
|
226
|
+
W, H = w_mm * k, h_mm * k
|
|
227
|
+
margin = spec.get("margin_mm", 12) * k
|
|
228
|
+
gutter = spec.get("gutter_mm", 5) * k
|
|
229
|
+
top = margin + (26 + 14 if spec.get("title") else 0)
|
|
230
|
+
yield from _panels(
|
|
231
|
+
spec["rows"], margin, top, W - 2 * margin, H - top - margin, gutter
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def build_panel_svg(
|
|
236
|
+
spec,
|
|
237
|
+
row,
|
|
238
|
+
col,
|
|
239
|
+
library=None,
|
|
240
|
+
scenes=None,
|
|
241
|
+
scale=1.0,
|
|
242
|
+
pixel_library=None,
|
|
243
|
+
spec_dir=None,
|
|
244
|
+
) -> str:
|
|
245
|
+
"""Render a single panel standalone, at `scale` x its full-page pixel size
|
|
246
|
+
(use scale < 1 for a quick low-res review render)."""
|
|
247
|
+
lib, scn, pxlib = _build_libs(spec, spec_dir, library, scenes, pixel_library)
|
|
248
|
+
for ri, ci, panel, _px, _py, pw, ph in _layout(spec):
|
|
249
|
+
if ri == row and ci == col:
|
|
250
|
+
# Render the panel body at full page size so absolute-sized elements
|
|
251
|
+
# (bubble text) keep the same proportions as the whole-page render;
|
|
252
|
+
# `scale` only shrinks the rasterized output via width/height, leaving
|
|
253
|
+
# the viewBox full-size so everything scales uniformly.
|
|
254
|
+
ow, oh = pw * scale, ph * scale
|
|
255
|
+
body = _render_panel(panel, 0, 0, pw, ph, lib, scn, pxlib)
|
|
256
|
+
return (
|
|
257
|
+
f'<svg xmlns="http://www.w3.org/2000/svg" width="{ow:.0f}" '
|
|
258
|
+
f'height="{oh:.0f}" viewBox="0 0 {pw:.1f} {ph:.1f}">\n'
|
|
259
|
+
f"{body}\n</svg>"
|
|
260
|
+
)
|
|
261
|
+
raise ValueError(f"no panel at row {row}, col {col}")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def build_scene_svg(
|
|
265
|
+
spec: dict,
|
|
266
|
+
library: Library | None = None,
|
|
267
|
+
scenes: SceneLibrary | None = None,
|
|
268
|
+
pixel_library: PixelLibrary | None = None,
|
|
269
|
+
spec_dir: Path | None = None,
|
|
270
|
+
) -> str:
|
|
271
|
+
"""Render a standalone illustration: one scene filling the whole canvas,
|
|
272
|
+
with actors / pixel art / bubbles on top. No comic grid, no panel border.
|
|
273
|
+
|
|
274
|
+
Spec shape: ``scene`` (name or {name, <slot>: <variant>}), optional
|
|
275
|
+
``scale`` (px per scene unit, default 4), plus ``actors`` / ``pixel`` /
|
|
276
|
+
``bubbles`` like a single panel, and an optional ``title``.
|
|
277
|
+
"""
|
|
278
|
+
if spec_type(spec) == "page":
|
|
279
|
+
raise ValueError(
|
|
280
|
+
"this is a 'page' spec (comic grid) — render it with "
|
|
281
|
+
"`comicforge render` instead of `scene`."
|
|
282
|
+
)
|
|
283
|
+
lib, scn, pxlib = _build_libs(spec, spec_dir, library, scenes, pixel_library)
|
|
284
|
+
sc = spec["scene"]
|
|
285
|
+
name = sc if isinstance(sc, str) else sc["name"]
|
|
286
|
+
scene = scn.get(name)
|
|
287
|
+
scale = spec.get("scale", 4)
|
|
288
|
+
w, h = scene.w * scale, scene.h * scale
|
|
289
|
+
body = _render_panel(spec, 0, 0, w, h, lib, scn, pxlib, border=False)
|
|
290
|
+
parts = [
|
|
291
|
+
f'<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" '
|
|
292
|
+
f'viewBox="0 0 {w} {h}">',
|
|
293
|
+
body,
|
|
294
|
+
]
|
|
295
|
+
title = spec.get("title")
|
|
296
|
+
if title:
|
|
297
|
+
ts = 22
|
|
298
|
+
parts.append(
|
|
299
|
+
f'<text x="{w / 2}" y="{ts + 8}" text-anchor="middle" '
|
|
300
|
+
f'font-family="{FONT}" font-size="{ts}" font-weight="bold" '
|
|
301
|
+
f'fill="{INK}">{title}</text>'
|
|
302
|
+
)
|
|
303
|
+
parts.append("</svg>")
|
|
304
|
+
return "\n".join(parts)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def build_character_svg(
|
|
308
|
+
name: str,
|
|
309
|
+
selection: dict[str, str],
|
|
310
|
+
pose: str | None = None,
|
|
311
|
+
*,
|
|
312
|
+
library: Library,
|
|
313
|
+
scale: float = 2.0,
|
|
314
|
+
bg: str = "#ffffff",
|
|
315
|
+
flip: bool = False,
|
|
316
|
+
pad: float = 0.08,
|
|
317
|
+
) -> str:
|
|
318
|
+
"""Render one character standalone, cropped to its pose, for quick review.
|
|
319
|
+
|
|
320
|
+
No page, no panel grid — just the composed character on a `bg` canvas sized
|
|
321
|
+
to the pose's viewBox times `scale`, with a `pad` fraction of margin.
|
|
322
|
+
"""
|
|
323
|
+
char = library.get(name)
|
|
324
|
+
p = char.resolve_pose(pose)
|
|
325
|
+
bw, bh = p.w * scale, p.h * scale
|
|
326
|
+
m = max(bw, bh) * pad
|
|
327
|
+
w, h = bw + 2 * m, bh + 2 * m
|
|
328
|
+
inner = char.place(selection, cx=w / 2, cy=h / 2, height=bh, flip=flip, pose=pose)
|
|
329
|
+
return (
|
|
330
|
+
f'<svg xmlns="http://www.w3.org/2000/svg" width="{w:.0f}" height="{h:.0f}" '
|
|
331
|
+
f'viewBox="0 0 {w:.1f} {h:.1f}">\n'
|
|
332
|
+
f'<rect width="{w:.1f}" height="{h:.1f}" fill="{bg}"/>\n'
|
|
333
|
+
f"{inner}\n</svg>"
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def render_character(
|
|
338
|
+
name: str,
|
|
339
|
+
out_path: str | Path,
|
|
340
|
+
selection: dict[str, str],
|
|
341
|
+
pose: str | None = None,
|
|
342
|
+
*,
|
|
343
|
+
library: Library,
|
|
344
|
+
scale: float = 2.0,
|
|
345
|
+
bg: str = "#ffffff",
|
|
346
|
+
flip: bool = False,
|
|
347
|
+
) -> str:
|
|
348
|
+
"""Render a single character to .svg/.png/.pdf for review. Returns the SVG."""
|
|
349
|
+
return _write(
|
|
350
|
+
build_character_svg(
|
|
351
|
+
name, selection, pose, library=library, scale=scale, bg=bg, flip=flip
|
|
352
|
+
),
|
|
353
|
+
out_path,
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _render_panel(
|
|
358
|
+
panel, px, py, pw, ph, lib, scenes, pixel_library=None, border=True
|
|
359
|
+
) -> str:
|
|
360
|
+
bg = panel.get("bg", "#fbfaf6")
|
|
361
|
+
clip = f"clip{int(px)}_{int(py)}"
|
|
362
|
+
out = [
|
|
363
|
+
f'<clipPath id="{clip}"><rect x="{px:.1f}" y="{py:.1f}" '
|
|
364
|
+
f'width="{pw:.1f}" height="{ph:.1f}" rx="10"/></clipPath>',
|
|
365
|
+
f'<g clip-path="url(#{clip})">',
|
|
366
|
+
f'<rect x="{px:.1f}" y="{py:.1f}" width="{pw:.1f}" height="{ph:.1f}" '
|
|
367
|
+
f'fill="{bg}"/>',
|
|
368
|
+
]
|
|
369
|
+
|
|
370
|
+
def ax(fx): # panel fraction -> page px
|
|
371
|
+
return px + fx * pw
|
|
372
|
+
|
|
373
|
+
def ay(fy):
|
|
374
|
+
return py + fy * ph
|
|
375
|
+
|
|
376
|
+
# scene background (under everything)
|
|
377
|
+
sc = panel.get("scene")
|
|
378
|
+
if sc is not None:
|
|
379
|
+
if isinstance(sc, str):
|
|
380
|
+
sc = {"name": sc}
|
|
381
|
+
scene = scenes.get(sc["name"])
|
|
382
|
+
selection = {s: sc[s] for s in scene.slots if s in sc}
|
|
383
|
+
out.append(scene_cover(scene, selection, px, py, pw, ph))
|
|
384
|
+
|
|
385
|
+
# pixel art (background-ish, drawn before characters)
|
|
386
|
+
for spec in _as_list(panel.get("pixel")):
|
|
387
|
+
inner, cols, rows = pixelart.resolve(spec, pixel_library)
|
|
388
|
+
height = spec.get("scale", 0.2) * ph
|
|
389
|
+
cell = height / rows
|
|
390
|
+
w = cols * cell
|
|
391
|
+
cx = ax(spec.get("x", 0.5)) - w / 2
|
|
392
|
+
cy = ay(spec.get("y", 0.5)) - height / 2
|
|
393
|
+
out.append(
|
|
394
|
+
f'<g transform="translate({cx:.1f},{cy:.1f}) scale({cell:.3f})">{inner}</g>'
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
# actors
|
|
398
|
+
for a in panel.get("actors", []):
|
|
399
|
+
char = lib.get(a["char"])
|
|
400
|
+
pose = a.get("pose")
|
|
401
|
+
selection = {s: a[s] for s in char.slots_for(pose) if s in a}
|
|
402
|
+
out.append(
|
|
403
|
+
char.place(
|
|
404
|
+
selection,
|
|
405
|
+
cx=ax(a.get("x", 0.5)),
|
|
406
|
+
cy=ay(a.get("y", 0.6)),
|
|
407
|
+
height=a.get("scale", 0.8) * ph,
|
|
408
|
+
flip=a.get("flip", False),
|
|
409
|
+
pose=pose,
|
|
410
|
+
)
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
# bubbles (on top) — placement can be derived from `speaker`
|
|
414
|
+
actors_by_char = {}
|
|
415
|
+
for a in panel.get("actors", []):
|
|
416
|
+
actors_by_char.setdefault(a["char"], a)
|
|
417
|
+
auto_y = 0 # how many bubbles we've auto-stacked from the top
|
|
418
|
+
for b in panel.get("bubbles", []):
|
|
419
|
+
actor = actors_by_char.get(b.get("speaker")) if b.get("speaker") else None
|
|
420
|
+
# tail target: explicit `to`, else the speaker's head
|
|
421
|
+
to = b.get("to")
|
|
422
|
+
if to is None and actor is not None:
|
|
423
|
+
to = [
|
|
424
|
+
actor.get("x", 0.5),
|
|
425
|
+
max(actor.get("y", 0.6) - actor.get("scale", 0.8) * 0.42, 0.05),
|
|
426
|
+
]
|
|
427
|
+
tail = (ax(to[0]), ay(to[1])) if to else None
|
|
428
|
+
# centre: explicit x/y, else above the speaker, stacked to avoid overlap
|
|
429
|
+
bx_f = b.get("x")
|
|
430
|
+
if bx_f is None:
|
|
431
|
+
bx_f = min(max(actor.get("x", 0.5), 0.24), 0.76) if actor else 0.5
|
|
432
|
+
by_f = b.get("y")
|
|
433
|
+
if by_f is None:
|
|
434
|
+
by_f = min(0.15 + 0.17 * auto_y, 0.6)
|
|
435
|
+
auto_y += 1
|
|
436
|
+
out.append(
|
|
437
|
+
bubble(
|
|
438
|
+
b["text"],
|
|
439
|
+
ax(bx_f),
|
|
440
|
+
ay(by_f),
|
|
441
|
+
tail=tail,
|
|
442
|
+
kind=b.get("kind", "speech"),
|
|
443
|
+
max_chars=b.get("max_chars", 22),
|
|
444
|
+
fs=b.get("fs", 16),
|
|
445
|
+
)
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
out.append("</g>")
|
|
449
|
+
# crisp panel border on top of clipped content
|
|
450
|
+
if border:
|
|
451
|
+
out.append(
|
|
452
|
+
f'<rect x="{px:.1f}" y="{py:.1f}" width="{pw:.1f}" height="{ph:.1f}" '
|
|
453
|
+
f'rx="10" fill="none" stroke="{INK}" stroke-width="3.5"/>'
|
|
454
|
+
)
|
|
455
|
+
return "\n".join(out)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _as_list(v):
|
|
459
|
+
if v is None:
|
|
460
|
+
return []
|
|
461
|
+
return v if isinstance(v, list) else [v]
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _write(svg: str, out_path: str | Path) -> str:
|
|
465
|
+
out_path = Path(out_path)
|
|
466
|
+
ext = out_path.suffix.lower()
|
|
467
|
+
if ext == ".svg":
|
|
468
|
+
out_path.write_text(svg, encoding="utf-8")
|
|
469
|
+
elif ext == ".png":
|
|
470
|
+
cairosvg.svg2png(bytestring=svg.encode(), write_to=str(out_path))
|
|
471
|
+
elif ext == ".pdf":
|
|
472
|
+
cairosvg.svg2pdf(bytestring=svg.encode(), write_to=str(out_path))
|
|
473
|
+
else:
|
|
474
|
+
raise ValueError(f"unsupported output extension: {ext}")
|
|
475
|
+
return svg
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _load(spec):
|
|
479
|
+
"""Normalise a spec arg to (dict, spec_dir). Paths resolve against the spec
|
|
480
|
+
file's dir; an inline dict has no dir."""
|
|
481
|
+
if isinstance(spec, dict):
|
|
482
|
+
return spec, None
|
|
483
|
+
spec_path = Path(spec)
|
|
484
|
+
return load_spec(spec_path), spec_path.parent.resolve()
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def render_spec(
|
|
488
|
+
spec,
|
|
489
|
+
out_path: str | Path,
|
|
490
|
+
library=None,
|
|
491
|
+
scenes=None,
|
|
492
|
+
pixel_library=None,
|
|
493
|
+
):
|
|
494
|
+
"""Render a comic page to .svg/.png/.pdf by extension. Returns the SVG."""
|
|
495
|
+
spec, spec_dir = _load(spec)
|
|
496
|
+
return _write(
|
|
497
|
+
build_svg(spec, library, scenes, pixel_library, spec_dir=spec_dir),
|
|
498
|
+
out_path,
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def render_panel(
|
|
503
|
+
spec,
|
|
504
|
+
out_path,
|
|
505
|
+
row=0,
|
|
506
|
+
col=0,
|
|
507
|
+
library=None,
|
|
508
|
+
scenes=None,
|
|
509
|
+
scale=0.5,
|
|
510
|
+
pixel_library=None,
|
|
511
|
+
):
|
|
512
|
+
"""Render one panel to .svg/.png/.pdf for review. Returns the SVG."""
|
|
513
|
+
spec, spec_dir = _load(spec)
|
|
514
|
+
return _write(
|
|
515
|
+
build_panel_svg(
|
|
516
|
+
spec, row, col, library, scenes, scale, pixel_library, spec_dir=spec_dir
|
|
517
|
+
),
|
|
518
|
+
out_path,
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def render_all_panels(
|
|
523
|
+
spec,
|
|
524
|
+
out_dir,
|
|
525
|
+
library=None,
|
|
526
|
+
scenes=None,
|
|
527
|
+
scale=0.5,
|
|
528
|
+
ext=".png",
|
|
529
|
+
pixel_library=None,
|
|
530
|
+
):
|
|
531
|
+
"""Render every panel into out_dir as panel_r<R>c<C>.<ext>. Returns paths."""
|
|
532
|
+
spec, spec_dir = _load(spec)
|
|
533
|
+
out_dir = Path(out_dir)
|
|
534
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
535
|
+
outs = []
|
|
536
|
+
for ri, ci, *_ in _layout(spec):
|
|
537
|
+
p = out_dir / f"panel_r{ri}c{ci}{ext}"
|
|
538
|
+
_write(
|
|
539
|
+
build_panel_svg(
|
|
540
|
+
spec, ri, ci, library, scenes, scale, pixel_library, spec_dir=spec_dir
|
|
541
|
+
),
|
|
542
|
+
p,
|
|
543
|
+
)
|
|
544
|
+
outs.append(p)
|
|
545
|
+
return outs
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def render_scene(
|
|
549
|
+
spec,
|
|
550
|
+
out_path: str | Path,
|
|
551
|
+
library=None,
|
|
552
|
+
scenes=None,
|
|
553
|
+
pixel_library=None,
|
|
554
|
+
):
|
|
555
|
+
"""Render a standalone scene illustration. Returns the SVG."""
|
|
556
|
+
spec, spec_dir = _load(spec)
|
|
557
|
+
return _write(
|
|
558
|
+
build_scene_svg(spec, library, scenes, pixel_library, spec_dir=spec_dir),
|
|
559
|
+
out_path,
|
|
560
|
+
)
|
comicforge/scaffold.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""`comicforge init` — scaffold a data-only ComicForge project.
|
|
2
|
+
|
|
3
|
+
A downstream project needs no Python: it is a directory of YAML specs and
|
|
4
|
+
SVG / pixel art that the installed ``cmf`` CLI points at. This module lays out
|
|
5
|
+
that directory — the four asset folders, a renderable starter page, a project
|
|
6
|
+
README, and a copy of the authoring skill so an LLM can write specs for you.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
# The authoring skill ships inside the wheel (force-included at comicforge/_skill,
|
|
14
|
+
# see pyproject.toml); in a source checkout it still lives at the repo root.
|
|
15
|
+
_BUNDLED_SKILL = Path(__file__).parent / "_skill"
|
|
16
|
+
_DEV_SKILL = Path(__file__).resolve().parent.parent / "skills" / "comicforge"
|
|
17
|
+
|
|
18
|
+
_GITKEEP = "# keep this (empty) directory under version control\n"
|
|
19
|
+
|
|
20
|
+
_HEART_SPRITE = """\
|
|
21
|
+
# A starter pixel-art sprite. Reference it from a spec as {art: heart, ...}.
|
|
22
|
+
palette:
|
|
23
|
+
r: "#e8554d"
|
|
24
|
+
grid:
|
|
25
|
+
- ".rr.rr."
|
|
26
|
+
- "rrrrrrr"
|
|
27
|
+
- "rrrrrrr"
|
|
28
|
+
- ".rrrrr."
|
|
29
|
+
- "..rrr.."
|
|
30
|
+
- "...r..."
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
_STARTER_PAGE = """\
|
|
34
|
+
# Your first page. Render it with:
|
|
35
|
+
# cmf render pages/hello.yaml -> output/hello-<timestamp>.png
|
|
36
|
+
# cmf render pages/hello.yaml -o hi.pdf
|
|
37
|
+
#
|
|
38
|
+
# Everything here is plain data — this project needs no Python. Paths resolve
|
|
39
|
+
# relative to THIS file, so `library: ../characters` works from any cwd.
|
|
40
|
+
title: "Hello, ComicForge"
|
|
41
|
+
page: A5
|
|
42
|
+
library: "../characters" # add characters here, then place them as `actors`
|
|
43
|
+
pixel_dir: "../pixel"
|
|
44
|
+
|
|
45
|
+
rows:
|
|
46
|
+
- panels:
|
|
47
|
+
- bubbles:
|
|
48
|
+
- text: "Edit pages/hello.yaml to build your first page!"
|
|
49
|
+
kind: speech
|
|
50
|
+
pixel:
|
|
51
|
+
- {art: heart, x: 0.5, y: 0.72, scale: 0.32}
|
|
52
|
+
# Once you add a character (see .claude/skills/comicforge/SKILL.md),
|
|
53
|
+
# place it in a panel like this:
|
|
54
|
+
# - actors:
|
|
55
|
+
# - {char: yourchar, face: happy, x: 0.5, y: 0.7, scale: 0.85}
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
_README = """\
|
|
59
|
+
# {name} — a ComicForge project
|
|
60
|
+
|
|
61
|
+
A **data-only** ComicForge project: YAML specs + SVG / pixel art, no Python.
|
|
62
|
+
|
|
63
|
+
## Setup (once)
|
|
64
|
+
|
|
65
|
+
uv tool install comicforge # puts `cmf` / `comicforge` on your PATH
|
|
66
|
+
|
|
67
|
+
## Render
|
|
68
|
+
|
|
69
|
+
cmf render pages/hello.yaml # -> output/hello-<timestamp>.png
|
|
70
|
+
cmf render pages/hello.yaml -o hello.pdf
|
|
71
|
+
cmf validate pages/hello.yaml # check a spec without rendering
|
|
72
|
+
|
|
73
|
+
## Layout
|
|
74
|
+
|
|
75
|
+
characters/ one dir per character (base.svg + overlays + character.yaml)
|
|
76
|
+
scenes/ one dir per scene background
|
|
77
|
+
pixel/ pixel-art sprites (grid + palette)
|
|
78
|
+
pages/ the page / scene specs you render
|
|
79
|
+
output/ renders land here when you omit -o (gitignored)
|
|
80
|
+
.claude/skills/comicforge/ the authoring skill — lets Claude write specs
|
|
81
|
+
|
|
82
|
+
Paths in a spec resolve relative to the spec file, so `library: ../characters`
|
|
83
|
+
works from anywhere. See the skill for the full spec grammar, and add your own
|
|
84
|
+
characters under `characters/` to start placing actors.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
_GITIGNORE = "output/\n"
|
|
88
|
+
|
|
89
|
+
_ASSET_DIRS = ("characters", "scenes", "pixel", "pages")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def skill_src() -> Path | None:
|
|
93
|
+
"""Locate the authoring skill: bundled in the wheel, else the source tree."""
|
|
94
|
+
if _BUNDLED_SKILL.is_dir():
|
|
95
|
+
return _BUNDLED_SKILL
|
|
96
|
+
if _DEV_SKILL.is_dir():
|
|
97
|
+
return _DEV_SKILL
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _write(path: Path, text: str, *, force: bool, created: list[Path]) -> None:
|
|
102
|
+
"""Write *text* to *path* unless it exists and *force* is False."""
|
|
103
|
+
if path.exists() and not force:
|
|
104
|
+
return
|
|
105
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
path.write_text(text, encoding="utf-8")
|
|
107
|
+
created.append(path)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def init_project(dest: str | Path, *, force: bool = False) -> list[Path]:
|
|
111
|
+
"""Scaffold a data-only project at *dest*. Returns the files written.
|
|
112
|
+
|
|
113
|
+
Idempotent: existing files are left untouched unless *force* is True, so
|
|
114
|
+
re-running over a project never clobbers your art.
|
|
115
|
+
"""
|
|
116
|
+
dest = Path(dest)
|
|
117
|
+
created: list[Path] = []
|
|
118
|
+
|
|
119
|
+
for d in _ASSET_DIRS:
|
|
120
|
+
_write(dest / d / ".gitkeep", _GITKEEP, force=force, created=created)
|
|
121
|
+
|
|
122
|
+
_write(dest / "pixel" / "heart.yaml", _HEART_SPRITE, force=force, created=created)
|
|
123
|
+
_write(dest / "pages" / "hello.yaml", _STARTER_PAGE, force=force, created=created)
|
|
124
|
+
_write(
|
|
125
|
+
dest / "README.md",
|
|
126
|
+
_README.format(name=dest.resolve().name),
|
|
127
|
+
force=force,
|
|
128
|
+
created=created,
|
|
129
|
+
)
|
|
130
|
+
_write(dest / ".gitignore", _GITIGNORE, force=force, created=created)
|
|
131
|
+
|
|
132
|
+
src = skill_src()
|
|
133
|
+
if src is not None:
|
|
134
|
+
skill_dest = dest / ".claude" / "skills" / "comicforge"
|
|
135
|
+
for f in sorted(src.glob("*")):
|
|
136
|
+
if f.is_file():
|
|
137
|
+
_write(
|
|
138
|
+
skill_dest / f.name,
|
|
139
|
+
f.read_text(encoding="utf-8"),
|
|
140
|
+
force=force,
|
|
141
|
+
created=created,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
return created
|